瀏覽代碼

Merge branch 'staging'

Kristian Vos 3 年之前
父節點
當前提交
0facb3064b
共有 100 個文件被更改,包括 14021 次插入10772 次删除
  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
 .DS_Store
 *.swp
 *.swp
 .idea/
 .idea/
+.vscode/
 .vagrant/
 .vagrant/
 
 
+.env
 startRedis.cmd
 startRedis.cmd
 startMongo.cmd
 startMongo.cmd
 .database
 .database
+.db
 .redis
 .redis
-dump.rdb
+*.rdb
 npm-debug.log
 npm-debug.log
+lerna-debug.log
 
 
-# Back End
+# Backend
 backend/node_modules/
 backend/node_modules/
 backend/config/default.json
 backend/config/default.json
 
 
-# Front End
+# Frontend
+frontend/yarn-error.log
+frontend/bundle-stats.json
+frontend/bundle-report.html
 frontend/node_modules/
 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
 npm
+node_modules
 
 
 # Logs
 # Logs
 log/
 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
 # 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
 ### Our Stack
 
 
-   * NodeJS
-   * MongoDB
-   * Redis
-   * Nginx (not required)
-   * VueJS
+- NodeJS
+- MongoDB
+- Redis
+- Nginx (not required)
+- VueJS
 
 
 ### Frontend
 ### 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
 ### 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
 ## 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
 ## Getting Started
+
 Once you've installed the required tools:
 Once you've installed the required tools:
 
 
 1. `git clone https://github.com/Musare/MusareNode.git`
 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`
 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`
 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)
 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
 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
 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
    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:
    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.
 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:
 2. Create a file called `startMongo.cmd` in the main folder with the contents:
 
 
-		"C:\Program Files\MongoDB\Server\3.2\bin\mongod.exe" --dbpath "D:\Programming\HTML\MusareNode\.database"
+   "C:\Program Files\MongoDB\Server\3.2\bin\mongod.exe" --dbpath "D:\Programming\HTML\MusareNode\.database"
+
+   Make sure to adjust your paths accordingly.
 
 
-	Make sure to adjust your paths accordingly.
-	
 3. Set up the MongoDB database
 3. Set up the MongoDB database
-	
-	1. Start the database by executing the script `startMongo.cmd` you just made
-		
-	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.
 4. In the folder where you installed Redis, edit the `redis.windows.conf` file. In there, look for the property `notify-keyspace-events`. Make sure that property is uncommented and has the value `Ex`. It should look like `notify-keyspace-events Ex` when done.
 
 
 5. Create a file called `startRedis.cmd` in the main folder with the contents:
 5. Create a file called `startRedis.cmd` in the main folder with the contents:
 
 
-		"D:\Redis\redis-server.exe" "D:\Redis\redis.windows.conf" "--requirepass" "PASSWORD"
+   "D:\Redis\redis-server.exe" "D:\Redis\redis.windows.conf" "--requirepass" "PASSWORD"
 
 
-	And again, make sure that the paths lead to the proper config and executable. Replace `PASSWORD` with your Redis password.
+   And again, make sure that the paths lead to the proper config and executable. Replace `PASSWORD` with your Redis password.
 
 
-####Non-docker start servers
+### 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.
 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`
 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`
    `"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`
    `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`
    `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
 ### 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
 2. Install nodemon globally
 
 
-   `npm install nodemon -g`
+   `yarn global add nodemon`
 
 
 3. Install webpack globally
 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)
 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
 ### Calling Toasts
 
 
 You can call Toasts using our custom package, [`vue-roaster`](https://github.com/atjonathan/vue-roaster), using the following code:
 You can call Toasts using our custom package, [`vue-roaster`](https://github.com/atjonathan/vue-roaster), using the following code:
 
 
 ```js
 ```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 apt-get update
 
 
 RUN npm install -g nodemon
 RUN npm install -g nodemon
+RUN npm install -g snyk
 
 
 RUN mkdir -p /opt
 RUN mkdir -p /opt
 WORKDIR /opt
 WORKDIR /opt
@@ -12,4 +13,4 @@ RUN npm install
 
 
 EXPOSE 80
 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,
 	"frontendPort": 80,
-	"serverDomain": "",
+	"serverDomain": "http://localhost:8080",
   	"serverPort": 8080,
   	"serverPort": 8080,
-  	"isDocker": true,
+	"isDocker": true,
+	"fancyConsole": true,
 	"apis": {
 	"apis": {
 		"youtube": {
 		"youtube": {
 			"key": ""
 			"key": ""
@@ -25,7 +27,17 @@
 		"mailgun": {
 		"mailgun": {
 			"key": "",
 			"key": "",
 			"domain": "",
 			"domain": "",
-		  	"enabled": true
+		  	"enabled": false
+		},
+		"spotify": {
+			"client": "",
+			"secret": "",
+			"enabled": false
+		},
+		"discogs": {
+			"client": "",
+			"secret": "",
+			"enabled": false
 		}
 		}
 	},
 	},
 	"cors": {
 	"cors": {
@@ -40,10 +52,11 @@
 	    "password": "PASSWORD"
 	    "password": "PASSWORD"
 	},
 	},
   	"mongo": {
   	"mongo": {
-	  	"url": "mongodb://musare:PASSWORD@mongo:27017/musare"
+	  	"url": "mongodb://musare:OTHER_PASSWORD_HERE@mongo:27017/musare"
 	},
 	},
   	"cookie": {
   	"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';
 'use strict';
 
 
+const util = require("util");
+
 process.env.NODE_CONFIG_DIR = `${__dirname}/config`;
 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 => {
 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}`);
 	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';
 '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 = {
 module.exports = {
 
 
@@ -34,9 +37,11 @@ module.exports = {
 			(res, body, next) => {
 			(res, body, next) => {
 				next(null, JSON.parse(body));
 				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}"`);
 				logger.error("APIS_SEARCH_YOUTUBE", `Searching youtube failed with query "${query}". "${err}"`);
 				return cb({status: 'failure', message: err});
 				return cb({status: 'failure', message: err});
 			}
 			}
@@ -53,17 +58,59 @@ module.exports = {
 	 * @param artist - an artist for that song
 	 * @param artist - an artist for that song
 	 * @param cb
 	 * @param cb
 	 */
 	 */
-	getSpotifySongs: hooks.adminRequired((session, title, artist, cb, userId) => {
+	getSpotifySongs: hooks.adminRequired((session, title, artist, cb) => {
 		async.waterfall([
 		async.waterfall([
 			(next) => {
 			(next) => {
 				utils.getSongsFromSpotify(title, artist, next);
 				utils.getSongsFromSpotify(title, artist, next);
 			}
 			}
 		], (songs) => {
 		], (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});
 			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
 	 * Joins a room
 	 *
 	 *
@@ -86,7 +133,7 @@ module.exports = {
 	 * @param cb
 	 * @param cb
 	 */
 	 */
 	joinAdminRoom: hooks.adminRequired((session, page, 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}`);
 			utils.socketJoinRoom(session.socketId, `admin.${page}`);
 		}
 		}
 		cb({});
 		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 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) {
 module.exports = function(next) {
 	return function(session) {
 	return function(session) {
 		let args = [];
 		let args = [];
@@ -23,14 +26,13 @@ module.exports = function(next) {
 				if (user.role !== 'admin') return next('Insufficient permissions.');
 				if (user.role !== 'admin') return next('Insufficient permissions.');
 				next();
 				next();
 			}
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.info("ADMIN_REQUIRED", `User failed to pass admin required check. "${err}"`);
 				logger.info("ADMIN_REQUIRED", `User failed to pass admin required check. "${err}"`);
 				return cb({status: 'failure', message: err});
 				return cb({status: 'failure', message: err});
 			}
 			}
 			logger.info("ADMIN_REQUIRED", `User "${session.userId}" passed admin required check.`, false);
 			logger.info("ADMIN_REQUIRED", `User "${session.userId}" passed admin required check.`, false);
-			args.push(session.userId);
 			next.apply(null, args);
 			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 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) {
 module.exports = function(next) {
 	return function(session) {
 	return function(session) {
 		let args = [];
 		let args = [];
@@ -17,14 +20,13 @@ module.exports = function(next) {
 				this.session = session;
 				this.session = session;
 				next();
 				next();
 			}
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.info("LOGIN_REQUIRED", `User failed to pass login required check.`);
 				logger.info("LOGIN_REQUIRED", `User failed to pass login required check.`);
 				return cb({status: 'failure', message: err});
 				return cb({status: 'failure', message: err});
 			}
 			}
 			logger.info("LOGIN_REQUIRED", `User "${session.userId}" passed login required check.`, false);
 			logger.info("LOGIN_REQUIRED", `User "${session.userId}" passed login required check.`, false);
-			args.push(session.userId);
 			next.apply(null, args);
 			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 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) {
 module.exports = function(next) {
 	return function(session, stationId) {
 	return function(session, stationId) {
@@ -29,14 +32,13 @@ module.exports = function(next) {
 				if (station.type === 'community' && station.owner === session.userId) return next(true);
 				if (station.type === 'community' && station.owner === session.userId) return next(true);
 				next('Invalid permissions.');
 				next('Invalid permissions.');
 			}
 			}
-		], (err) => {
+		], async (err) => {
 			if (err !== true) {
 			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}"`);
 				logger.info("OWNER_REQUIRED", `User failed to pass owner required check for station "${stationId}". "${err}"`);
 				return cb({status: 'failure', message: err});
 				return cb({status: 'failure', message: err});
 			}
 			}
 			logger.info("OWNER_REQUIRED", `User "${session.userId}" passed owner required check for station "${stationId}"`, false);
 			logger.info("OWNER_REQUIRED", `User "${session.userId}" passed owner required check for station "${stationId}"`, false);
-			args.push(session.userId);
 			next.apply(null, args);
 			next.apply(null, args);
 		});
 		});
 	}
 	}

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

@@ -2,11 +2,13 @@
 
 
 const async = require('async');
 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 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 => {
 cache.sub('news.create', news => {
 	utils.socketsFromUser(news.createdBy, sockets => {
 	utils.socketsFromUser(news.createdBy, sockets => {
@@ -45,9 +47,9 @@ module.exports = {
 			(next) => {
 			(next) => {
 				db.models.news.find({}).sort({ createdAt: 'desc' }).exec(next);
 				db.models.news.find({}).sort({ createdAt: 'desc' }).exec(next);
 			}
 			}
-		], (err, news) => {
+		], async (err, news) => {
 			if (err) {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("NEWS_INDEX", `Indexing news failed. "${err}"`);
 				logger.error("NEWS_INDEX", `Indexing news failed. "${err}"`);
 				return cb({status: 'failure', message: 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} session - the session object automatically added by socket.io
 	 * @param {Object} data - the object of the news data
 	 * @param {Object} data - the object of the news data
 	 * @param {Function} cb - gets called with the result
 	 * @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([
 		async.waterfall([
 			(next) => {
 			(next) => {
-				data.createdBy = userId;
+				data.createdBy = session.userId;
 				data.createdAt = Date.now();
 				data.createdAt = Date.now();
 				db.models.news.create(data, next);
 				db.models.news.create(data, next);
 			}
 			}
-		], (err, news) => {
+		], async (err, news) => {
 			if (err) {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("NEWS_CREATE", `Creating news failed. "${err}"`);
 				logger.error("NEWS_CREATE", `Creating news failed. "${err}"`);
 				return cb({ 'status': 'failure', 'message': err });
 				return cb({ 'status': 'failure', 'message': err });
 			}
 			}
@@ -94,9 +95,9 @@ module.exports = {
 			(next) => {
 			(next) => {
 				db.models.news.findOne({}).sort({ createdAt: 'desc' }).exec(next);
 				db.models.news.findOne({}).sort({ createdAt: 'desc' }).exec(next);
 			}
 			}
-		], (err, news) => {
+		], async (err, news) => {
 			if (err) {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("NEWS_NEWEST", `Getting the latest news failed. "${err}"`);
 				logger.error("NEWS_NEWEST", `Getting the latest news failed. "${err}"`);
 				return cb({ 'status': 'failure', 'message': err });
 				return cb({ 'status': 'failure', 'message': err });
 			}
 			}
@@ -114,15 +115,15 @@ module.exports = {
 	 */
 	 */
 	//TODO Pass in an id, not an object
 	//TODO Pass in an id, not an object
 	//TODO Fix this
 	//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) {
 			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 });
 				return cb({ 'status': 'failure', 'message': err });
 			} else {
 			} else {
 				cache.pub('news.remove', news);
 				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' });
 				return cb({ 'status': 'success', 'message': 'Successfully removed News' });
 			}
 			}
 		});
 		});
@@ -137,15 +138,15 @@ module.exports = {
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
 	 */
 	 */
 	//TODO Fix this
 	//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) {
 			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 });
 				return cb({ 'status': 'failure', 'message': err });
 			} else {
 			} else {
 				cache.pub('news.update', news);
 				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' });
 				return cb({ 'status': 'success', 'message': 'Successfully updated News' });
 			}
 			}
 		});
 		});

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

@@ -1,14 +1,16 @@
 'use strict';
 '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 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 => {
 cache.sub('playlist.create', playlistId => {
 	playlists.getPlaylist(playlistId, (err, playlist) => {
 	playlists.getPlaylist(playlistId, (err, playlist) => {
@@ -78,25 +80,24 @@ let lib = {
 	 * @param {Object} session - the session object automatically added by socket.io
 	 * @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 {String} playlistId - the id of the playlist we are getting the first song from
 	 * @param {Function} cb - gets called with the result
 	 * @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([
 		async.waterfall([
 			(next) => {
 			(next) => {
 				playlists.getPlaylist(playlistId, next);
 				playlists.getPlaylist(playlistId, next);
 			},
 			},
 
 
 			(playlist, 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]);
 				next(null, playlist.songs[0]);
 			}
 			}
-		], (err, song) => {
+		], async (err, song) => {
 			if (err) {
 			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});
 				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({
 			cb({
 				status: 'success',
 				status: 'success',
 				song: song
 				song: song
@@ -109,20 +110,19 @@ let lib = {
 	 *
 	 *
 	 * @param {Object} session - the session object automatically added by socket.io
 	 * @param {Object} session - the session object automatically added by socket.io
 	 * @param {Function} cb - gets called with the result
 	 * @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([
 		async.waterfall([
 			(next) => {
 			(next) => {
-				db.models.playlist.find({ createdBy: userId }, next);
+				db.models.playlist.find({ createdBy: session.userId }, next);
 			}
 			}
-		], (err, playlists) => {
+		], async (err, playlists) => {
 			if (err) {
 			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});
 				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({
 			cb({
 				status: 'success',
 				status: 'success',
 				data: playlists
 				data: playlists
@@ -136,9 +136,8 @@ let lib = {
 	 * @param {Object} session - the session object automatically added by socket.io
 	 * @param {Object} session - the session object automatically added by socket.io
 	 * @param {Object} data - the data for the new private playlist
 	 * @param {Object} data - the data for the new private playlist
 	 * @param {Function} cb - gets called with the result
 	 * @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([
 		async.waterfall([
 
 
 			(next) => {
 			(next) => {
@@ -150,20 +149,22 @@ let lib = {
 				db.models.playlist.create({
 				db.models.playlist.create({
 					displayName,
 					displayName,
 					songs,
 					songs,
-					createdBy: userId,
+					createdBy: session.userId,
 					createdAt: Date.now()
 					createdAt: Date.now()
 				}, next);
 				}, next);
 			}
 			}
 
 
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
 			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});
 				return cb({ status: 'failure', message: err});
 			}
 			}
 			cache.pub('playlist.create', playlist._id);
 			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 {Object} session - the session object automatically added by socket.io
 	 * @param {String} playlistId - the id of the playlist we are getting
 	 * @param {String} playlistId - the id of the playlist we are getting
 	 * @param {Function} cb - gets called with the result
 	 * @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([
 		async.waterfall([
 			(next) => {
 			(next) => {
 				playlists.getPlaylist(playlistId, next);
 				playlists.getPlaylist(playlistId, next);
 			},
 			},
 
 
 			(playlist, 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);
 				next(null, playlist);
 			}
 			}
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
 			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});
 				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({
 			cb({
 				status: 'success',
 				status: 'success',
 				data: playlist
 				data: playlist
@@ -207,24 +207,23 @@ let lib = {
 	 * @param {String} playlistId - the id of the playlist we are updating
 	 * @param {String} playlistId - the id of the playlist we are updating
 	 * @param {Object} playlist - the new private playlist object
 	 * @param {Object} playlist - the new private playlist object
 	 * @param {Function} cb - gets called with the result
 	 * @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([
 		async.waterfall([
 			(next) => {
 			(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) => {
 			(res, next) => {
 				playlists.updatePlaylist(playlistId, next)
 				playlists.updatePlaylist(playlistId, next)
 			}
 			}
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
 			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});
 				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({
 			cb({
 				status: 'success',
 				status: 'success',
 				data: playlist
 				data: playlist
@@ -239,13 +238,12 @@ let lib = {
 	 * @param {String} songId - the id of the song we are trying to add
 	 * @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 {String} playlistId - the id of the playlist we are adding the song to
 	 * @param {Function} cb - gets called with the result
 	 * @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([
 		async.waterfall([
 			(next) => {
 			(next) => {
 				playlists.getPlaylist(playlistId, (err, playlist) => {
 				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) => {
 					async.each(playlist.songs, (song, next) => {
 						if (song.songId === songId) return next('That song is already in the playlist');
 						if (song.songId === songId) return next('That song is already in the playlist');
@@ -270,7 +268,7 @@ let lib = {
 				});
 				});
 			},
 			},
 			(newSong, next) => {
 			(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);
 					if (err) return next(err);
 					playlists.updatePlaylist(playlistId, (err, playlist) => {
 					playlists.updatePlaylist(playlistId, (err, playlist) => {
 						next(err, playlist, newSong);
 						next(err, playlist, newSong);
@@ -278,14 +276,14 @@ let lib = {
 				});
 				});
 			}
 			}
 		],
 		],
-		(err, playlist, newSong) => {
+		async (err, playlist, newSong) => {
 			if (err) {
 			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});
 				return cb({ status: 'failure', message: err});
 			} else {
 			} 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 });
 				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} 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 {String} playlistId - the id of the playlist we are adding the set of songs to
 	 * @param {Function} cb - gets called with the result
 	 * @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([
 		async.waterfall([
 			(next) => {
 			(next) => {
 				utils.getPlaylistFromYouTube(url, songs => {
 				utils.getPlaylistFromYouTube(url, songs => {
@@ -324,16 +321,16 @@ let lib = {
 			},
 			},
 
 
 			(playlist, 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);
 				next(null, playlist);
 			}
 			}
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
 			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});
 				return cb({ status: 'failure', message: err});
 			} else {
 			} 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 });
 				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} 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 {String} playlistId - the id of the playlist we are removing the song from
 	 * @param {Function} cb - gets called with the result
 	 * @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([
 		async.waterfall([
 			(next) => {
 			(next) => {
 				if (!songId || typeof songId !== 'string') return next('Invalid song id.');
 				if (!songId || typeof songId !== 'string') return next('Invalid song id.');
@@ -361,21 +357,21 @@ let lib = {
 			},
 			},
 
 
 			(playlist, next) => {
 			(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) => {
 			(res, next) => {
 				playlists.updatePlaylist(playlistId, next);
 				playlists.updatePlaylist(playlistId, next);
 			}
 			}
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
 			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});
 				return cb({ status: 'failure', message: err});
 			} else {
 			} 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 });
 				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 {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 {String} playlistId - the id of the playlist we are updating the displayName for
 	 * @param {Function} cb - gets called with the result
 	 * @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([
 		async.waterfall([
 			(next) => {
 			(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) => {
 			(res, next) => {
 				playlists.updatePlaylist(playlistId, next);
 				playlists.updatePlaylist(playlistId, next);
 			}
 			}
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
 			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});
 				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' });
 			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} 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 {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 {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([
 		async.waterfall([
 			(next) => {
 			(next) => {
 				playlists.getPlaylist(playlistId, next);
 				playlists.getPlaylist(playlistId, next);
 			},
 			},
 
 
 			(playlist, 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) => {
 				async.each(playlist.songs, (song, next) => {
 					if (song.songId === songId) return next(song);
 					if (song.songId === songId) return next(song);
 					next();
 					next();
@@ -437,14 +431,14 @@ let lib = {
 			},
 			},
 
 
 			(song, next) => {
 			(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);
 					if (err) return next(err);
 					return next(null, song);
 					return next(null, song);
 				});
 				});
 			},
 			},
 
 
 			(song, next) => {
 			(song, next) => {
-				db.models.playlist.update({_id: playlistId}, {
+				db.models.playlist.updateOne({_id: playlistId}, {
 					$push: {
 					$push: {
 						songs: {
 						songs: {
 							$each: [song],
 							$each: [song],
@@ -457,14 +451,14 @@ let lib = {
 			(res, next) => {
 			(res, next) => {
 				playlists.updatePlaylist(playlistId, next);
 				playlists.updatePlaylist(playlistId, next);
 			}
 			}
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
 			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});
 				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' });
 			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} 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 {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 {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([
 		async.waterfall([
 			(next) => {
 			(next) => {
 				playlists.getPlaylist(playlistId, next);
 				playlists.getPlaylist(playlistId, next);
 			},
 			},
 
 
 			(playlist, 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) => {
 				async.each(playlist.songs, (song, next) => {
 					if (song.songId === songId) return next(song);
 					if (song.songId === songId) return next(song);
 					next();
 					next();
@@ -496,14 +489,14 @@ let lib = {
 			},
 			},
 
 
 			(song, next) => {
 			(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);
 					if (err) return next(err);
 					return next(null, song);
 					return next(null, song);
 				});
 				});
 			},
 			},
 
 
 			(song, next) => {
 			(song, next) => {
-				db.models.playlist.update({_id: playlistId}, {
+				db.models.playlist.updateOne({_id: playlistId}, {
 					$push: {
 					$push: {
 						songs: song
 						songs: song
 					}
 					}
@@ -513,14 +506,14 @@ let lib = {
 			(res, next) => {
 			(res, next) => {
 				playlists.updatePlaylist(playlistId, next);
 				playlists.updatePlaylist(playlistId, next);
 			}
 			}
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
 			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});
 				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' });
 			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 {Object} session - the session object automatically added by socket.io
 	 * @param {String} playlistId - the id of the playlist we are moving the song to the top from
 	 * @param {String} playlistId - the id of the playlist we are moving the song to the top from
 	 * @param {Function} cb - gets called with the result
 	 * @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([
 		async.waterfall([
 			(next) => {
 			(next) => {
 				playlists.deletePlaylist(playlistId, next);
 				playlists.deletePlaylist(playlistId, next);
 			}
 			}
-		], (err) => {
+		], async (err) => {
 			if (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});
 				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' });
 			return cb({ status: 'success', message: 'Playlist successfully removed' });
 		});
 		});
 	})
 	})

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

@@ -1,17 +1,20 @@
 'use strict';
 '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 => {
 cache.sub('ip.ban', data => {
+	utils.emitToRoom('admin.punishments', 'event:admin.punishment.added', data.punishment);
 	utils.socketsFromIP(data.ip, sockets => {
 	utils.socketsFromIP(data.ip, sockets => {
 		sockets.forEach(socket => {
 		sockets.forEach(socket => {
-			socket.emit('keep.event:banned', data.punishment);
 			socket.disconnect(true);
 			socket.disconnect(true);
 		});
 		});
 	});
 	});
@@ -30,9 +33,9 @@ module.exports = {
 			(next) => {
 			(next) => {
 				db.models.punishment.find({}, next);
 				db.models.punishment.find({}, next);
 			}
 			}
-		], (err, punishments) => {
+		], async (err, punishments) => {
 			if (err) {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("PUNISHMENTS_INDEX", `Indexing punishments failed. "${err}"`);
 				logger.error("PUNISHMENTS_INDEX", `Indexing punishments failed. "${err}"`);
 				return cb({ 'status': 'failure', 'message': err});
 				return cb({ 'status': 'failure', 'message': err});
 			}
 			}
@@ -49,13 +52,12 @@ module.exports = {
 	 * @param {String} reason - the reason for the ban
 	 * @param {String} reason - the reason for the ban
 	 * @param {String} expiresAt - the time the ban expires
 	 * @param {String} expiresAt - the time the ban expires
 	 * @param {Function} cb - gets called with the result
 	 * @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([
 		async.waterfall([
 			(next) => {
 			(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();
 				else return next();
 			},
 			},
 
 
@@ -98,25 +100,20 @@ module.exports = {
 			},
 			},
 
 
 			(next) => {
 			(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) {
 			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 });
 				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';
 '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 config = require('config');
+const async = require('async');
 const request = require('request');
 const request = require('request');
+
 const hooks = require('./hooks');
 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 => {
 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);
 		utils.emitToRoom('admin.queue', 'event:admin.queueSong.added', song);
 	});
 	});
 });
 });
@@ -21,38 +24,32 @@ cache.sub('queue.removedSong', songId => {
 });
 });
 
 
 cache.sub('queue.update', 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);
 		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([
 		async.waterfall([
 			(next) => {
 			(next) => {
-				db.models.queueSong.find({}, next);
+				db.models.queueSong.countDocuments({}, next);
 			}
 			}
-		], (err, songs) => {
+		], async (err, count) => {
 			if (err) {
 			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
 	 * @param cb
 	 */
 	 */
 	getSet: hooks.adminRequired((session, set, 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 {String} songId - the id of the queuesong that gets updated
 	 * @param {Object} updatedSong - the object of the updated queueSong
 	 * @param {Object} updatedSong - the object of the updated queueSong
 	 * @param {Function} cb - gets called with the result
 	 * @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([
 		async.waterfall([
 			(next) => {
 			(next) => {
 				db.models.queueSong.findOne({_id: songId}, next);
 				db.models.queueSong.findOne({_id: songId}, next);
@@ -91,16 +96,16 @@ module.exports = {
 				let $set = {};
 				let $set = {};
 				for (let prop in updatedSong) if (updatedSong[prop] !== song[prop]) $set[prop] = updatedSong[prop]; updated = true;
 				for (let prop in updatedSong) if (updatedSong[prop] !== song[prop]) $set[prop] = updatedSong[prop]; updated = true;
 				if (!updated) return next('No properties changed');
 				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) {
 			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});
 				return cb({status: 'failure', message: err});
 			}
 			}
 			cache.pub('queue.update', songId);
 			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.'});
 			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 {Object} session - the session object automatically added by socket.io
 	 * @param {String} songId - the id of the queuesong that gets removed
 	 * @param {String} songId - the id of the queuesong that gets removed
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
-	 * @param {String} userId - the userId automatically added by hooks
 	 */
 	 */
 	remove: hooks.adminRequired((session, songId, cb, userId) => {
 	remove: hooks.adminRequired((session, songId, cb, userId) => {
 		async.waterfall([
 		async.waterfall([
 			(next) => {
 			(next) => {
-				db.models.queueSong.remove({_id: songId}, next);
+				db.models.queueSong.deleteOne({_id: songId}, next);
 			}
 			}
-		], (err) => {
+		], async (err) => {
 			if (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});
 				return cb({status: 'failure', message: err});
 			}
 			}
 			cache.pub('queue.removedSong', songId);
 			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.'});
 			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 {Object} session - the session object automatically added by socket.io
 	 * @param {String} songId - the id of the song that gets added
 	 * @param {String} songId - the id of the song that gets added
 	 * @param {Function} cb - gets called with the result
 	 * @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();
 		let requestedAt = Date.now();
 
 
 		async.waterfall([
 		async.waterfall([
@@ -155,34 +158,33 @@ module.exports = {
 			(song, next) => {
 			(song, next) => {
 				if (song) return next('This song has already been added.');
 				if (song) return next('This song has already been added.');
 				//TODO Add err object as first param of callback
 				//TODO Add err object as first param of callback
-				console.log(52, songId);
 				utils.getSongFromYouTube(songId, (song) => {
 				utils.getSongFromYouTube(songId, (song) => {
+					song.duration = -1;
 					song.artists = [];
 					song.artists = [];
 					song.genres = [];
 					song.genres = [];
 					song.skipDuration = 0;
 					song.skipDuration = 0;
-					song.thumbnail = 'empty';
+					song.thumbnail = `${config.get("domain")}/assets/notes.png`;
 					song.explicit = false;
 					song.explicit = false;
-					song.requestedBy = userId;
+					song.requestedBy = session.userId;
 					song.requestedAt = requestedAt;
 					song.requestedAt = requestedAt;
 					next(null, song);
 					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) => {
 			(newSong, next) => {
 				const song = new db.models.queueSong(newSong);
 				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);
 					if (err) return next(err);
 					next(null, song);
 					next(null, song);
 				});
 				});
 			},
 			},
 			(newSong, next) => {
 			(newSong, next) => {
-				db.models.user.findOne({ _id: userId }, (err, user) => {
+				db.models.user.findOne({ _id: session.userId }, (err, user) => {
 					if (err) next(err, newSong);
 					if (err) next(err, newSong);
 					else {
 					else {
 						user.statistics.songsRequested = user.statistics.songsRequested + 1;
 						user.statistics.songsRequested = user.statistics.songsRequested + 1;
@@ -193,15 +195,55 @@ module.exports = {
 					}
 					}
 				});
 				});
 			}
 			}
-		], (err, newSong) => {
+		], async (err, newSong) => {
 			if (err) {
 			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});
 				return cb({status: 'failure', message: err});
 			}
 			}
 			cache.pub('queue.newSong', newSong._id);
 			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' });
 			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 async = require('async');
 
 
-const db = require('../db');
-const cache = require('../cache');
-const utils = require('../utils');
-const logger = require('../logger');
 const hooks = require('./hooks');
 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 = [
 const reportableIssues = [
 	{
 	{
 		name: 'Video',
 		name: 'Video',
@@ -71,9 +75,9 @@ module.exports = {
 			(next) => {
 			(next) => {
 				db.models.report.find({ resolved: false }).sort({ released: 'desc' }).exec(next);
 				db.models.report.find({ resolved: false }).sort({ released: 'desc' }).exec(next);
 			}
 			}
-		], (err, reports) => {
+		], async (err, reports) => {
 			if (err) {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("REPORTS_INDEX", `Indexing reports failed. "${err}"`);
 				logger.error("REPORTS_INDEX", `Indexing reports failed. "${err}"`);
 				return cb({ 'status': 'failure', 'message': err});
 				return cb({ 'status': 'failure', 'message': err});
 			}
 			}
@@ -94,9 +98,9 @@ module.exports = {
 			(next) => {
 			(next) => {
 				db.models.report.findOne({ _id: reportId }).exec(next);
 				db.models.report.findOne({ _id: reportId }).exec(next);
 			}
 			}
-		], (err, report) => {
+		], async (err, report) => {
 			if (err) {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("REPORTS_FIND_ONE", `Finding report "${reportId}" failed. "${err}"`);
 				logger.error("REPORTS_FIND_ONE", `Finding report "${reportId}" failed. "${err}"`);
 				return cb({ 'status': 'failure', 'message': 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 {Object} session - the session object automatically added by socket.io
 	 * @param {String} songId - the id of the song to index reports for
 	 * @param {String} songId - the id of the song to index reports for
@@ -115,7 +119,7 @@ module.exports = {
 	getReportsForSong: hooks.adminRequired((session, songId, cb) => {
 	getReportsForSong: hooks.adminRequired((session, songId, cb) => {
 		async.waterfall([
 		async.waterfall([
 			(next) => {
 			(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) => {
 			(reports, next) => {
@@ -125,9 +129,9 @@ module.exports = {
 				}
 				}
 				next(null, data);
 				next(null, data);
 			}
 			}
-		], (err, data) => {
+		], async (err, data) => {
 			if (err) {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("GET_REPORTS_FOR_SONG", `Indexing reports for song "${songId}" failed. "${err}"`);
 				logger.error("GET_REPORTS_FOR_SONG", `Indexing reports for song "${songId}" failed. "${err}"`);
 				return cb({ 'status': 'failure', 'message': err});
 				return cb({ 'status': 'failure', 'message': err});
 			} else {
 			} else {
@@ -143,9 +147,8 @@ module.exports = {
 	 * @param {Object} session - the session object automatically added by socket.io
 	 * @param {Object} session - the session object automatically added by socket.io
 	 * @param {String} reportId - the id of the report that is getting resolved
 	 * @param {String} reportId - the id of the report that is getting resolved
 	 * @param {Function} cb - gets called with the result
 	 * @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([
 		async.waterfall([
 			(next) => {
 			(next) => {
 				db.models.report.findOne({ _id: reportId }).exec(next);
 				db.models.report.findOne({ _id: reportId }).exec(next);
@@ -159,14 +162,14 @@ module.exports = {
 					else next();
 					else next();
 				});
 				});
 			}
 			}
-		], (err) => {
+		], async (err) => {
 			if (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});
 				return cb({ 'status': 'failure', 'message': err});
 			} else {
 			} else {
 				cache.pub('report.resolve', reportId);
 				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' });
 				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} session - the session object automatically added by socket.io
 	 * @param {Object} data - the object of the report data
 	 * @param {Object} data - the object of the report data
 	 * @param {Function} cb - gets called with the result
 	 * @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([
 		async.waterfall([
 
 
 			(next) => {
 			(next) => {
@@ -195,6 +197,11 @@ module.exports = {
 			(song, next) => {
 			(song, next) => {
 				if (!song) return next('Song not found.');
 				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++) {
 				for (let z = 0; z < data.issues.length; z++) {
 					if (reportableIssues.filter(issue => { return issue.name == data.issues[z].name; }).length > 0) {
 					if (reportableIssues.filter(issue => { return issue.name == data.issues[z].name; }).length > 0) {
@@ -222,19 +229,19 @@ module.exports = {
 			},
 			},
 
 
 			(next) => {
 			(next) => {
-				data.createdBy = userId;
+				data.createdBy = session.userId;
 				data.createdAt = Date.now();
 				data.createdAt = Date.now();
 				db.models.report.create(data, next);
 				db.models.report.create(data, next);
 			}
 			}
 
 
-		], (err, report) => {
+		], async (err, report) => {
 			if (err) {
 			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 });
 				return cb({ 'status': 'failure', 'message': err });
 			} else {
 			} else {
 				cache.pub('report.create', report);
 				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' });
 				return cb({ 'status': 'success', 'message': 'Successfully created report' });
 			}
 			}
 		});
 		});

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

@@ -1,27 +1,30 @@
 'use strict';
 'use strict';
 
 
-const db = require('../db');
-const io = require('../io');
-const songs = require('../songs');
-const cache = require('../cache');
 const async = require('async');
 const async = require('async');
-const utils = require('../utils');
-const logger = require('../logger');
+
 const hooks = require('./hooks');
 const hooks = require('./hooks');
 const queueSongs = require('./queueSongs');
 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 => {
 cache.sub('song.removed', songId => {
 	utils.emitToRoom('admin.songs', 'event:admin.song.removed', songId);
 	utils.emitToRoom('admin.songs', 'event:admin.song.removed', songId);
 });
 });
 
 
 cache.sub('song.added', 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);
 		utils.emitToRoom('admin.songs', 'event:admin.song.added', song);
 	});
 	});
 });
 });
 
 
 cache.sub('song.updated', songId => {
 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);
 		utils.emitToRoom('admin.songs', 'event:admin.song.updated', song);
 	});
 	});
 });
 });
@@ -73,11 +76,11 @@ module.exports = {
 	length: hooks.adminRequired((session, cb) => {
 	length: hooks.adminRequired((session, cb) => {
 		async.waterfall([
 		async.waterfall([
 			(next) => {
 			(next) => {
-				db.models.song.count({}, next);
+				db.models.song.countDocuments({}, next);
 			}
 			}
-		], (err, count) => {
+		], async (err, count) => {
 			if (err) {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("SONGS_LENGTH", `Failed to get length from songs. "${err}"`);
 				logger.error("SONGS_LENGTH", `Failed to get length from songs. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 				return cb({'status': 'failure', 'message': err});
 			}
 			}
@@ -96,18 +99,42 @@ module.exports = {
 	getSet: hooks.adminRequired((session, set, cb) => {
 	getSet: hooks.adminRequired((session, set, cb) => {
 		async.waterfall([
 		async.waterfall([
 			(next) => {
 			(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) {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("SONGS_GET_SET", `Failed to get set from songs. "${err}"`);
 				logger.error("SONGS_GET_SET", `Failed to get set from songs. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 				return cb({'status': 'failure', 'message': err});
 			}
 			}
 			logger.success("SONGS_GET_SET", `Got set from songs successfully.`);
 			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) => {
 	update: hooks.adminRequired((session, songId, song, cb) => {
 		async.waterfall([
 		async.waterfall([
 			(next) => {
 			(next) => {
-				db.models.song.update({_id: songId}, song, {runValidators: true}, next);
+				db.models.song.updateOne({_id: songId}, song, {runValidators: true}, next);
 			},
 			},
 
 
 			(res, next) => {
 			(res, next) => {
 				songs.updateSong(songId, next);
 				songs.updateSong(songId, next);
 			}
 			}
-		], (err, song) => {
+		], async (err, song) => {
 			if (err) {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("SONGS_UPDATE", `Failed to update song "${songId}". "${err}"`);
 				logger.error("SONGS_UPDATE", `Failed to update song "${songId}". "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 				return cb({'status': 'failure', 'message': err});
 			}
 			}
@@ -150,15 +177,15 @@ module.exports = {
 	remove: hooks.adminRequired((session, songId, cb) => {
 	remove: hooks.adminRequired((session, songId, cb) => {
 		async.waterfall([
 		async.waterfall([
 			(next) => {
 			(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
 			(res, next) => {//TODO Check if res gets returned from above
 				cache.hdel('songs', songId, next);
 				cache.hdel('songs', songId, next);
 			}
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("SONGS_UPDATE", `Failed to remove song "${songId}". "${err}"`);
 				logger.error("SONGS_UPDATE", `Failed to remove song "${songId}". "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 				return cb({'status': 'failure', 'message': err});
 			}
 			}
@@ -174,9 +201,8 @@ module.exports = {
 	 * @param session
 	 * @param session
 	 * @param song - the song object
 	 * @param song - the song object
 	 * @param cb
 	 * @param cb
-	 * @param userId
 	 */
 	 */
-	add: hooks.adminRequired((session, song, cb, userId) => {
+	add: hooks.adminRequired((session, song, cb) => {
 		async.waterfall([
 		async.waterfall([
 			(next) => {
 			(next) => {
 				db.models.song.findOne({songId: song.songId}, next);
 				db.models.song.findOne({songId: song.songId}, next);
@@ -189,23 +215,23 @@ module.exports = {
 
 
 			(next) => {
 			(next) => {
 				const newSong = new db.models.song(song);
 				const newSong = new db.models.song(song);
-				newSong.acceptedBy = userId;
+				newSong.acceptedBy = session.userId;
 				newSong.acceptedAt = Date.now();
 				newSong.acceptedAt = Date.now();
 				newSong.save(next);
 				newSong.save(next);
 			},
 			},
 
 
-			(next) => {
+			(res, next) => {
 				queueSongs.remove(session, song._id, () => {
 				queueSongs.remove(session, song._id, () => {
 					next();
 					next();
 				});
 				});
 			},
 			},
-		], (err) => {
+		], async (err) => {
 			if (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});
 				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);
 			cache.pub('song.added', song.songId);
 			cb({status: 'success', message: 'Song has been moved from the queue successfully.'});
 			cb({status: 'success', message: 'Song has been moved from the queue successfully.'});
 		});
 		});
@@ -218,9 +244,8 @@ module.exports = {
 	 * @param session
 	 * @param session
 	 * @param songId - the song id
 	 * @param songId - the song id
 	 * @param cb
 	 * @param cb
-	 * @param userId
 	 */
 	 */
-	like: hooks.loginRequired((session, songId, cb, userId) => {
+	like: hooks.loginRequired((session, songId, cb) => {
 		async.waterfall([
 		async.waterfall([
 			(next) => {
 			(next) => {
 				db.models.song.findOne({songId}, next);
 				db.models.song.findOne({songId}, next);
@@ -230,21 +255,21 @@ module.exports = {
 				if (!song) return next('No song found with that id.');
 				if (!song) return next('No song found with that id.');
 				next(null, song);
 				next(null, song);
 			}
 			}
-		], (err, song) => {
+		], async (err, song) => {
 			if (err) {
 			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});
 				return cb({'status': 'failure', 'message': err});
 			}
 			}
 			let oldSongId = songId;
 			let oldSongId = songId;
 			songId = song._id;
 			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.' });
 				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) {
 					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.' });
 							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.' });
 								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) => {
 								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.' });
 									if (err) return cb({ status: 'failure', message: 'Something went wrong while liking this song.' });
@@ -266,9 +291,8 @@ module.exports = {
 	 * @param session
 	 * @param session
 	 * @param songId - the song id
 	 * @param songId - the song id
 	 * @param cb
 	 * @param cb
-	 * @param userId
 	 */
 	 */
-	dislike: hooks.loginRequired((session, songId, cb, userId) => {
+	dislike: hooks.loginRequired((session, songId, cb) => {
 		async.waterfall([
 		async.waterfall([
 			(next) => {
 			(next) => {
 				db.models.song.findOne({songId}, next);
 				db.models.song.findOne({songId}, next);
@@ -278,21 +302,21 @@ module.exports = {
 				if (!song) return next('No song found with that id.');
 				if (!song) return next('No song found with that id.');
 				next(null, song);
 				next(null, song);
 			}
 			}
-		], (err, song) => {
+		], async (err, song) => {
 			if (err) {
 			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});
 				return cb({'status': 'failure', 'message': err});
 			}
 			}
 			let oldSongId = songId;
 			let oldSongId = songId;
 			songId = song._id;
 			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.' });
 				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) {
 					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.' });
 							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.' });
 								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) => {
 								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.' });
 									if (err) return cb({ status: 'failure', message: 'Something went wrong while disliking this song.' });
@@ -314,9 +338,8 @@ module.exports = {
 	 * @param session
 	 * @param session
 	 * @param songId - the song id
 	 * @param songId - the song id
 	 * @param cb
 	 * @param cb
-	 * @param userId
 	 */
 	 */
-	undislike: hooks.loginRequired((session, songId, cb, userId) => {
+	undislike: hooks.loginRequired((session, songId, cb) => {
 		async.waterfall([
 		async.waterfall([
 			(next) => {
 			(next) => {
 				db.models.song.findOne({songId}, next);
 				db.models.song.findOne({songId}, next);
@@ -326,27 +349,27 @@ module.exports = {
 				if (!song) return next('No song found with that id.');
 				if (!song) return next('No song found with that id.');
 				next(null, song);
 				next(null, song);
 			}
 			}
-		], (err, song) => {
+		], async (err, song) => {
 			if (err) {
 			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});
 				return cb({'status': 'failure', 'message': err});
 			}
 			}
 			let oldSongId = songId;
 			let oldSongId = songId;
 			songId = song._id;
 			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({
 				if (user.disliked.indexOf(songId) === -1) return cb({
 					status: 'failure',
 					status: 'failure',
 					message: 'You have not disliked this song.'
 					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) {
 					if (!err) {
-						db.models.user.count({"liked": songId}, (err, likes) => {
+						db.models.user.countDocuments({"liked": songId}, (err, likes) => {
 							if (err) return cb({
 							if (err) return cb({
 								status: 'failure',
 								status: 'failure',
 								message: 'Something went wrong while undisliking this song.'
 								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({
 								if (err) return cb({
 									status: 'failure',
 									status: 'failure',
 									message: 'Something went wrong while undisliking this song.'
 									message: 'Something went wrong while undisliking this song.'
@@ -383,9 +406,8 @@ module.exports = {
 	 * @param session
 	 * @param session
 	 * @param songId - the song id
 	 * @param songId - the song id
 	 * @param cb
 	 * @param cb
-	 * @param userId
 	 */
 	 */
-	unlike: hooks.loginRequired((session, songId, cb, userId) => {
+	unlike: hooks.loginRequired((session, songId, cb) => {
 		async.waterfall([
 		async.waterfall([
 			(next) => {
 			(next) => {
 				db.models.song.findOne({songId}, next);
 				db.models.song.findOne({songId}, next);
@@ -395,23 +417,23 @@ module.exports = {
 				if (!song) return next('No song found with that id.');
 				if (!song) return next('No song found with that id.');
 				next(null, song);
 				next(null, song);
 			}
 			}
-		], (err, song) => {
+		], async (err, song) => {
 			if (err) {
 			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});
 				return cb({'status': 'failure', 'message': err});
 			}
 			}
 			let oldSongId = songId;
 			let oldSongId = songId;
 			songId = song._id;
 			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.' });
 				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) {
 					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.' });
 							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.' });
 								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.' });
 									if (err) return cb({ status: 'failure', message: 'Something went wrong while unliking this song.' });
 									songs.updateSong(songId, (err, song) => {});
 									songs.updateSong(songId, (err, song) => {});
 									cache.pub('song.unlike', JSON.stringify({ songId: oldSongId, userId: session.userId, likes: likes, dislikes: dislikes }));
 									cache.pub('song.unlike', JSON.stringify({ songId: oldSongId, userId: session.userId, likes: likes, dislikes: dislikes }));
@@ -431,9 +453,8 @@ module.exports = {
 	 * @param session
 	 * @param session
 	 * @param songId - the song id
 	 * @param songId - the song id
 	 * @param cb
 	 * @param cb
-	 * @param userId
 	 */
 	 */
-	getOwnSongRatings: hooks.loginRequired((session, songId, cb, userId) => {
+	getOwnSongRatings: hooks.loginRequired((session, songId, cb) => {
 		async.waterfall([
 		async.waterfall([
 			(next) => {
 			(next) => {
 				db.models.song.findOne({songId}, next);
 				db.models.song.findOne({songId}, next);
@@ -443,14 +464,14 @@ module.exports = {
 				if (!song) return next('No song found with that id.');
 				if (!song) return next('No song found with that id.');
 				next(null, song);
 				next(null, song);
 			}
 			}
-		], (err, song) => {
+		], async (err, song) => {
 			if (err) {
 			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});
 				return cb({'status': 'failure', 'message': err});
 			}
 			}
 			let newSongId = song._id;
 			let newSongId = song._id;
-			db.models.user.findOne({_id: userId}, (err, user) => {
+			db.models.user.findOne({_id: session.userId}, (err, user) => {
 				if (!err && user) {
 				if (!err && user) {
 					return cb({
 					return cb({
 						status: 'success',
 						status: 'success',

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

@@ -5,15 +5,18 @@ const async   = require('async'),
 	  config  = require('config'),
 	  config  = require('config'),
 	  _		  =  require('underscore')._;
 	  _		  =  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 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 userList = {};
 let usersPerStation = {};
 let usersPerStation = {};
 let usersPerStationCount = {};
 let usersPerStationCount = {};
@@ -29,39 +32,40 @@ setInterval(() => {
 	usersPerStationCount = {};
 	usersPerStationCount = {};
 
 
 	async.each(Object.keys(userList), function(socketId, next) {
 	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
 		//TODO Code to show users
 	}, (err) => {
 	}, (err) => {
@@ -99,10 +103,10 @@ cache.sub('station.updateUsers', stationId => {
 cache.sub('station.updateUserCount', stationId => {
 cache.sub('station.updateUserCount', stationId => {
 	let count = usersPerStationCount[stationId] || 0;
 	let count = usersPerStationCount[stationId] || 0;
 	utils.emitToRoom(`station.${stationId}`, "event:userCount.updated", count);
 	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);
 		if (station.privacy === 'public') utils.emitToRoom('home', "event:userCount.updated", stationId, count);
 		else {
 		else {
-			let sockets = utils.getRoomSockets('home');
+			let sockets = await utils.getRoomSockets('home');
 			for (let socketId in sockets) {
 			for (let socketId in sockets) {
 				let socket = sockets[socketId];
 				let socket = sockets[socketId];
 				let session = sockets[socketId].session;
 				let session = sockets[socketId].session;
@@ -134,7 +138,9 @@ cache.sub('privatePlaylist.selected', data => {
 });
 });
 
 
 cache.sub('station.pause', stationId => {
 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 => {
 cache.sub('station.resume', stationId => {
@@ -159,14 +165,14 @@ cache.sub('station.remove', stationId => {
 });
 });
 
 
 cache.sub('station.create', stationId => {
 cache.sub('station.create', stationId => {
-	stations.initializeStation(stationId, (err, station) => {
+	stations.initializeStation(stationId, async (err, station) => {
 		station.userCount = usersPerStationCount[stationId] || 0;
 		station.userCount = usersPerStationCount[stationId] || 0;
 		if (err) console.error(err);
 		if (err) console.error(err);
 		utils.emitToRoom('admin.stations', 'event:admin.station.added', station);
 		utils.emitToRoom('admin.stations', 'event:admin.station.added', station);
 		// TODO If community, check if on whitelist
 		// TODO If community, check if on whitelist
 		if (station.privacy === 'public') utils.emitToRoom('home', "event:stations.created", station);
 		if (station.privacy === 'public') utils.emitToRoom('home', "event:stations.created", station);
 		else {
 		else {
-			let sockets = utils.getRoomSockets('home');
+			let sockets = await utils.getRoomSockets('home');
 			for (let socketId in sockets) {
 			for (let socketId in sockets) {
 				let socket = sockets[socketId];
 				let socket = sockets[socketId];
 				let session = sockets[socketId].session;
 				let session = sockets[socketId].session;
@@ -208,40 +214,27 @@ module.exports = {
 				next(null, stations);
 				next(null, stations);
 			},
 			},
 
 
-			(stations, next) => {
+			(stationsArray, next) => {
 				let resultStations = [];
 				let resultStations = [];
-				async.each(stations, (station, next) => {
+				async.each(stationsArray, (station, next) => {
 					async.waterfall([
 					async.waterfall([
 						(next) => {
 						(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;
 						station.userCount = usersPerStationCount[station._id] || 0;
-						if (err === true) resultStations.push(station);
+						if (exists) resultStations.push(station);
 						next();
 						next();
 					});
 					});
 				}, () => {
 				}, () => {
 					next(null, resultStations);
 					next(null, resultStations);
 				});
 				});
 			}
 			}
-		], (err, stations) => {
+		], async (err, stations) => {
 			if (err) {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_INDEX", `Indexing stations failed. "${err}"`);
 				logger.error("STATIONS_INDEX", `Indexing stations failed. "${err}"`);
 				return cb({'status': 'failure', 'message': 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 session
 	 * @param stationName - the station name
 	 * @param stationName - the station name
 	 * @param cb
 	 * @param cb
 	 */
 	 */
-	findByName: (session, stationName, cb) => {
+	existsByName: (session, stationName, cb) => {
 		async.waterfall([
 		async.waterfall([
 			(next) => {
 			(next) => {
 				stations.getStationByName(stationName, next);
 				stations.getStationByName(stationName, next);
 			},
 			},
 
 
 			(station, 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) {
 			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});
 				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);
 				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) => {
 			(station, next) => {
 				if (!station) return next('Station not found.');
 				if (!station) return next('Station not found.');
 				else if (station.type !== 'official') return next('This is not an official station.');
 				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.');
 				if (!playlist) return next('Playlist not found.');
 				next(null, playlist);
 				next(null, playlist);
 			}
 			}
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_GET_PLAYLIST", `Getting playlist for station "${stationId}" failed. "${err}"`);
 				logger.error("STATIONS_GET_PLAYLIST", `Getting playlist for station "${stationId}" failed. "${err}"`);
 				return cb({ status: 'failure', message: err });
 				return cb({ status: 'failure', message: err });
 			} else {
 			} else {
@@ -333,27 +336,10 @@ module.exports = {
 
 
 			(station, next) => {
 			(station, next) => {
 				if (!station) return next('Station not found.');
 				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,
 					startedAt: station.startedAt,
 					paused: station.paused,
 					paused: station.paused,
 					timePaused: station.timePaused,
 					timePaused: station.timePaused,
+					pausedAt: station.pausedAt,
 					description: station.description,
 					description: station.description,
 					displayName: station.displayName,
 					displayName: station.displayName,
 					privacy: station.privacy,
 					privacy: station.privacy,
@@ -395,9 +382,9 @@ module.exports = {
 					next(null, data);
 					next(null, data);
 				});
 				});
 			}
 			}
-		], (err, data) => {
+		], async (err, data) => {
 			if (err) {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_JOIN", `Joining station "${stationName}" failed. "${err}"`);
 				logger.error("STATIONS_JOIN", `Joining station "${stationName}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 				return cb({'status': 'failure', 'message': err});
 			}
 			}
@@ -420,15 +407,15 @@ module.exports = {
 			},
 			},
 
 
 			(station, next) => {
 			(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) => {
 			(res, next) => {
 				stations.updateStation(stationId, next);
 				stations.updateStation(stationId, next);
 			}
 			}
-		], (err, station) => {
+		], async (err, station) => {
 			if (err) {
 			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}"`);
 				logger.error("STATIONS_UPDATE_LOCKED_STATUS", `Toggling the queue lock for station "${stationId}" failed. "${err}"`);
 				return cb({ status: 'failure', message: err });
 				return cb({ status: 'failure', message: err });
 			} else {
 			} else {
@@ -445,9 +432,8 @@ module.exports = {
 	 * @param session
 	 * @param session
 	 * @param stationId - the station id
 	 * @param stationId - the station id
 	 * @param cb
 	 * @param cb
-	 * @param userId
 	 */
 	 */
-	voteSkip: hooks.loginRequired((session, stationId, cb, userId) => {
+	voteSkip: hooks.loginRequired((session, stationId, cb) => {
 		async.waterfall([
 		async.waterfall([
 			(next) => {
 			(next) => {
 				stations.getStation(stationId, next);
 				stations.getStation(stationId, next);
@@ -455,20 +441,21 @@ module.exports = {
 
 
 			(station, next) => {
 			(station, next) => {
 				if (!station) return next('Station not found.');
 				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.');
 					return next('Insufficient permissions.');
 				});
 				});
 			},
 			},
 
 
 			(station, next) => {
 			(station, next) => {
 				if (!station.currentSong) return next('There is currently no song to skip.');
 				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);
 				next(null, station);
 			},
 			},
 
 
 			(station, next) => {
 			(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) => {
 			(res, next) => {
@@ -479,9 +466,9 @@ module.exports = {
 				if (!station) return next('Station not found.');
 				if (!station) return next('Station not found.');
 				next(null, station);
 				next(null, station);
 			}
 			}
-		], (err, station) => {
+		], async (err, station) => {
 			if (err) {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_VOTE_SKIP", `Vote skipping station "${stationId}" failed. "${err}"`);
 				logger.error("STATIONS_VOTE_SKIP", `Vote skipping station "${stationId}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 				return cb({'status': 'failure', 'message': err});
 			}
 			}
@@ -509,9 +496,9 @@ module.exports = {
 				if (!station) return next('Station not found.');
 				if (!station) return next('Station not found.');
 				next();
 				next();
 			}
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_FORCE_SKIP", `Force skipping station "${stationId}" failed. "${err}"`);
 				logger.error("STATIONS_FORCE_SKIP", `Force skipping station "${stationId}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 				return cb({'status': 'failure', 'message': err});
 			}
 			}
@@ -540,9 +527,9 @@ module.exports = {
 				if (!station) return next('Station not found.');
 				if (!station) return next('Station not found.');
 				next();
 				next();
 			}
 			}
-		], (err, userCount) => {
+		], async (err, userCount) => {
 			if (err) {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_LEAVE", `Leaving station "${stationId}" failed. "${err}"`);
 				logger.error("STATIONS_LEAVE", `Leaving station "${stationId}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 				return cb({'status': 'failure', 'message': err});
 			}
 			}
@@ -564,19 +551,19 @@ module.exports = {
 	updateName: hooks.ownerRequired((session, stationId, newName, cb) => {
 	updateName: hooks.ownerRequired((session, stationId, newName, cb) => {
 		async.waterfall([
 		async.waterfall([
 			(next) => {
 			(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) => {
 			(res, next) => {
 				stations.updateStation(stationId, next);
 				stations.updateStation(stationId, next);
 			}
 			}
-		], (err) => {
+		], async (err) => {
 			if (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});
 				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.'});
 			return cb({'status': 'success', 'message': 'Successfully updated the name.'});
 		});
 		});
 	}),
 	}),
@@ -592,15 +579,15 @@ module.exports = {
 	updateDisplayName: hooks.ownerRequired((session, stationId, newDisplayName, cb) => {
 	updateDisplayName: hooks.ownerRequired((session, stationId, newDisplayName, cb) => {
 		async.waterfall([
 		async.waterfall([
 			(next) => {
 			(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) => {
 			(res, next) => {
 				stations.updateStation(stationId, next);
 				stations.updateStation(stationId, next);
 			}
 			}
-		], (err) => {
+		], async (err) => {
 			if (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}"`);
 				logger.error("STATIONS_UPDATE_DISPLAY_NAME", `Updating station "${stationId}" displayName to "${newDisplayName}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 				return cb({'status': 'failure', 'message': err});
 			}
 			}
@@ -620,15 +607,15 @@ module.exports = {
 	updateDescription: hooks.ownerRequired((session, stationId, newDescription, cb) => {
 	updateDescription: hooks.ownerRequired((session, stationId, newDescription, cb) => {
 		async.waterfall([
 		async.waterfall([
 			(next) => {
 			(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) => {
 			(res, next) => {
 				stations.updateStation(stationId, next);
 				stations.updateStation(stationId, next);
 			}
 			}
-		], (err) => {
+		], async (err) => {
 			if (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}"`);
 				logger.error("STATIONS_UPDATE_DESCRIPTION", `Updating station "${stationId}" description to "${newDescription}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 				return cb({'status': 'failure', 'message': err});
 			}
 			}
@@ -648,15 +635,15 @@ module.exports = {
 	updatePrivacy: hooks.ownerRequired((session, stationId, newPrivacy, cb) => {
 	updatePrivacy: hooks.ownerRequired((session, stationId, newPrivacy, cb) => {
 		async.waterfall([
 		async.waterfall([
 			(next) => {
 			(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) => {
 			(res, next) => {
 				stations.updateStation(stationId, next);
 				stations.updateStation(stationId, next);
 			}
 			}
-		], (err) => {
+		], async (err) => {
 			if (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}"`);
 				logger.error("STATIONS_UPDATE_PRIVACY", `Updating station "${stationId}" privacy to "${newPrivacy}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': 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
 	 * Updates a station's party mode
 	 *
 	 *
@@ -682,15 +725,15 @@ module.exports = {
 			(station, next) => {
 			(station, next) => {
 				if (!station) return next('Station not found.');
 				if (!station) return next('Station not found.');
 				if (station.partyMode === newPartyMode) return next('The party mode was already ' + ((newPartyMode) ? 'enabled.' : 'disabled.'));
 				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) => {
 			(res, next) => {
 				stations.updateStation(stationId, next);
 				stations.updateStation(stationId, next);
 			}
 			}
-		], (err) => {
+		], async (err) => {
 			if (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}"`);
 				logger.error("STATIONS_UPDATE_PARTY_MODE", `Updating station "${stationId}" party mode to "${newPartyMode}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 				return cb({'status': 'failure', 'message': err});
 			}
 			}
@@ -717,15 +760,15 @@ module.exports = {
 			(station, next) => {
 			(station, next) => {
 				if (!station) return next('Station not found.');
 				if (!station) return next('Station not found.');
 				if (station.paused) return next('That station was already paused.');
 				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) => {
 			(res, next) => {
 				stations.updateStation(stationId, next);
 				stations.updateStation(stationId, next);
 			}
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_PAUSE", `Pausing station "${stationId}" failed. "${err}"`);
 				logger.error("STATIONS_PAUSE", `Pausing station "${stationId}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 				return cb({'status': 'failure', 'message': err});
 			}
 			}
@@ -753,15 +796,15 @@ module.exports = {
 				if (!station) return next('Station not found.');
 				if (!station) return next('Station not found.');
 				if (!station.paused) return next('That station is not paused.');
 				if (!station.paused) return next('That station is not paused.');
 				station.timePaused += (Date.now() - station.pausedAt);
 				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) => {
 			(res, next) => {
 				stations.updateStation(stationId, next);
 				stations.updateStation(stationId, next);
 			}
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_RESUME", `Resuming station "${stationId}" failed. "${err}"`);
 				logger.error("STATIONS_RESUME", `Resuming station "${stationId}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 				return cb({'status': 'failure', 'message': err});
 			}
 			}
@@ -781,15 +824,15 @@ module.exports = {
 	remove: hooks.ownerRequired((session, stationId, cb) => {
 	remove: hooks.ownerRequired((session, stationId, cb) => {
 		async.waterfall([
 		async.waterfall([
 			(next) => {
 			(next) => {
-				db.models.station.remove({ _id: stationId }, err => next(err));
+				db.models.station.deleteOne({ _id: stationId }, err => next(err));
 			},
 			},
 
 
 			(next) => {
 			(next) => {
 				cache.hdel('stations', stationId, err => next(err));
 				cache.hdel('stations', stationId, err => next(err));
 			}
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_REMOVE", `Removing station "${stationId}" failed. "${err}"`);
 				logger.error("STATIONS_REMOVE", `Removing station "${stationId}" failed. "${err}"`);
 				return cb({ 'status': 'failure', 'message': err });
 				return cb({ 'status': 'failure', 'message': err });
 			}
 			}
@@ -805,9 +848,8 @@ module.exports = {
 	 * @param session
 	 * @param session
 	 * @param data - the station data
 	 * @param data - the station data
 	 * @param cb
 	 * @param cb
-	 * @param userId
 	 */
 	 */
-	create: hooks.loginRequired((session, data, cb, userId) => {
+	create: hooks.loginRequired((session, data, cb) => {
 		data.name = data.name.toLowerCase();
 		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"];
 		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([
 		async.waterfall([
@@ -824,7 +866,7 @@ module.exports = {
 				if (station) return next('A station with that name or display name already exists.');
 				if (station) return next('A station with that name or display name already exists.');
 				const { name, displayName, description, genres, playlist, type, blacklistedGenres } = data;
 				const { name, displayName, description, genres, playlist, type, blacklistedGenres } = data;
 				if (type === 'official') {
 				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 (err) return next(err);
 						if (!user) return next('User not found.');
 						if (!user) return next('User not found.');
 						if (user.role !== 'admin') return next('Admin required.');
 						if (user.role !== 'admin') return next('Admin required.');
@@ -848,15 +890,15 @@ module.exports = {
 						description,
 						description,
 						type,
 						type,
 						privacy: 'private',
 						privacy: 'private',
-						owner: userId,
+						owner: session.userId,
 						queue: [],
 						queue: [],
 						currentSong: null
 						currentSong: null
 					}, next);
 					}, next);
 				}
 				}
 			}
 			}
-		], (err, station) => {
+		], async (err, station) => {
 			if (err) {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_CREATE", `Creating station failed. "${err}"`);
 				logger.error("STATIONS_CREATE", `Creating station failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 				return cb({'status': 'failure', 'message': err});
 			}
 			}
@@ -873,9 +915,8 @@ module.exports = {
 	 * @param stationId - the station id
 	 * @param stationId - the station id
 	 * @param songId - the song id
 	 * @param songId - the song id
 	 * @param cb
 	 * @param cb
-	 * @param userId
 	 */
 	 */
-	addToQueue: hooks.loginRequired((session, stationId, songId, cb, userId) => {
+	addToQueue: hooks.loginRequired((session, stationId, songId, cb) => {
 		async.waterfall([
 		async.waterfall([
 			(next) => {
 			(next) => {
 				stations.getStation(stationId, next);
 				stations.getStation(stationId, next);
@@ -884,8 +925,8 @@ module.exports = {
 			(station, next) => {
 			(station, next) => {
 				if (!station) return next('Station not found.');
 				if (!station) return next('Station not found.');
 				if (station.locked) {
 				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 return next(null, station);
 					});
 					});
 				} else {
 				} else {
@@ -895,8 +936,9 @@ module.exports = {
 
 
 			(station, next) => {
 			(station, next) => {
 				if (station.type !== 'community') return next('That station is not a community station.');
 				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.');
 					return next('Insufficient permissions.');
 				});
 				});
 			},
 			},
@@ -928,7 +970,7 @@ module.exports = {
 
 
 			(song, station, next) => {
 			(song, station, next) => {
 				let queue = station.queue;
 				let queue = station.queue;
-				song.requestedBy = userId;
+				song.requestedBy = session.userId;
 				queue.push(song);
 				queue.push(song);
 
 
 				let totalDuration = 0;
 				let totalDuration = 0;
@@ -972,15 +1014,15 @@ module.exports = {
 			},
 			},
 
 
 			(song, next) => {
 			(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) => {
 			(res, next) => {
 				stations.updateStation(stationId, next);
 				stations.updateStation(stationId, next);
 			}
 			}
-		], (err, station) => {
+		], async (err, station) => {
 			if (err) {
 			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}"`);
 				logger.error("STATIONS_ADD_SONG_TO_QUEUE", `Adding song "${songId}" to station "${stationId}" queue failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 				return cb({'status': 'failure', 'message': err});
 			}
 			}
@@ -997,9 +1039,8 @@ module.exports = {
 	 * @param stationId - the station id
 	 * @param stationId - the station id
 	 * @param songId - the song id
 	 * @param songId - the song id
 	 * @param cb
 	 * @param cb
-	 * @param userId
 	 */
 	 */
-	removeFromQueue: hooks.ownerRequired((session, stationId, songId, cb, userId) => {
+	removeFromQueue: hooks.ownerRequired((session, stationId, songId, cb) => {
 		async.waterfall([
 		async.waterfall([
 			(next) => {
 			(next) => {
 				if (!songId) return next('Invalid song id.');
 				if (!songId) return next('Invalid song id.');
@@ -1019,15 +1060,15 @@ module.exports = {
 			},
 			},
 
 
 			(next) => {
 			(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) => {
 			(res, next) => {
 				stations.updateStation(stationId, next);
 				stations.updateStation(stationId, next);
 			}
 			}
-		], (err, station) => {
+		], async (err, station) => {
 			if (err) {
 			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}"`);
 				logger.error("STATIONS_REMOVE_SONG_TO_QUEUE", `Removing song "${songId}" from station "${stationId}" queue failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 				return cb({'status': 'failure', 'message': err});
 			}
 			}
@@ -1057,14 +1098,15 @@ module.exports = {
 			},
 			},
 
 
 			(station, next) => {
 			(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.');
 					return next('Insufficient permissions.');
 				});
 				});
 			}
 			}
-		], (err, station) => {
+		], async (err, station) => {
 			if (err) {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_GET_QUEUE", `Getting queue for station "${stationId}" failed. "${err}"`);
 				logger.error("STATIONS_GET_QUEUE", `Getting queue for station "${stationId}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 				return cb({'status': 'failure', 'message': err});
 			}
 			}
@@ -1080,9 +1122,8 @@ module.exports = {
 	 * @param stationId - the station id
 	 * @param stationId - the station id
 	 * @param playlistId - the private playlist id
 	 * @param playlistId - the private playlist id
 	 * @param cb
 	 * @param cb
-	 * @param userId
 	 */
 	 */
-	selectPrivatePlaylist: hooks.ownerRequired((session, stationId, playlistId, cb, userId) => {
+	selectPrivatePlaylist: hooks.ownerRequired((session, stationId, playlistId, cb) => {
 		async.waterfall([
 		async.waterfall([
 			(next) => {
 			(next) => {
 				stations.getStation(stationId, next);
 				stations.getStation(stationId, next);
@@ -1098,15 +1139,15 @@ module.exports = {
 			(playlist, next) => {
 			(playlist, next) => {
 				if (!playlist) return next('Playlist not found.');
 				if (!playlist) return next('Playlist not found.');
 				let currentSongIndex = (playlist.songs.length > 0) ? playlist.songs.length - 1 : 0;
 				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) => {
 			(res, next) => {
 				stations.updateStation(stationId, next);
 				stations.updateStation(stationId, next);
 			}
 			}
-		], (err, station) => {
+		], async (err, station) => {
 			if (err) {
 			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}"`);
 				logger.error("STATIONS_SELECT_PRIVATE_PLAYLIST", `Selecting private playlist "${playlistId}" for station "${stationId}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 				return cb({'status': 'failure', 'message': err});
 			}
 			}
@@ -1117,4 +1158,61 @@ module.exports = {
 			return cb({'status': 'success', 'message': 'Successfully selected playlist.'});
 			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 config = require('config');
 const request = require('request');
 const request = require('request');
 const bcrypt = require('bcrypt');
 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 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 => {
 cache.sub('user.updateUsername', user => {
 	utils.socketsFromUser(user._id, sockets => {
 	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 = {
 module.exports = {
 
 
 	/**
 	/**
@@ -84,9 +103,9 @@ module.exports = {
 			(next) => {
 			(next) => {
 				db.models.user.find({}).exec(next);
 				db.models.user.find({}).exec(next);
 			}
 			}
-		], (err, users) => {
+		], async (err, users) => {
 			if (err) {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("USER_INDEX", `Indexing users failed. "${err}"`);
 				logger.error("USER_INDEX", `Indexing users failed. "${err}"`);
 				return cb({status: 'failure', message: err});
 				return cb({status: 'failure', message: err});
 			} else {
 			} else {
@@ -147,16 +166,21 @@ module.exports = {
 			},
 			},
 
 
 			(user, next) => {
 			(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) => {
 				cache.hset('sessions', sessionId, cache.schemas.session(sessionId, user._id), (err) => {
 					if (err) return next(err);
 					if (err) return next(err);
 					next(null, sessionId);
 					next(null, sessionId);
 				});
 				});
 			}
 			}
 
 
-		], (err, sessionId) => {
+		], async (err, sessionId) => {
 			if (err && err !== true) {
 			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}"`);
 				logger.error("USER_PASSWORD_LOGIN", `Login failed with password for user "${identifier}". "${err}"`);
 				return cb({status: 'failure', message: err});
 				return cb({status: 'failure', message: err});
 			}
 			}
@@ -176,9 +200,9 @@ module.exports = {
 	 * @param {Object} recaptcha - the recaptcha data
 	 * @param {Object} recaptcha - the recaptcha data
 	 * @param {Function} cb - gets called with the result
 	 * @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();
 		email = email.toLowerCase();
-		let verificationToken = utils.generateRandomString(64);
+		let verificationToken = await utils.generateRandomString(64);
 		async.waterfall([
 		async.waterfall([
 
 
 			// verify the request with google recaptcha
 			// verify the request with google recaptcha
@@ -225,10 +249,16 @@ module.exports = {
 				bcrypt.hash(sha256(password), salt, next)
 				bcrypt.hash(sha256(password), salt, next)
 			},
 			},
 
 
-			// save the new user to the database
 			(hash, next) => {
 			(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({
 				db.models.user.create({
-					_id: utils.generateRandomString(12),//TODO Check if exists
+					_id,
 					username,
 					username,
 					email: {
 					email: {
 						address: email,
 						address: email,
@@ -250,9 +280,9 @@ module.exports = {
 				});
 				});
 			}
 			}
 
 
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
 			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}"`);
 				logger.error("USER_PASSWORD_REGISTER", `Register failed with password for user "${username}"."${err}"`);
 				cb({status: 'failure', message: err});
 				cb({status: 'failure', message: err});
 			} else {
 			} else {
@@ -290,9 +320,9 @@ module.exports = {
 			(session, next) => {
 			(session, next) => {
 				cache.hdel('sessions', session.sessionId, next);
 				cache.hdel('sessions', session.sessionId, next);
 			}
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("USER_LOGOUT", `Logout failed. "${err}" `);
 				logger.error("USER_LOGOUT", `Logout failed. "${err}" `);
 				cb({ status: 'failure', message: err });
 				cb({ status: 'failure', message: err });
 			} else {
 			} else {
@@ -309,15 +339,15 @@ module.exports = {
 	 * @param {Object} session - the session object automatically added by socket.io
 	 * @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 {String} userId - the id of the user we are trying to delete the sessions of
 	 * @param {Function} cb - gets called with the result
 	 * @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([
 		async.waterfall([
 
 
 			(next) => {
 			(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();
 					else return next();
 				});
 				});
 			},
 			},
@@ -349,9 +379,9 @@ module.exports = {
 				});
 				});
 			}
 			}
 
 
-		], err => {
+		], async err => {
 			if (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}"`);
 				logger.error("REMOVE_SESSIONS_FOR_USER", `Couldn't remove all sessions for user "${userId}". "${err}"`);
 				return cb({ status: 'failure', message: err });
 				return cb({ status: 'failure', message: err });
 			} else {
 			} else {
@@ -379,9 +409,9 @@ module.exports = {
 				if (!account) return next('User not found.');
 				if (!account) return next('User not found.');
 				next(null, account);
 				next(null, account);
 			}
 			}
-		], (err, account) => {
+		], async (err, account) => {
 			if (err && err !== true) {
 			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}"`);
 				logger.error("FIND_BY_USERNAME", `User not found for username "${username}". "${err}"`);
 				cb({status: 'failure', message: err});
 				cb({status: 'failure', message: err});
 			} else {
 			} else {
@@ -412,21 +442,26 @@ module.exports = {
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
 	 */
 	 */
 	getUsernameFromId: (session, userId, cb) => {
 	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}".`);
 				logger.success("GET_USERNAME_FROM_ID", `Found username for userId "${userId}".`);
 				return cb({
 				return cb({
 					status: 'success',
 					status: 'success',
 					data: user.username
 					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.');
 				if (!user) return next('User not found.');
 				next(null, user);
 				next(null, user);
 			}
 			}
-		], (err, user) => {
+		], async (err, user) => {
 			if (err && err !== true) {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("FIND_BY_SESSION", `User not found. "${err}"`);
 				logger.error("FIND_BY_SESSION", `User not found. "${err}"`);
 				cb({status: 'failure', message: err});
 				cb({status: 'failure', message: err});
 			} else {
 			} else {
@@ -487,13 +522,12 @@ module.exports = {
 	 * @param {String} updatingUserId - the updating user's id
 	 * @param {String} updatingUserId - the updating user's id
 	 * @param {String} newUsername - the new username
 	 * @param {String} newUsername - the new username
 	 * @param {Function} cb - gets called with the result
 	 * @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([
 		async.waterfall([
 			(next) => {
 			(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) => {
 			(user, next) => {
@@ -518,11 +552,11 @@ module.exports = {
 			},
 			},
 
 
 			(next) => {
 			(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) {
 			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}"`);
 				logger.error("UPDATE_USERNAME", `Couldn't update username for user "${updatingUserId}" to username "${newUsername}". "${err}"`);
 				cb({status: 'failure', message: err});
 				cb({status: 'failure', message: err});
 			} else {
 			} else {
@@ -543,15 +577,14 @@ module.exports = {
 	 * @param {String} updatingUserId - the updating user's id
 	 * @param {String} updatingUserId - the updating user's id
 	 * @param {String} newEmail - the new email
 	 * @param {String} newEmail - the new email
 	 * @param {Function} cb - gets called with the result
 	 * @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();
 		newEmail = newEmail.toLowerCase();
-		let verificationToken = utils.generateRandomString(64);
+		let verificationToken = await utils.generateRandomString(64);
 		async.waterfall([
 		async.waterfall([
 			(next) => {
 			(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) => {
 			(user, next) => {
@@ -576,7 +609,7 @@ module.exports = {
 			},
 			},
 
 
 			(next) => {
 			(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) => {
 			(res, next) => {
@@ -588,9 +621,9 @@ module.exports = {
 					next();
 					next();
 				});
 				});
 			}
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
 			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}'`);
 				logger.error("UPDATE_EMAIL", `Couldn't update email for user "${updatingUserId}" to email "${newEmail}". '${err}'`);
 				cb({status: 'failure', message: err});
 				cb({status: 'failure', message: err});
 			} else {
 			} else {
@@ -607,9 +640,8 @@ module.exports = {
 	 * @param {String} updatingUserId - the updating user's id
 	 * @param {String} updatingUserId - the updating user's id
 	 * @param {String} newRole - the new role
 	 * @param {String} newRole - the new role
 	 * @param {Function} cb - gets called with the result
 	 * @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();
 		newRole = newRole.toLowerCase();
 		async.waterfall([
 		async.waterfall([
 
 
@@ -623,16 +655,16 @@ module.exports = {
 				else return next();
 				else return next();
 			},
 			},
 			(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) {
 			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});
 				cb({status: 'failure', message: err});
 			} else {
 			} 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({
 				cb({
 					status: 'success',
 					status: 'success',
 					message: 'Role successfully updated.'
 					message: 'Role successfully updated.'
@@ -647,12 +679,11 @@ module.exports = {
 	 * @param {Object} session - the session object automatically added by socket.io
 	 * @param {Object} session - the session object automatically added by socket.io
 	 * @param {String} newPassword - the new password
 	 * @param {String} newPassword - the new password
 	 * @param {Function} cb - gets called with the result
 	 * @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([
 		async.waterfall([
 			(next) => {
 			(next) => {
-				db.models.user.findOne({_id: userId}, next);
+				db.models.user.findOne({_id: session.userId}, next);
 			},
 			},
 
 
 			(user, next) => {
 			(user, next) => {
@@ -675,16 +706,16 @@ module.exports = {
 			},
 			},
 
 
 			(hashedPassword, next) => {
 			(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) {
 			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 });
 				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({
 			cb({
 				status: 'success',
 				status: 'success',
 				message: 'Password successfully updated.'
 				message: 'Password successfully updated.'
@@ -698,13 +729,12 @@ module.exports = {
 	 * @param {Object} session - the session object automatically added by socket.io
 	 * @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 {String} email - the email of the user that requests a password reset
 	 * @param {Function} cb - gets called with the result
 	 * @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([
 		async.waterfall([
 			(next) => {
 			(next) => {
-				db.models.user.findOne({_id: userId}, next);
+				db.models.user.findOne({_id: session.userId}, next);
 			},
 			},
 
 
 			(user, next) => {
 			(user, next) => {
@@ -722,13 +752,13 @@ module.exports = {
 			(user, next) => {
 			(user, next) => {
 				mail.schemas.passwordRequest(user.email.address, user.username, code, next);
 				mail.schemas.passwordRequest(user.email.address, user.username, code, next);
 			}
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
 			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});
 				cb({status: 'failure', message: err});
 			} else {
 			} else {
-				logger.success("REQUEST_PASSWORD", `UserId '${userId}' successfully requested a password.`);
+				logger.success("REQUEST_PASSWORD", `UserId '${session.userId}' successfully requested a password.`);
 				cb({
 				cb({
 					status: 'success',
 					status: 'success',
 					message: 'Successfully requested password.'
 					message: 'Successfully requested password.'
@@ -743,13 +773,12 @@ module.exports = {
 	 * @param {Object} session - the session object automatically added by socket.io
 	 * @param {Object} session - the session object automatically added by socket.io
 	 * @param {String} code - the password code
 	 * @param {String} code - the password code
 	 * @param {Function} cb - gets called with the result
 	 * @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([
 		async.waterfall([
 			(next) => {
 			(next) => {
 				if (!code || typeof code !== 'string') return next('Invalid code1.');
 				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) => {
 			(user, next) => {
@@ -757,9 +786,9 @@ module.exports = {
 				if (user.services.password.set.expires < new Date()) return next('That code has expired.');
 				if (user.services.password.set.expires < new Date()) return next('That code has expired.');
 				next(null);
 				next(null);
 			}
 			}
-		], (err) => {
+		], async(err) => {
 			if (err && err !== true) {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("VERIFY_PASSWORD_CODE", `Code '${code}' failed to verify. '${err}'`);
 				logger.error("VERIFY_PASSWORD_CODE", `Code '${code}' failed to verify. '${err}'`);
 				cb({status: 'failure', message: err});
 				cb({status: 'failure', message: err});
 			} else {
 			} else {
@@ -779,9 +808,8 @@ module.exports = {
 	 * @param {String} code - the password code
 	 * @param {String} code - the password code
 	 * @param {String} newPassword - the new password code
 	 * @param {String} newPassword - the new password code
 	 * @param {Function} cb - gets called with the result
 	 * @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([
 		async.waterfall([
 			(next) => {
 			(next) => {
 				if (!code || typeof code !== 'string') return next('Invalid code1.');
 				if (!code || typeof code !== 'string') return next('Invalid code1.');
@@ -809,16 +837,16 @@ module.exports = {
 			},
 			},
 
 
 			(hashedPassword, next) => {
 			(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) {
 			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}'`);
 				logger.error("ADD_PASSWORD_WITH_CODE", `Code '${code}' failed to add password. '${err}'`);
 				cb({status: 'failure', message: err});
 				cb({status: 'failure', message: err});
 			} else {
 			} else {
 				logger.success("ADD_PASSWORD_WITH_CODE", `Code '${code}' successfully added password.`);
 				logger.success("ADD_PASSWORD_WITH_CODE", `Code '${code}' successfully added password.`);
-				cache.pub('user.linkPassword', userId);
+				cache.pub('user.linkPassword', session.userId);
 				cb({
 				cb({
 					status: 'success',
 					status: 'success',
 					message: 'Successfully added password.'
 					message: 'Successfully added password.'
@@ -832,27 +860,26 @@ module.exports = {
 	 *
 	 *
 	 * @param {Object} session - the session object automatically added by socket.io
 	 * @param {Object} session - the session object automatically added by socket.io
 	 * @param {Function} cb - gets called with the result
 	 * @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([
 		async.waterfall([
 			(next) => {
 			(next) => {
-				db.models.user.findOne({_id: userId}, next);
+				db.models.user.findOne({_id: session.userId}, next);
 			},
 			},
 
 
 			(user, next) => {
 			(user, next) => {
 				if (!user) return next('Not logged in.');
 				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.');
 				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) {
 			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});
 				cb({status: 'failure', message: err});
 			} else {
 			} 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({
 				cb({
 					status: 'success',
 					status: 'success',
 					message: 'Successfully unlinked password.'
 					message: 'Successfully unlinked password.'
@@ -866,27 +893,26 @@ module.exports = {
 	 *
 	 *
 	 * @param {Object} session - the session object automatically added by socket.io
 	 * @param {Object} session - the session object automatically added by socket.io
 	 * @param {Function} cb - gets called with the result
 	 * @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([
 		async.waterfall([
 			(next) => {
 			(next) => {
-				db.models.user.findOne({_id: userId}, next);
+				db.models.user.findOne({_id: session.userId}, next);
 			},
 			},
 
 
 			(user, next) => {
 			(user, next) => {
 				if (!user) return next('Not logged in.');
 				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.');
 				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) {
 			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});
 				cb({status: 'failure', message: err});
 			} else {
 			} 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({
 				cb({
 					status: 'success',
 					status: 'success',
 					message: 'Successfully unlinked GitHub.'
 					message: 'Successfully unlinked GitHub.'
@@ -902,8 +928,8 @@ module.exports = {
 	 * @param {String} email - the email of the user that requests a password reset
 	 * @param {String} email - the email of the user that requests a password reset
 	 * @param {Function} cb - gets called with the result
 	 * @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([
 		async.waterfall([
 			(next) => {
 			(next) => {
 				if (!email || typeof email !== 'string') return next('Invalid email.');
 				if (!email || typeof email !== 'string') return next('Invalid email.');
@@ -926,9 +952,9 @@ module.exports = {
 			(user, next) => {
 			(user, next) => {
 				mail.schemas.resetPasswordRequest(user.email.address, user.username, code, next);
 				mail.schemas.resetPasswordRequest(user.email.address, user.username, code, next);
 			}
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
 			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}'`);
 				logger.error("REQUEST_PASSWORD_RESET", `Email '${email}' failed to request password reset. '${err}'`);
 				cb({status: 'failure', message: err});
 				cb({status: 'failure', message: err});
 			} else {
 			} else {
@@ -960,9 +986,9 @@ module.exports = {
 				if (!user.services.password.reset.expires > new Date()) return next('That code has expired.');
 				if (!user.services.password.reset.expires > new Date()) return next('That code has expired.');
 				next(null);
 				next(null);
 			}
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
 			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}'`);
 				logger.error("VERIFY_PASSWORD_RESET_CODE", `Code '${code}' failed to verify. '${err}'`);
 				cb({status: 'failure', message: err});
 				cb({status: 'failure', message: err});
 			} else {
 			} else {
@@ -1011,11 +1037,11 @@ module.exports = {
 			},
 			},
 
 
 			(hashedPassword, next) => {
 			(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) {
 			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}'`);
 				logger.error("CHANGE_PASSWORD_WITH_RESET_CODE", `Code '${code}' failed to change password. '${err}'`);
 				cb({status: 'failure', message: err});
 				cb({status: 'failure', message: err});
 			} else {
 			} else {
@@ -1036,13 +1062,12 @@ module.exports = {
 	 * @param {String} reason - the reason for the ban
 	 * @param {String} reason - the reason for the ban
 	 * @param {String} expiresAt - the time the ban expires
 	 * @param {String} expiresAt - the time the ban expires
 	 * @param {Function} cb - gets called with the result
 	 * @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([
 		async.waterfall([
 			(next) => {
 			(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();
 				else return next();
 			},
 			},
 
 
@@ -1085,25 +1110,50 @@ module.exports = {
 			},
 			},
 
 
 			(next) => {
 			(next) => {
-				punishments.addPunishment('banUserId', value, reason, expiresAt, userId, next)
+				punishments.addPunishment('banUserId', userId, reason, expiresAt, userId, next)
 			},
 			},
 
 
 			(punishment, next) => {
 			(punishment, next) => {
-				cache.pub('user.ban', {userId: value, punishment});
+				cache.pub('user.ban', { userId, punishment });
 				next();
 				next();
 			},
 			},
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
 			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});
 				cb({status: 'failure', message: err});
 			} else {
 			} 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({
 				cb({
 					status: 'success',
 					status: 'success',
 					message: 'Successfully banned user.'
 					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';
 'use strict';
 
 
+const coreClass = require("../core");
+
 const express = require('express');
 const express = require('express');
 const bodyParser = require('body-parser');
 const bodyParser = require('body-parser');
 const cookieParser = require('cookie-parser');
 const cookieParser = require('cookie-parser');
 const cors = require('cors');
 const cors = require('cors');
 const config = require('config');
 const config = require('config');
 const async = require('async');
 const async = require('async');
-const logger = require('./logger');
-const mail = require('./mail');
 const request = require('request');
 const request = require('request');
 const OAuth2 = require('oauth').OAuth2;
 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';
 'use strict';
 
 
+const coreClass = require("../../core");
+
 const redis = require('redis');
 const redis = require('redis');
+const config = require('config');
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 
 
 // Lightweight / convenience wrapper around redis module for our needs
 // Lightweight / convenience wrapper around redis module for our needs
 
 
 const pubs = {}, subs = {};
 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
 	 * 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(pubs).forEach((channel) => pubs[channel].quit());
 			Object.keys(subs).forEach((channel) => subs[channel].client.quit());
 			Object.keys(subs).forEach((channel) => subs[channel].client.quit());
 		}
 		}
-	},
+	}
 
 
 	/**
 	/**
 	 * Sets a single value in a table
 	 * 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 {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
 	 * @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();
 		if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
 		// automatically stringify objects and arrays into JSON
 		// automatically stringify objects and arrays into JSON
 		if (stringifyJson && ['object', 'array'].includes(typeof value)) value = JSON.stringify(value);
 		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 (cb !== undefined) {
 				if (err) return cb(err);
 				if (err) return cb(err);
 				cb(null, JSON.parse(value));
 				cb(null, JSON.parse(value));
 			}
 			}
 		});
 		});
-	},
+	}
 
 
 	/**
 	/**
 	 * Gets a single value from a table
 	 * 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 {Function} cb - gets called when the value is returned from Redis
 	 * @param {Boolean} [parseJson=true] - attempt to parse returned data as JSON
 	 * @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 (!key || !table) return typeof cb === 'function' ? cb(null, null) : null;
 		if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
 		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 (err) return typeof cb === 'function' ? cb(err) : null;
 			if (parseJson) try {
 			if (parseJson) try {
 				value = JSON.parse(value);
 				value = JSON.parse(value);
@@ -106,7 +126,7 @@ const lib = {
 			}
 			}
 			if (typeof cb === 'function') cb(null, value);
 			if (typeof cb === 'function') cb(null, value);
 		});
 		});
-	},
+	}
 
 
 	/**
 	/**
 	 * Deletes a single value from a table
 	 * Deletes a single value from a table
@@ -115,15 +135,17 @@ const lib = {
 	 * @param {String} key - name of the key to delete
 	 * @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
 	 * @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();
 		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);
 			if (err) return cb(err);
 			else return cb(null);
 			else return cb(null);
 		});
 		});
-	},
+	}
 
 
 	/**
 	/**
 	 * Returns all the keys for a table
 	 * 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 {Function} cb - gets called when the values are returned from Redis
 	 * @param {Boolean} [parseJson=true] - attempts to parse all values as JSON by default
 	 * @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);
 		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 (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) Object.keys(obj).forEach((key) => { try { obj[key] = JSON.parse(obj[key]); } catch (e) {} });
 			if (parseJson && !obj) obj = [];
 			if (parseJson && !obj) obj = [];
 			cb(null, obj);
 			cb(null, obj);
 		});
 		});
-	},
+	}
 
 
 	/**
 	/**
 	 * Publish a message to a channel, caches the redis client connection
 	 * 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 {*} value - the value we want to send
 	 * @param {Boolean} [stringifyJson=true] - stringify 'value' if it's an Object or Array
 	 * @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) {
 		/*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);
 		 pubs[channel].on('error', (err) => console.error);
 		 }*/
 		 }*/
 
 
 		if (stringifyJson && ['object', 'array'].includes(typeof value)) value = JSON.stringify(value);
 		if (stringifyJson && ['object', 'array'].includes(typeof value)) value = JSON.stringify(value);
 
 
 		//pubs[channel].publish(channel, 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
 	 * 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 {Function} cb - gets called when a message is received
 	 * @param {Boolean} [parseJson=true] - parse the message as JSON
 	 * @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';
 'use strict';
 
 
+const coreClass = require("../../core");
+
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 const config = require('config');
 const config = require('config');
 
 
-const bluebird = require('bluebird');
-
 const regex = {
 const regex = {
 	azAZ09_: /^[A-Za-z0-9_]+$/,
 	azAZ09_: /^[A-Za-z0-9_]+$/,
 	az09_: /^[a-z0-9_]+$/,
 	az09_: /^[a-z0-9_]+$/,
 	emailSimple: /^[\x00-\x7F]+@[a-z0-9]+\.[a-z0-9]+(\.[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) => {
 const isLength = (string, min, max) => {
 	return !(typeof string !== 'string' || string.length < min || string.length > 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 },
 	thumbnail: { type: String, required: true },
 	explicit: { type: Boolean, required: true },
 	explicit: { type: Boolean, required: true },
 	requestedBy: { type: String, 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 = {
 module.exports = {
 	resolved: { type: Boolean, default: false, required: true },
 	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 },
 	description: { type: String },
 	issues: [{
 	issues: [{
 		name: String,
 		name: String,

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

@@ -12,5 +12,6 @@ module.exports = {
 	requestedBy: { type: String, required: true },
 	requestedBy: { type: String, required: true },
 	requestedAt: { type: Date, required: true },
 	requestedAt: { type: Date, required: true },
 	acceptedBy: { type: String, 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 = {
 module.exports = {
-	_id: { type: String, required: true, index: true, unique: true, min: 12, max: 12 },
 	username: { type: String, required: true },
 	username: { type: String, required: true },
 	role: { type: String, default: 'default', required: true },
 	role: { type: String, default: 'default', required: true },
 	email: {
 	email: {
@@ -29,5 +28,6 @@ module.exports = {
 	},
 	},
 	liked: [{ type: String }],
 	liked: [{ type: String }],
 	disliked: [{ type: String }],
 	disliked: [{ type: String }],
+	favoriteStations: [{ type: String }],
 	createdAt: { type: Date, default: Date.now() }
 	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
 // 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();
 						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';
 '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;
 	return (num < 10) ? '0' + num : num;
 };
 };
 
 
-let getTime = () => {
+const getTime = () => {
 	let time = new Date();
 	let time = new Date();
 	return {
 	return {
 		year: time.getFullYear(),
 		year: time.getFullYear(),
@@ -84,121 +21,157 @@ let getTime = () => {
 	}
 	}
 };
 };
 
 
-let getTimeFormatted = () => {
+const getTimeFormatted = () => {
 	let time = getTime();
 	let time = getTime();
 	return `${time.year}-${twoDigits(time.month)}-${twoDigits(time.day)} ${twoDigits(time.hour)}:${twoDigits(time.minute)}:${twoDigits(time.second)}`;
 	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';
 '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 (!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 config = require('config');
-const mail = require('../index');
+
+const moduleManager = require('../../../index');
+
+const mail = moduleManager.modules["mail"];
 
 
 /**
 /**
  * Sends a request password email
  * Sends a request password email

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

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

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

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

+ 111 - 58
backend/logic/notifications.js

@@ -1,49 +1,105 @@
 'use strict';
 'use strict';
 
 
+const coreClass = require("../core");
+
 const crypto = require('crypto');
 const crypto = require('crypto');
 const redis = require('redis');
 const redis = require('redis');
-const logger = require('./logger');
+const config = require('config');
 
 
 const subscriptions = [];
 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,
 	 * 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 {Integer} time - how long in milliseconds until the notification should be fired
 	 * @param {Function} cb - gets called when the notification has been scheduled
 	 * @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 = ()=>{};
 		if (!cb) cb = ()=>{};
+
 		time = Math.round(time);
 		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
 	 * 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
 	 * @param {Boolean} unique - only subscribe if another subscription with the same name doesn't already exist
 	 * @return {Object} - the subscription object
 	 * @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;
 		if (unique && !!subscriptions.find((subscription) => subscription.originalName == name)) return;
 		let subscription = { originalName: name, name: crypto.createHash('md5').update(`_notification:${name}_`).digest('hex'), cb };
 		let subscription = { originalName: name, name: crypto.createHash('md5').update(`_notification:${name}_`).digest('hex'), cb };
 		subscriptions.push(subscription);
 		subscriptions.push(subscription);
 		return subscription;
 		return subscription;
-	},
+	}
 
 
 	/**
 	/**
 	 * Remove a notification subscription
 	 * Remove a notification subscription
 	 *
 	 *
 	 * @param {Object} subscription - the subscription object returned by {@link subscribe}
 	 * @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);
 		let index = subscriptions.indexOf(subscription);
 		if (index) subscriptions.splice(index, 1);
 		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';
 '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
 	 * 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 {String} playlistId - the id of the playlist we are trying to get
 	 * @param {Function} cb - gets called once we're done initializing
 	 * @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([
 		async.waterfall([
 			(next) => {
 			(next) => {
-				cache.hgetall('playlists', next);
+				this.cache.hgetall('playlists', next);
 			},
 			},
 
 
 			(playlists, next) => {
 			(playlists, next) => {
 				if (!playlists) return next();
 				if (!playlists) return next();
 				let playlistIds = Object.keys(playlists);
 				let playlistIds = Object.keys(playlists);
 				async.each(playlistIds, (playlistId, next) => {
 				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);
 						if (err) next(err);
 						else if (!playlist) {
 						else if (!playlist) {
-							cache.hdel('playlists', playlistId, next);
+							this.cache.hdel('playlists', playlistId, next);
 						}
 						}
 						else next();
 						else next();
 					});
 					});
@@ -83,17 +91,17 @@ module.exports = {
 			},
 			},
 
 
 			(next) => {
 			(next) => {
-				cache.hget('playlists', playlistId, next);
+				this.cache.hget('playlists', playlistId, next);
 			},
 			},
 
 
 			(playlist, next) => {
 			(playlist, next) => {
 				if (playlist) return next(true, playlist);
 				if (playlist) return next(true, playlist);
-				db.models.playlist.findOne({ _id: playlistId }, next);
+				this.db.models.playlist.findOne({ _id: playlistId }, next);
 			},
 			},
 
 
 			(playlist, next) => {
 			(playlist, next) => {
 				if (playlist) {
 				if (playlist) {
-					cache.hset('playlists', playlistId, playlist, next);
+					this.cache.hset('playlists', playlistId, playlist, next);
 				} else next('Playlist not found');
 				} else next('Playlist not found');
 			},
 			},
 
 
@@ -101,7 +109,7 @@ module.exports = {
 			if (err && err !== true) return cb(err);
 			if (err && err !== true) return cb(err);
 			else cb(null, playlist);
 			else cb(null, playlist);
 		});
 		});
-	},
+	}
 
 
 	/**
 	/**
 	 * Gets a playlist from id from Mongo and updates the cache with it
 	 * 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 {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
 	 * @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) => {
 			(next) => {
-				db.models.playlist.findOne({ _id: playlistId }, next);
+				this.db.models.playlist.findOne({ _id: playlistId }, next);
 			},
 			},
 
 
 			(playlist, next) => {
 			(playlist, next) => {
 				if (!playlist) {
 				if (!playlist) {
-					cache.hdel('playlists', playlistId);
+					this.cache.hdel('playlists', playlistId);
 					return next('Playlist not found');
 					return next('Playlist not found');
 				}
 				}
-				cache.hset('playlists', playlistId, playlist, next);
+				this.cache.hset('playlists', playlistId, playlist, next);
 			}
 			}
 
 
 		], (err, playlist) => {
 		], (err, playlist) => {
 			if (err && err !== true) return cb(err);
 			if (err && err !== true) return cb(err);
 			cb(null, playlist);
 			cb(null, playlist);
 		});
 		});
-	},
+	}
 
 
 	/**
 	/**
 	 * Deletes playlist from id from Mongo and cache
 	 * 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 {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
 	 * @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([
 		async.waterfall([
 
 
 			(next) => {
 			(next) => {
-				db.models.playlist.remove({ _id: playlistId }, next);
+				this.db.models.playlist.deleteOne({ _id: playlistId }, next);
 			},
 			},
 
 
 			(res, next) => {
 			(res, next) => {
-				cache.hdel('playlists', playlistId, next);
+				this.cache.hdel('playlists', playlistId, next);
 			}
 			}
 
 
 		], (err) => {
 		], (err) => {
@@ -154,9 +163,5 @@ module.exports = {
 
 
 			cb(null);
 			cb(null);
 		});
 		});
-	},
-
-	_lockdown: () => {
-		lockdown = true;
 	}
 	}
-};
+}

+ 87 - 79
backend/logic/punishments.js

@@ -1,73 +1,80 @@
 'use strict';
 '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 async = require('async');
 const mongoose = require('mongoose');
 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
 	 * 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
 	 * @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 = [];
 		let punishmentsToRemove = [];
 		async.waterfall([
 		async.waterfall([
 			(next) => {
 			(next) => {
-				cache.hgetall('punishments', next);
+				this.cache.hgetall('punishments', next);
 			},
 			},
 
 
 			(punishmentsObj, next) => {
 			(punishmentsObj, next) => {
@@ -88,7 +95,7 @@ module.exports = {
 				async.each(
 				async.each(
 					punishmentsToRemove,
 					punishmentsToRemove,
 					(punishment, next2) => {
 					(punishment, next2) => {
-						cache.hdel('punishments', punishment.punishmentId, () => {
+						this.cache.hdel('punishments', punishment.punishmentId, () => {
 							next2();
 							next2();
 						});
 						});
 					},
 					},
@@ -102,7 +109,7 @@ module.exports = {
 
 
 			cb(null, punishments);
 			cb(null, punishments);
 		});
 		});
-	},
+	}
 
 
 	/**
 	/**
 	 * Gets a punishment by id
 	 * 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 {String} id - the id of the punishment we are trying to get
 	 * @param {Function} cb - gets called once we're done initializing
 	 * @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([
 		async.waterfall([
 
 
 			(next) => {
 			(next) => {
 				if (!mongoose.Types.ObjectId.isValid(id)) return next('Id is not a valid ObjectId.');
 				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) => {
 			(punishment, next) => {
 				if (punishment) return next(true, punishment);
 				if (punishment) return next(true, punishment);
-				db.models.punishment.findOne({_id: id}, next);
+				this.db.models.punishment.findOne({_id: id}, next);
 			},
 			},
 
 
 			(punishment, next) => {
 			(punishment, next) => {
 				if (punishment) {
 				if (punishment) {
-					cache.hset('punishments', id, punishment, next);
+					this.cache.hset('punishments', id, punishment, next);
 				} else next('Punishment not found.');
 				} else next('Punishment not found.');
 			},
 			},
 
 
@@ -135,7 +143,7 @@ module.exports = {
 
 
 			cb(null, punishment);
 			cb(null, punishment);
 		});
 		});
-	},
+	}
 
 
 	/**
 	/**
 	 * Gets all punishments from a userId
 	 * 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 {String} userId - the userId of the punishment(s) we are trying to get
 	 * @param {Function} cb - gets called once we're done initializing
 	 * @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([
 		async.waterfall([
 			(next) => {
 			(next) => {
-				module.exports.getPunishments(next);
+				this.getPunishments(next);
 			},
 			},
 			(punishments, next) => {
 			(punishments, next) => {
 				punishments = punishments.filter((punishment) => {
 				punishments = punishments.filter((punishment) => {
@@ -160,13 +169,14 @@ module.exports = {
 
 
 			cb(null, punishments);
 			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([
 		async.waterfall([
 			(next) => {
 			(next) => {
-				const punishment = new db.models.punishment({
+				const punishment = new this.db.models.punishment({
 					type,
 					type,
 					value,
 					value,
 					reason,
 					reason,
@@ -182,7 +192,7 @@ module.exports = {
 			},
 			},
 
 
 			(punishment, next) => {
 			(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);
 					next(err, punishment);
 				});
 				});
 			},
 			},
@@ -194,13 +204,14 @@ module.exports = {
 		], (err, punishment) => {
 		], (err, punishment) => {
 			cb(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([
 		async.waterfall([
 			(next) => {
 			(next) => {
-				const punishment = new db.models.punishment({
+				const punishment = new this.db.models.punishment({
 					type,
 					type,
 					value,
 					value,
 					reason,
 					reason,
@@ -217,7 +228,7 @@ module.exports = {
 			},
 			},
 
 
 			(punishment, next) => {
 			(punishment, next) => {
-				cache.hset('punishments', punishment._id, punishment, next);
+				this.cache.hset('punishments', punishment._id, punishment, next);
 			},
 			},
 
 
 			(punishment, next) => {
 			(punishment, next) => {
@@ -227,9 +238,6 @@ module.exports = {
 		], (err) => {
 		], (err) => {
 			cb(err);
 			cb(err);
 		});
 		});
-	},
-
-	_lockdown: () => {
-		lockdown = true;
 	}
 	}
-};
+}
+

+ 80 - 72
backend/logic/songs.js

@@ -1,60 +1,69 @@
 'use strict';
 '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 async = require('async');
 const mongoose = require('mongoose');
 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
 	 * 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 {String} id - the id of the song we are trying to get
 	 * @param {Function} cb - gets called once we're done initializing
 	 * @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) => {
 			(next) => {
 				if (!mongoose.Types.ObjectId.isValid(id)) return next('Id is not a valid ObjectId.');
 				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) => {
 			(song, next) => {
 				if (song) return next(true, song);
 				if (song) return next(true, song);
-				db.models.song.findOne({_id: id}, next);
+				this.db.models.song.findOne({_id: id}, next);
 			},
 			},
 
 
 			(song, next) => {
 			(song, next) => {
 				if (song) {
 				if (song) {
-					cache.hset('songs', id, song, next);
+					this.cache.hset('songs', id, song, next);
 				} else next('Song not found.');
 				} else next('Song not found.');
 			},
 			},
 
 
@@ -87,7 +96,7 @@ module.exports = {
 
 
 			cb(null, song);
 			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
 	 * 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 {String} songId - the mongo id of the song we are trying to get
 	 * @param {Function} cb - gets called once we're done initializing
 	 * @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([
 		async.waterfall([
 			(next) => {
 			(next) => {
-				db.models.song.findOne({ songId }, next);
+				this.db.models.song.findOne({ songId }, next);
 			}
 			}
 		], (err, song) => {
 		], (err, song) => {
 			if (err && err !== true) return cb(err);
 			if (err && err !== true) return cb(err);
 			else return cb(null, song);
 			else return cb(null, song);
 		});
 		});
-	},
+	}
 
 
 	/**
 	/**
 	 * Gets a song from id from Mongo and updates the cache with it
 	 * 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 {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
 	 * @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([
 		async.waterfall([
 
 
 			(next) => {
 			(next) => {
-				db.models.song.findOne({_id: songId}, next);
+				this.db.models.song.findOne({_id: songId}, next);
 			},
 			},
 
 
 			(song, next) => {
 			(song, next) => {
 				if (!song) {
 				if (!song) {
-					cache.hdel('songs', songId);
+					this.cache.hdel('songs', songId);
 					return next('Song not found.');
 					return next('Song not found.');
 				}
 				}
 
 
-				cache.hset('songs', songId, song, next);
+				this.cache.hset('songs', songId, song, next);
 			}
 			}
 
 
 		], (err, song) => {
 		], (err, song) => {
@@ -135,7 +146,7 @@ module.exports = {
 
 
 			cb(null, song);
 			cb(null, song);
 		});
 		});
-	},
+	}
 
 
 	/**
 	/**
 	 * Deletes song from id from Mongo and cache
 	 * 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 {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
 	 * @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([
 		async.waterfall([
 
 
 			(next) => {
 			(next) => {
-				db.models.song.remove({ songId }, next);
+				this.db.models.song.deleteOne({ songId }, next);
 			},
 			},
 
 
 			(next) => {
 			(next) => {
-				cache.hdel('songs', songId, next);
+				this.cache.hdel('songs', songId, next);
 			}
 			}
 
 
 		], (err) => {
 		], (err) => {
@@ -160,9 +172,5 @@ module.exports = {
 
 
 			cb(null);
 			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';
 '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');
 const async = require('async');
 
 
 let subscription = null;
 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 = ()=>{};
 		if (typeof cb !== 'function') cb = ()=>{};
-		let _this = this;
+
 		async.waterfall([
 		async.waterfall([
 			(next) => {
 			(next) => {
-				_this.getStation(stationId, next);
+				this.getStation(stationId, next, true);
 			},
 			},
 			(station, next) => {
 			(station, next) => {
 				if (!station) return next('Station not found.');
 				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);
 				if (station.paused) return next(true, station);
 				next(null, station);
 				next(null, station);
 			},
 			},
 			(station, next) => {
 			(station, next) => {
 				if (!station.currentSong) {
 				if (!station.currentSong) {
-					return _this.skipStation(station._id)((err, station) => {
+					return this.skipStation(station._id)((err, station) => {
 						if (err) return next(err);
 						if (err) return next(err);
 						return next(true, station);
 						return next(true, station);
-					});
+					}, true);
 				}
 				}
 				let timeLeft = ((station.currentSong.duration * 1000) - (Date.now() - station.startedAt - station.timePaused));
 				let timeLeft = ((station.currentSong.duration * 1000) - (Date.now() - station.startedAt - station.timePaused));
 				if (isNaN(timeLeft)) timeLeft = -1;
 				if (isNaN(timeLeft)) timeLeft = -1;
 				if (station.currentSong.duration * 1000 < timeLeft || timeLeft < 0) {
 				if (station.currentSong.duration * 1000 < timeLeft || timeLeft < 0) {
 					this.skipStation(station._id)((err, station) => {
 					this.skipStation(station._id)((err, station) => {
 						next(err, station);
 						next(err, station);
-					});
+					}, true);
 				} else {
 				} 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);
 					next(null, station);
 				}
 				}
 			}
 			}
@@ -132,17 +158,18 @@ module.exports = {
 			if (err && err !== true) return cb(err);
 			if (err && err !== true) return cb(err);
 			cb(null, station);
 			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 = [];
 		let songList = [];
 		async.waterfall([
 		async.waterfall([
 			(next) => {
 			(next) => {
+				if (station.genres.length === 0) return next();
 				let genresDone = [];
 				let genresDone = [];
 				station.genres.forEach((genre) => {
 				station.genres.forEach((genre) => {
-					db.models.song.find({genres: genre}, (err, songs) => {
+					this.db.models.song.find({genres: genre}, (err, songs) => {
 						if (!err) {
 						if (!err) {
 							songs.forEach((song) => {
 							songs.forEach((song) => {
 								if (songList.indexOf(song._id) === -1) {
 								if (songList.indexOf(song._id) === -1) {
@@ -171,47 +198,51 @@ module.exports = {
 					if (songList.indexOf(songId) !== -1) playlist.push(songId);
 					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);
 					next(null, playlist);
 				});
 				});
 			},
 			},
 
 
 			(playlist, next) => {
 			(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);
 						next(err, playlist);
-					});
+					}, true);
 				});
 				});
 			}
 			}
 
 
 		], (err, newPlaylist) => {
 		], (err, newPlaylist) => {
 			cb(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.
 	// 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([
 		async.waterfall([
 			(next) => {
 			(next) => {
-				cache.hget('stations', stationId, next);
+				this.cache.hget('stations', stationId, next);
 			},
 			},
 
 
 			(station, next) => {
 			(station, next) => {
 				if (station) return next(true, station);
 				if (station) return next(true, station);
-				db.models.station.findOne({ _id: stationId }, next);
+				this.db.models.station.findOne({ _id: stationId }, next);
 			},
 			},
 
 
 			(station, next) => {
 			(station, next) => {
 				if (station) {
 				if (station) {
 					if (station.type === 'official') {
 					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);
 					next(true, station);
 				} else next('Station not found');
 				} else next('Station not found');
 			},
 			},
@@ -220,25 +251,25 @@ module.exports = {
 			if (err && err !== true) return cb(err);
 			if (err && err !== true) return cb(err);
 			cb(null, station);
 			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.
 	// 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([
 		async.waterfall([
 
 
 			(next) => {
 			(next) => {
-				db.models.station.findOne({ name: stationName }, next);
+				this.db.models.station.findOne({ name: stationName }, next);
 			},
 			},
 
 
 			(station, next) => {
 			(station, next) => {
 				if (station) {
 				if (station) {
 					if (station.type === 'official') {
 					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);
 					next(true, station);
 				} else next('Station not found');
 				} else next('Station not found');
 			},
 			},
@@ -247,36 +278,37 @@ module.exports = {
 			if (err && err !== true) return cb(err);
 			if (err && err !== true) return cb(err);
 			cb(null, station);
 			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([
 		async.waterfall([
 
 
 			(next) => {
 			(next) => {
-				db.models.station.findOne({ _id: stationId }, next);
+				this.db.models.station.findOne({ _id: stationId }, next);
 			},
 			},
 
 
 			(station, next) => {
 			(station, next) => {
 				if (!station) {
 				if (!station) {
-					cache.hdel('stations', stationId);
+					this.cache.hdel('stations', stationId);
 					return next('Station not found');
 					return next('Station not found');
 				}
 				}
-				cache.hset('stations', stationId, station, next);
+				this.cache.hset('stations', stationId, station, next);
 			}
 			}
 
 
 		], (err, station) => {
 		], (err, station) => {
 			if (err && err !== true) return cb(err);
 			if (err && err !== true) return cb(err);
 			cb(null, station);
 			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 = [];
 		let lessInfoPlaylist = [];
 		async.each(songList, (song, next) => {
 		async.each(songList, (song, next) => {
-			songs.getSong(song, (err, song) => {
+			this.songs.getSong(song, (err, song) => {
 				if (!err && song) {
 				if (!err && song) {
 					let newSong = {
 					let newSong = {
 						songId: song.songId,
 						songId: song.songId,
@@ -289,36 +321,36 @@ module.exports = {
 				next();
 				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();
 				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 = ()=>{};
 			if (typeof cb !== 'function') cb = ()=>{};
 
 
 			async.waterfall([
 			async.waterfall([
 				(next) => {
 				(next) => {
-					_this.getStation(stationId, next);
+					this.getStation(stationId, next, true);
 				},
 				},
 				(station, next) => {
 				(station, next) => {
 					if (!station) return next('Station not found.');
 					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) 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
 					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);
 							if (err) return next(err);
 							next(null, station.queue[0], -12, station);
 							next(null, station.queue[0], -12, station);
 						});
 						});
 					}
 					}
 					if (station.type === 'community' && !station.partyMode) {
 					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 (err) return next(err);
 							if (!playlist) return next(null, null, -13, station);
 							if (!playlist) return next(null, null, -13, station);
 							playlist = playlist.songs;
 							playlist = playlist.songs;
@@ -341,45 +373,46 @@ module.exports = {
 										return next(null, currentSong, currentSongIndex, station);
 										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);
 							} else return next(null, null, -14, station);
 						});
 						});
 					}
 					}
 					if (station.type === 'official' && station.playlist.length === 0) {
 					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 (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 {
 							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);
 									return next(null, song, 0, station);
 								});
 								});
 							}
 							}
-						});
+						}, true);
 					}
 					}
 					if (station.type === 'official' && station.playlist.length > 0) {
 					if (station.type === 'official' && station.playlist.length > 0) {
 						async.doUntil((next) => {
 						async.doUntil((next) => {
 							if (station.currentSongIndex < station.playlist.length - 1) {
 							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);
 									if (!err) return next(null, song, station.currentSongIndex + 1);
 									else {
 									else {
 										station.currentSongIndex++;
 										station.currentSongIndex++;
-										next(null, null);
+										next(null, null, null);
 									}
 									}
 								});
 								});
 							} else {
 							} 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;
 										station.playlist = newPlaylist;
 										next(null, song, 0);
 										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) => {
 						}, (err, song, currentSongIndex) => {
 							return next(err, song, currentSongIndex, station);
 							return next(err, song, currentSongIndex, station);
 						});
 						});
@@ -417,37 +450,37 @@ module.exports = {
 				},
 				},
 
 
 				($set, station, next) => {
 				($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)
 							if (station.type === 'community' && station.partyMode === true)
-								cache.pub('station.queueUpdate', stationId);
+								this.cache.pub('station.queueUpdate', stationId);
 							next(null, station);
 							next(null, station);
-						});
+						}, true);
 					});
 					});
 				},
 				},
-			], (err, station) => {
+			], async (err, station) => {
 				if (!err) {
 				if (!err) {
 					if (station.currentSong !== null && station.currentSong.songId !== undefined) {
 					if (station.currentSong !== null && station.currentSong.songId !== undefined) {
 						station.currentSong.skipVotes = 0;
 						station.currentSong.skipVotes = 0;
 					}
 					}
 					//TODO Pub/Sub this
 					//TODO Pub/Sub this
-					utils.emitToRoom(`station.${station._id}`, "event:songs.next", {
+					this.utils.emitToRoom(`station.${station._id}`, "event:songs.next", {
 						currentSong: station.currentSong,
 						currentSong: station.currentSong,
 						startedAt: station.startedAt,
 						startedAt: station.startedAt,
 						paused: station.paused,
 						paused: station.paused,
 						timePaused: 0
 						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 {
 					else {
-						let sockets = utils.getRoomSockets('home');
+						let sockets = await this.utils.getRoomSockets('home');
 						for (let socketId in sockets) {
 						for (let socketId in sockets) {
 							let socket = sockets[socketId];
 							let socket = sockets[socketId];
 							let session = sockets[socketId].session;
 							let session = sockets[socketId].session;
 							if (session.sessionId) {
 							if (session.sessionId) {
-								cache.hget('sessions', session.sessionId, (err, session) => {
+								this.cache.hget('sessions', session.sessionId, (err, session) => {
 									if (!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 (!err && user) {
 												if (user.role === 'admin') socket.emit("event:station.nextSong", station._id, station.currentSong);
 												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);
 												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) {
 					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) {
 						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 {
 					} else {
-						utils.socketsLeaveSongRooms(utils.getRoomSockets(`station.${station._id}`));
+						this.utils.socketsLeaveSongRooms(await this.utils.getRoomSockets(`station.${station._id}`));
 					}
 					}
 					cb(null, station);
 					cb(null, station);
 				} else {
 				} 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);
 					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';
 '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");
 const async = require("async");
-let utils;
+const fs = require("fs");
+
 let tasks = {};
 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] = {
 		tasks[name] = {
 			name,
 			name,
 			fn,
 			fn,
@@ -116,29 +37,137 @@ module.exports = {
 			timer: null
 			timer: null
 		};
 		};
 		if (!paused) this.handleTask(tasks[name]);
 		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();
 		if (tasks[name].timer) tasks[name].timer.pause();
-	},
-	resumeTask: (name) => {
+	}
+
+	async resumeTask(name) {
+		try { await this._validateHook(); } catch { return; }
+
 		tasks[name].timer.resume();
 		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();
 		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';
 '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'),
 	  async	  = require('async'),
-	  request = require('request'),
-	  cache   = require('./cache');
+	  request = require('request');
 
 
 class Timer {
 class Timer {
 	constructor(callback, delay, paused) {
 	constructor(callback, delay, paused) {
@@ -55,96 +53,137 @@ class Timer {
 			return Date.now() - this.timePaused;
 			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 chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".split("");
 		let result = [];
 		let result = [];
 		for (let i = 0; i < len; i++) {
 		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("");
 		return result.join("");
-	},
-	getSocketFromId: function(socketId) {
+	}
+
+	async getSocketFromId(socketId) {
+		try { await this._validateHook(); } catch { return; }
 		return globals.io.sockets.sockets[socketId];
 		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) {
 		if (ns) {
 			return ns.connected[socketId];
 			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 = [];
 		let sockets = [];
 		if (ns) {
 		if (ns) {
 			async.each(Object.keys(ns.connected), (id, next) => {
 			async.each(Object.keys(ns.connected), (id, next) => {
@@ -155,14 +194,18 @@ module.exports = {
 				cb(sockets);
 				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 = [];
 		let sockets = [];
 		if (ns) {
 		if (ns) {
 			async.each(Object.keys(ns.connected), (id, next) => {
 			async.each(Object.keys(ns.connected), (id, next) => {
 				let session = ns.connected[id].session;
 				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]);
 					if (!err && session && session.userId === userId) sockets.push(ns.connected[id]);
 					next();
 					next();
 				});
 				});
@@ -170,14 +213,18 @@ module.exports = {
 				cb(sockets);
 				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 = [];
 		let sockets = [];
 		if (ns) {
 		if (ns) {
 			async.each(Object.keys(ns.connected), (id, next) => {
 			async.each(Object.keys(ns.connected), (id, next) => {
 				let session = ns.connected[id].session;
 				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]);
 					if (!err && session && ns.connected[id].ip === ip) sockets.push(ns.connected[id]);
 					next();
 					next();
 				});
 				});
@@ -185,9 +232,13 @@ module.exports = {
 				cb(sockets);
 				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 = [];
 		let sockets = [];
 		if (ns) {
 		if (ns) {
 			async.each(Object.keys(ns.connected), (id, next) => {
 			async.each(Object.keys(ns.connected), (id, next) => {
@@ -198,31 +249,43 @@ module.exports = {
 				cb(sockets);
 				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;
 		let rooms = socket.rooms;
 		for (let room in rooms) {
 		for (let room in rooms) {
 			socket.leave(room);
 			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;
 		let rooms = socket.rooms;
 		for (let room in rooms) {
 		for (let room in rooms) {
 			socket.leave(room);
 			socket.leave(room);
 		}
 		}
 		socket.join(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;
 		let rooms = socket.rooms;
 		for (let room in rooms) {
 		for (let room in rooms) {
 			if (room.indexOf('song.') !== -1) socket.leave(rooms);
 			if (room.indexOf('song.') !== -1) socket.leave(rooms);
 		}
 		}
 		socket.join(room);
 		socket.join(room);
-	},
-	socketsJoinSongRoom: function(sockets, room) {
+	}
+
+	async socketsJoinSongRoom(sockets, room) {
+		try { await this._validateHook(); } catch { return; }
+
 		for (let id in sockets) {
 		for (let id in sockets) {
 			let socket = sockets[id];
 			let socket = sockets[id];
 			let rooms = socket.rooms;
 			let rooms = socket.rooms;
@@ -231,8 +294,11 @@ module.exports = {
 			}
 			}
 			socket.join(room);
 			socket.join(room);
 		}
 		}
-	},
-	socketsLeaveSongRooms: function(sockets) {
+	}
+
+	async socketsLeaveSongRooms(sockets) {
+		try { await this._validateHook(); } catch { return; }
+
 		for (let id in sockets) {
 		for (let id in sockets) {
 			let socket = sockets[id];
 			let socket = sockets[id];
 			let rooms = socket.rooms;
 			let rooms = socket.rooms;
@@ -240,30 +306,36 @@ module.exports = {
 				if (room.indexOf('song.') !== -1) socket.leave(room);
 				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) {
 		for (let id in sockets) {
 			let socket = sockets[id];
 			let socket = sockets[id];
 			if (socket.rooms[room]) {
 			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);
 				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 = [];
 		let roomSockets = [];
 		for (let id in sockets) {
 		for (let id in sockets) {
 			let socket = sockets[id];
 			let socket = sockets[id];
 			if (socket.rooms[room]) roomSockets.push(socket);
 			if (socket.rooms[room]) roomSockets.push(socket);
 		}
 		}
 		return roomSockets;
 		return roomSockets;
-	},
-	getSongFromYouTube: (songId, cb) => {
+	}
+
+	async getSongFromYouTube(songId, cb) {
+		try { await this._validateHook(); } catch { return; }
 
 
 		youtubeRequestCallbacks.push({cb: (test) => {
 		youtubeRequestCallbacks.push({cb: (test) => {
 			youtubeRequestsActive = true;
 			youtubeRequestsActive = true;
@@ -319,8 +391,10 @@ module.exports = {
 		if (!youtubeRequestsActive) {
 		if (!youtubeRequestsActive) {
 			youtubeRequestCallbacks[0].cb(youtubeRequestCallbacks[0].songId);
 			youtubeRequestCallbacks[0].cb(youtubeRequestCallbacks[0].songId);
 		}
 		}
-	},
-	getPlaylistFromYouTube: (url, cb) => {
+	}
+
+	async getPlaylistFromYouTube(url, cb) {
+		try { await this._validateHook(); } catch { return; }
 
 
 		let name = 'list'.replace(/[\[]/,"\\\[").replace(/[\]]/,"\\\]");
 		let name = 'list'.replace(/[\[]/,"\\\[").replace(/[\]]/,"\\\]");
 		var regex = new RegExp('[\\?&]' + name + '=([^&#]*)');
 		var regex = new RegExp('[\\?&]' + name + '=([^&#]*)');
@@ -352,18 +426,30 @@ module.exports = {
 			});
 			});
 		}
 		}
 		getPage(null, []);
 		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 = [
 		const spotifyParams = [
 			`q=${encodeURIComponent(song.title)}`,
 			`q=${encodeURIComponent(song.title)}`,
 			`type=track`
 			`type=track`
 		].join('&');
 		].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);
 			if (err) console.error(err);
-
 			body = JSON.parse(body);
 			body = JSON.parse(body);
+			if (body.error) console.error(body.error);
 
 
 			durationArtistLoop:
 			durationArtistLoop:
 			for (let i in body) {
 			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 = [
 		const spotifyParams = [
 			`q=${encodeURIComponent(title)}`,
 			`q=${encodeURIComponent(title)}`,
 			`type=track`
 			`type=track`
 		].join('&');
 		].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);
 			if (err) return console.error(err);
 			body = JSON.parse(body);
 			body = JSON.parse(body);
+			if (body.error) return console.error(body.error);
+
 			let songs = [];
 			let songs = [];
 
 
 			for (let i in body) {
 			for (let i in body) {
@@ -427,8 +528,11 @@ module.exports = {
 
 
 			cb(songs);
 			cb(songs);
 		});
 		});
-	},
-	shuffle: (array) => {
+	}
+
+	async shuffle(array) {
+		try { await this._validateHook(); } catch { return; }
+
 		let currentIndex = array.length, temporaryValue, randomIndex;
 		let currentIndex = array.length, temporaryValue, randomIndex;
 
 
 		// While there remain elements to shuffle...
 		// While there remain elements to shuffle...
@@ -445,8 +549,11 @@ module.exports = {
 		}
 		}
 
 
 		return array;
 		return array;
-	},
-	getError: (err) => {
+	}
+
+	async getError(err) {
+		try { await this._validateHook(); } catch { return; }
+
 		let error = 'An error occurred.';
 		let error = 'An error occurred.';
 		if (typeof err === "string") error = err;
 		if (typeof err === "string") error = err;
 		else if (err.message) {
 		else if (err.message) {
@@ -454,29 +561,5 @@ module.exports = {
 			else error = err.errors[Object.keys(err.errors)].message;
 			else error = err.errors[Object.keys(err.errors)].message;
 		}
 		}
 		return error;
 		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",
   "name": "musare-backend",
-  "version": "0.0.1",
+  "private": true,
+  "version": "2.1.0",
   "description": "A modern, open-source, collaborative music app https://musare.com",
   "description": "A modern, open-source, collaborative music app https://musare.com",
-  "main": "app.js",
+  "main": "index.js",
   "author": "Musare Team",
   "author": "Musare Team",
+  "license": "GPL-3.0",
   "repository": "https://github.com/Musare/MusareNode",
   "repository": "https://github.com/Musare/MusareNode",
   "scripts": {
   "scripts": {
-    "development": "nodemon -L /opt/app",
-    "production": "node /opt/app"
+    "dev": "nodemon",
+    "docker:dev": "nodemon -L /opt/app",
+    "docker:prod": "node /opt/app"
   },
   },
   "dependencies": {
   "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",
     "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:
     links:
     - mongo
     - mongo
     - redis
     - redis
+    environment:
+    - SNYK_TOKEN=${SNYK_TOKEN}
   frontend:
   frontend:
     build: ./frontend
     build: ./frontend
+    environment:
     ports:
     ports:
     - "${FRONTEND_PORT}:80"
     - "${FRONTEND_PORT}:80"
     volumes:
     volumes:
     - ./frontend:/opt/app
     - ./frontend:/opt/app
+    environment:
+    - FRONTEND_MODE=${FRONTEND_MODE}
+    - SNYK_TOKEN=${SNYK_TOKEN}
   mongo:
   mongo:
-    image: mongo
+    image: mongo:4.0
     ports:
     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:
   mongoclient:
     image: mongoclient/mongoclient
     image: mongoclient/mongoclient
     ports:
     ports:
-    - "3000:3000"
+    - "${MONGOCLIENT_PORT}:3000"
   redis:
   redis:
     image: redis
     image: redis
     command: "--notify-keyspace-events Ex --requirepass ${REDIS_PASSWORD}"
     command: "--notify-keyspace-events Ex --requirepass ${REDIS_PASSWORD}"
     volumes:
     volumes:
     - .redis:/data
     - .redis:/data
     ports:
     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
 	"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": {
 	"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>
 <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>
 	</div>
 </template>
 </template>
 
 
 <script>
 <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>
 </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;
 		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 {
 		&: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 {
 		&:after {
-			 bottom: 150%;
-		}
-
-		&:hover {
-			&:after { bottom: 120%; }
+			top: 125%;
 		}
 		}
 	}
 	}
+}
 
 
+.tooltip-left {
+	&:after {
+		bottom: -10px;
+		right: 130%;
+		min-width: 100px;
+	}
 
 
-	.tooltip-bottom {
+	&:hover {
 		&:after {
 		&:after {
-			 top: 155%;
+			right: 110%;
 		}
 		}
+	}
+}
 
 
-		&:hover {
-			&:after { top: 125%; }
-		}
+.tooltip-right {
+	&:after {
+		bottom: -10px;
+		left: 190%;
+		min-width: 100px;
 	}
 	}
 
 
-	.tooltip-left {
+	&:hover {
 		&:after {
 		&: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>
 </style>

+ 11 - 5
frontend/Dockerfile

@@ -1,18 +1,24 @@
-FROM node
+FROM node:12
 
 
 RUN apt-get update
 RUN apt-get update
 RUN apt-get install nginx -y
 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
 RUN mkdir -p /opt
 WORKDIR /opt
 WORKDIR /opt
 ADD package.json /opt/package.json
 ADD package.json /opt/package.json
 
 
-RUN npm install
+RUN yarn install
 
 
 RUN mkdir -p /run/nginx
 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 callbacks = [];
-let bannedCallbacks = [];
+const bannedCallbacks = [];
 
 
 export default {
 export default {
-
 	ready: false,
 	ready: false,
 	authenticated: false,
 	authenticated: false,
-	username: '',
-	userId: '',
-	role: 'default',
+	username: "",
+	userId: "",
+	role: "default",
 	banned: null,
 	banned: null,
 	ban: {},
 	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);
 		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 => {
 		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) return cb(false);
 		if (!this.ready && this.banned === true) return cb(true, this.ban);
 		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.authenticated = authenticated;
 		this.role = role;
 		this.role = role;
 		this.username = username;
 		this.username = username;
@@ -45,4 +44,4 @@ export default {
 		});
 		});
 		callbacks = [];
 		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>

文件差異過大導致無法顯示
+ 0 - 1
frontend/build/vendor/jquery.min.js


文件差異過大導致無法顯示
+ 0 - 0
frontend/build/vendor/moment.min.js


+ 18 - 17
frontend/components/404.vue

@@ -1,25 +1,26 @@
 <template>
 <template>
 	<div class="wrapper">
 	<div class="wrapper">
+		<metadata title="404" />
+
 		<h3><strong>404</strong>&nbsp;Not Found</h3>
 		<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>
 	</div>
 </template>
 </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>
 <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>
 
 
-					<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>
-						<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>
 					</div>
-
 				</div>
 				</div>
+				<footer class="card-footer">
+					<a class="card-footer-item" @click="createNews()" href="#"
+						>Create</a
+					>
+				</footer>
 			</div>
 			</div>
-			<footer class='card-footer'>
-				<a class='card-footer-item' @click='createNews()' href='#'>Create</a>
-			</footer>
 		</div>
 		</div>
-	</div>
 
 
-	<edit-news v-if='modals.editNews'></edit-news>
+		<edit-news v-if="modals.editNews" />
+	</div>
 </template>
 </template>
 
 
 <script>
 <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: [],
 						bugs: [],
 						features: [],
 						features: [],
 						improvements: [],
 						improvements: [],
 						upcoming: []
 						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>
 </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>
 </style>

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

@@ -1,114 +1,191 @@
 <template>
 <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>
 					</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>
 				</div>
+				<footer class="card-footer">
+					<a class="card-footer-item" v-on:click="banIP()" href="#"
+						>Ban IP</a
+					>
+				</footer>
 			</div>
 			</div>
-			<footer class='card-footer'>
-				<a class='card-footer-item' @click='banIP()' href='#'>Ban IP</a>
-			</footer>
 		</div>
 		</div>
+		<view-punishment v-if="modals.viewPunishment" />
 	</div>
 	</div>
-	<view-punishment v-show='modals.viewPunishment'></view-punishment>
 </template>
 </template>
 
 
 <script>
 <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>
 </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>
 </style>

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

@@ -1,149 +1,258 @@
 <template>
 <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>
 	</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>
 </template>
 
 
 <script>
 <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>
 </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>
 </style>

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

@@ -1,109 +1,159 @@
 <template>
 <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>
 </template>
 
 
 <script>
 <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>
 </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>
 </style>

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

@@ -1,152 +1,284 @@
 <template>
 <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>
 	</div>
-	<edit-song v-show='modals.editSong'></edit-song>
 </template>
 </template>
 
 
 <script>
 <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>
 </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>
 </style>

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

@@ -1,226 +1,387 @@
 <template>
 <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>
 							</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>
-						<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>
 					</div>
 				</div>
 				</div>
+				<footer class="card-footer">
+					<a
+						class="card-footer-item"
+						href="#"
+						@click="createStation()"
+						>Create</a
+					>
+				</footer>
 			</div>
 			</div>
-			<footer class='card-footer'>
-				<a class='card-footer-item' @click='createStation()' href='#'>Create</a>
-			</footer>
 		</div>
 		</div>
-	</div>
 
 
-	<edit-station v-show='modals.editStation'></edit-station>
+		<edit-station v-if="modals.editStation" store="admin/stations" />
+	</div>
 </template>
 </template>
 
 
 <script>
 <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,
 					name,
-					type: 'official',
 					displayName,
 					displayName,
 					description,
 					description,
 					genres,
 					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>
 </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>
 </style>

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

@@ -1,18 +1,21 @@
 <template>
 <template>
-	<div class='container'>
+	<div class="container">
+		<metadata title="Admin | Statistics" />
 		<div class="columns">
 		<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
 						Average Logs
 					</p>
 					</p>
 				</header>
 				</header>
-				<div class='card-content'>
-					<div class='content'>
+				<div class="card-content">
+					<div class="content">
 						<table class="table">
 						<table class="table">
 							<thead>
 							<thead>
 								<tr>
 								<tr>
-									<th> </th>
+									<th />
 									<th>Success</th>
 									<th>Success</th>
 									<th>Error</th>
 									<th>Error</th>
 									<th>Info</th>
 									<th>Info</th>
@@ -21,27 +24,51 @@
 							<tbody>
 							<tbody>
 								<tr>
 								<tr>
 									<th><strong>Average per second</strong></th>
 									<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>
 								<tr>
 								<tr>
 									<th><strong>Average per minute</strong></th>
 									<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>
 								<tr>
 								<tr>
 									<th><strong>Average per hour</strong></th>
 									<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>
 								<tr>
 								<tr>
 									<th><strong>Average per day</strong></th>
 									<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>
 								</tr>
 							</tbody>
 							</tbody>
 						</table>
 						</table>
@@ -49,22 +76,26 @@
 				</div>
 				</div>
 			</div>
 			</div>
 		</div>
 		</div>
-		<br>
+		<br />
 		<div class="columns">
 		<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>
 			</div>
 			</div>
 		</div>
 		</div>
-		<br>
+		<br />
 		<div class="columns">
 		<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>
 				</div>
 			</div>
 			</div>
@@ -73,228 +104,247 @@
 </template>
 </template>
 
 
 <script>
 <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.successUnitsPerMinute = units;
 					this.minuteChart.data.datasets[0].data = units;
 					this.minuteChart.data.datasets[0].data = units;
 					this.minuteChart.update();
 					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.errorUnitsPerMinute = units;
 					this.minuteChart.data.datasets[1].data = units;
 					this.minuteChart.data.datasets[1].data = units;
 					this.minuteChart.update();
 					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.infoUnitsPerMinute = units;
 					this.minuteChart.data.datasets[2].data = units;
 					this.minuteChart.data.datasets[2].data = units;
 					this.minuteChart.update();
 					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.successUnitsPerHour = units;
 					this.hourChart.data.datasets[0].data = units;
 					this.hourChart.data.datasets[0].data = units;
 					this.hourChart.update();
 					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>
 </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>
 </style>

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

@@ -1,100 +1,128 @@
 <template>
 <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>
 	</div>
-	<edit-user v-show='modals.editUser'></edit-user>
 </template>
 </template>
 
 
 <script>
 <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>
 </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>
 </style>

+ 138 - 28
frontend/components/MainFooter.vue

@@ -1,47 +1,157 @@
 <template>
 <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>
-					<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>
-					<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>
-					<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>
 					</a>
 				</p>
 				</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>
 		</div>
 		</div>
 	</footer>
 	</footer>
 </template>
 </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>
 </style>

+ 131 - 118
frontend/components/MainHeader.vue

@@ -1,157 +1,170 @@
 <template>
 <template>
 	<nav class="nav is-info">
 	<nav class="nav is-info">
 		<div class="nav-left">
 		<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>
 		</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>
 		</span>
 
 
 		<div class="nav-right nav-menu" :class="{ 'is-active': isMobile }">
 		<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>
 				<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
 					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>
-			<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>
 			</span>
 		</div>
 		</div>
 	</nav>
 	</nav>
 </template>
 </template>
 
 
 <script>
 <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>
 </script>
 
 
 <style lang="scss" scoped>
 <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 {
 		.nav-item {
-			font-size: 15px;
-			color: hsl(0, 0%, 100%);
+			color: $dark-grey-2;
 
 
 			&:hover {
 			&: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>
 </style>

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

@@ -1,124 +1,150 @@
 <template>
 <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">
 			<aside class="menu">
 				<p class="menu-label">
 				<p class="menu-label">
 					Playlists
 					Playlists
 				</p>
 				</p>
 				<ul class="menu-list">
 				<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>
 								<i class="material-icons">playlist_add_check</i>
 							</span>
 							</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>
 								<i class="material-icons">playlist_add</i>
 							</span>
 							</span>
 							{{ playlist.displayName }}
 							{{ playlist.displayName }}
 						</div>
 						</div>
 					</li>
 					</li>
 				</ul>
 				</ul>
-				</aside>
-		</div>
+			</aside>
+		</template>
 	</modal>
 	</modal>
 </template>
 </template>
 
 
 <script>
 <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>
 </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>
 </style>

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

@@ -1,13 +1,30 @@
 <template>
 <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>
 						</div>
 					</li>
 					</li>
 				</ul>
 				</ul>
@@ -15,27 +32,59 @@
 			</aside>
 			</aside>
 			<div class="control is-grouped">
 			<div class="control is-grouped">
 				<p class="control is-expanded">
 				<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>
 				<p class="control">
 				<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>
 				</p>
 			</div>
 			</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>
 				<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>
 				</tbody>
 			</table>
 			</table>
 		</div>
 		</div>
@@ -43,103 +92,145 @@
 </template>
 </template>
 
 
 <script>
 <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>
 </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>
 </style>

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

@@ -1,91 +1,145 @@
 <template>
 <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/ -->
 			<!-- 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>
 			</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>
 			</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>
 			</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>
 	</modal>
 </template>
 </template>
 
 
 <script>
 <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>
 <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>
 			</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>
 			</p>
 			<div class="columns">
 			<div class="columns">
 				<div class="column">
 				<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>
 					</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 }}
 						{{ bug }}
-						<button class='delete is-info' @click='removeChange("bugs", index)'></button>
+						<button
+							class="delete is-info"
+							@click="removeChangeClick('bugs', index)"
+						/>
 					</span>
 					</span>
 				</div>
 				</div>
 				<div class="column">
 				<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>
 					</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 }}
 						{{ feature }}
-						<button class='delete is-info' @click='removeChange("features", index)'></button>
+						<button
+							class="delete is-info"
+							@click="removeChangeClick('features', index)"
+						/>
 					</span>
 					</span>
 				</div>
 				</div>
 			</div>
 			</div>
 
 
 			<div class="columns">
 			<div class="columns">
 				<div class="column">
 				<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>
 					</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 }}
 						{{ improvement }}
-						<button class='delete is-info' @click='removeChange("improvements", index)'></button>
+						<button
+							class="delete is-info"
+							@click="removeChangeClick('improvements', index)"
+						/>
 					</span>
 					</span>
 				</div>
 				</div>
 				<div class="column">
 				<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>
 					</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 }}
 						{{ upcoming }}
-						<button class='delete is-info' @click='removeChange("upcoming", index)'></button>
+						<button
+							class="delete is-info"
+							@click="removeChangeClick('upcoming', index)"
+						/>
 					</span>
 					</span>
 				</div>
 				</div>
 			</div>
 			</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>
 				<span>&nbsp;Save</span>
 			</button>
 			</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>
 				<span>&nbsp;Save and close</span>
 			</button>
 			</button>
-			<button class='button is-danger' @click='$parent.toggleModal()'>
+			<button
+				class="button is-danger"
+				@click="
+					closeModal({
+						sector: 'admin',
+						modal: 'editNews'
+					})
+				"
+			>
 				<span>&nbsp;Close</span>
 				<span>&nbsp;Close</span>
 			</button>
 			</button>
 		</div>
 		</div>
@@ -76,161 +167,216 @@
 </template>
 </template>
 
 
 <script>
 <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>
 </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>
 </style>

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

@@ -1,531 +1,1841 @@
 <template>
 <template>
 	<div>
 	<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>
-				</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>
-						<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>
 					</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>
-					</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>
 						</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>
 						</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>
 					</div>
-				</article>
+				</div>
 			</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>
 					<span>&nbsp;Save</span>
 				</button>
 				</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>
 					<span>&nbsp;Save and close</span>
 				</button>
 				</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>
 					<span>&nbsp;Close</span>
 				</button>
 				</button>
 			</div>
 			</div>
 		</modal>
 		</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>
 	</div>
 </template>
 </template>
 
 
 <script>
 <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.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>
 </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%;
 		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;
 		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>
 </style>

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

@@ -1,217 +1,1040 @@
 <template>
 <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>
 			</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>
 			</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>
 </template>
 
 
 <script>
 <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 {
 						else {
 							this.$parent.stations.forEach((station, index) => {
 							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 {
 						else {
 							this.$parent.stations.forEach((station, index) => {
 							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 {
 						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 {
 						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>
 </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;
 			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;
 		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;
 		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>
 </style>

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

@@ -1,14 +1,30 @@
 <template>
 <template>
 	<div>
 	<div>
-		<modal title='Edit User'>
-			<div slot='body'>
+		<modal title="Edit User">
+			<div slot="body">
 				<p class="control has-addons">
 				<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>
 				<p class="control has-addons">
 				<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>
 				<p class="control has-addons">
 				<p class="control has-addons">
 					<span class="select">
 					<span class="select">
@@ -17,37 +33,55 @@
 							<option>admin</option>
 							<option>admin</option>
 						</select>
 						</select>
 					</span>
 					</span>
-					<a class="button is-info" @click='updateRole()'>Update Role</a>
+					<a class="button is-info" v-on:click="updateRole()"
+						>Update Role</a
+					>
 				</p>
 				</p>
-				<hr>
+				<hr />
 				<p class="control has-addons">
 				<p class="control has-addons">
 					<span class="select">
 					<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>
 						</select>
 					</span>
 					</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>
 				</p>
 			</div>
 			</div>
-			<div slot='footer'>
+			<div slot="footer">
 				<!--button class='button is-warning'>
 				<!--button class='button is-warning'>
 					<span>&nbsp;Send Verification Email</span>
 					<span>&nbsp;Send Verification Email</span>
 				</button>
 				</button>
 				<button class='button is-warning'>
 				<button class='button is-warning'>
 					<span>&nbsp;Send Password Reset Email</span>
 					<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>
 					<span>&nbsp;Remove all sessions</span>
 				</button>
 				</button>
-				<button class='button is-danger' @click='$parent.toggleModal()'>
+				<button
+					class="button is-danger"
+					@click="
+						closeModal({
+							sector: 'admin',
+							modal: 'editUser'
+						})
+					"
+				>
 					<span>&nbsp;Close</span>
 					<span>&nbsp;Close</span>
 				</button>
 				</button>
 			</div>
 			</div>
@@ -56,92 +90,148 @@
 </template>
 </template>
 
 
 <script>
 <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>
 </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>
 </style>

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

@@ -1,15 +1,48 @@
 <template>
 <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">
 			<article class="message">
 				<div class="message-body">
 				<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>
 				</div>
 			</article>
 			</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>
 				<thead>
 					<tr>
 					<tr>
 						<td>Issue</td>
 						<td>Issue</td>
@@ -17,7 +50,7 @@
 					</tr>
 					</tr>
 				</thead>
 				</thead>
 				<tbody>
 				<tbody>
-					<tr v-for='(index, issue) in $parent.editing.issues' track-by='$index'>
+					<tr v-for="(issue, index) in report.issues" :key="index">
 						<td>
 						<td>
 							<span>{{ issue.name }}</span>
 							<span>{{ issue.name }}</span>
 						</td>
 						</td>
@@ -28,11 +61,31 @@
 				</tbody>
 				</tbody>
 			</table>
 			</table>
 		</div>
 		</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>
 				<span>Resolve</span>
 			</a>
 			</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>
 				<span>Cancel</span>
 			</a>
 			</a>
 		</div>
 		</div>
@@ -40,14 +93,36 @@
 </template>
 </template>
 
 
 <script>
 <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>
 </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>
 <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>
 			</header>
-			<section class='modal-card-body'>
+			<section class="modal-card-body">
 				<!-- validation to check if exists http://bulma.io/documentation/elements/form/ -->
 				<!-- 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>
 			</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>
 					</div>
 					&nbsp;&nbsp;Login with GitHub
 					&nbsp;&nbsp;Login with GitHub
 				</a>
 				</a>
-				<a href='/reset_password' @click='resetPassword()'>Forgot password?</a>
+				<a href="/reset_password" v-on:click="resetPassword()"
+					>Forgot password?</a
+				>
 			</footer>
 			</footer>
 		</div>
 		</div>
 	</div>
 	</div>
 </template>
 </template>
 
 
 <script>
 <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>
 </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>
 </style>

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

@@ -1,75 +1,81 @@
 <template>
 <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>
 			</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>
 			</section>
 		</div>
 		</div>
 	</div>
 	</div>
 </template>
 </template>
 
 
 <script>
 <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>
 </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;
 		background: transparent;
-		right: 0;
-		position: absolute;
-		&:hover { background: transparent; }
+	}
 
 
-		&:before, &:after { background-color: #bbb; }
+	&:before,
+	&:after {
+		background-color: #bbb;
 	}
 	}
+}
 </style>
 </style>

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

@@ -1,37 +1,43 @@
 <template>
 <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>
 			</header>
-			<section class='modal-card-body'>
-				<slot name='body'></slot>
+			<section class="modal-card-body">
+				<slot name="body" />
 			</section>
 			</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>
 			</footer>
 		</div>
 		</div>
 	</div>
 	</div>
 </template>
 </template>
 
 
 <script>
 <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>
 </script>

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

@@ -1,88 +1,121 @@
 <template>
 <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>
 			</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>
 	</modal>
 </template>
 </template>
 
 
 <script>
 <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>
 </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>
 <template>
-	<modal title='Edit Playlist'>
-		<div slot='body'>
+	<modal title="Edit Playlist">
+		<div slot="body">
 			<nav class="level">
 			<nav class="level">
 				<div class="level-item has-text-centered">
 				<div class="level-item has-text-centered">
 					<div>
 					<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>
 				</div>
 				</div>
 			</nav>
 			</nav>
 			<hr />
 			<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>
-							<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>
-							<a href='#' @click='removeSongFromPlaylist(song.songId)'><i class='material-icons'>delete</i></a>
 						</div>
 						</div>
 					</li>
 					</li>
 				</ul>
 				</ul>
 				<br />
 				<br />
 			</aside>
 			</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>
-				<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>
 				</p>
 			</div>
 			</div>
-			<table class='table' v-if='songQueryResults.length > 0'>
+			<table v-if="songQueryResults.length > 0" class="table">
 				<tbody>
 				<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>
 				</tbody>
 			</table>
 			</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>
-				<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>
 				</p>
 			</div>
 			</div>
 			<h5>Edit playlist details:</h5>
 			<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>
-				<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>
 				</p>
 			</div>
 			</div>
 		</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>
 		</div>
 	</modal>
 	</modal>
 </template>
 </template>
 
 
 <script>
 <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>
 </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>
 </style>

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

@@ -1,33 +1,78 @@
 <template>
 <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>
 			</header>
-			<section class='modal-card-body'>
+			<section class="modal-card-body">
 				<!-- validation to check if exists http://bulma.io/documentation/elements/form/ -->
 				<!-- 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>
 				</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>
 				</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>
 				</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>
 			</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>
 					</div>
 					&nbsp;&nbsp;Register with GitHub
 					&nbsp;&nbsp;Register with GitHub
 				</a>
 				</a>
@@ -37,56 +82,105 @@
 </template>
 </template>
 
 
 <script>
 <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>
 </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>
 </style>

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

@@ -1,89 +1,147 @@
 <template>
 <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
 								Previous Song
 							</p>
 							</p>
 						</header>
 						</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>
 									</p>
 								</figure>
 								</figure>
-								<div class='media-content'>
-									<div class='content'>
+								<div class="media-content">
+									<div class="content">
 										<p>
 										<p>
-											<strong>{{ $parent.previousSong.title }}</strong>
-											<br>
-											<small>{{ $parent.previousSong.artists.split(' ,') }}</small>
+											<strong>{{
+												previousSong.title
+											}}</strong>
+											<br />
+											<small>{{
+												previousSong.artists.split(" ,")
+											}}</small>
 										</p>
 										</p>
 									</div>
 									</div>
 								</div>
 								</div>
 							</article>
 							</article>
 						</div>
 						</div>
-						<a @click=highlight('previousSong') href='#' class='absolute-a'></a>
+						<a
+							href="#"
+							class="absolute-a"
+							@click="highlight('previousSong')"
+						/>
 					</div>
 					</div>
 				</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
 								Current Song
 							</p>
 							</p>
 						</header>
 						</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>
 									</p>
 								</figure>
 								</figure>
-								<div class='media-content'>
-									<div class='content'>
+								<div class="media-content">
+									<div class="content">
 										<p>
 										<p>
-											<strong>{{ $parent.currentSong.title }}</strong>
-											<br>
-											<small>{{ $parent.currentSong.artists.split(' ,') }}</small>
+											<strong>{{
+												currentSong.title
+											}}</strong>
+											<br />
+											<small>{{
+												currentSong.artists.split(" ,")
+											}}</small>
 										</p>
 										</p>
 									</div>
 									</div>
 								</div>
 								</div>
 							</article>
 							</article>
 						</div>
 						</div>
-						<a @click=highlight('currentSong') href='#' class='absolute-a'></a>
+						<a
+							href="#"
+							class="absolute-a"
+							@click="highlight('currentSong')"
+						/>
 					</div>
 					</div>
 				</div>
 				</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 }}
 								{{ reason }}
 							</label>
 							</label>
 						</p>
 						</p>
 					</div>
 					</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>
 			</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>
 				<span>&nbsp;Create</span>
 			</a>
 			</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>
 				<span>&nbsp;Cancel</span>
 			</a>
 			</a>
 		</div>
 		</div>
@@ -91,151 +149,159 @@
 </template>
 </template>
 
 
 <script>
 <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: [
 				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>
 </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>
 </style>

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

@@ -1,21 +1,70 @@
 <template>
 <template>
 	<div>
 	<div>
-		<modal title='View Punishment'>
-			<div slot='body'>
+		<modal title="View Punishment">
+			<div slot="body">
 				<article class="message">
 				<article class="message">
 					<div class="message-body">
 					<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>
 					</div>
 				</article>
 				</article>
 			</div>
 			</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>
 					<span>&nbsp;Close</span>
 				</button>
 				</button>
 			</div>
 			</div>
@@ -24,41 +73,36 @@
 </template>
 </template>
 
 
 <script>
 <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>
 <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>
 			</header>
-			<section class='modal-card-body'>
-				<div class='content'>
+			<section class="modal-card-body">
+				<div class="content">
 					<p>{{ news.description }}</p>
 					<p>{{ news.description }}</p>
 				</div>
 				</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>
 					</ul>
 				</div>
 				</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>
 					</ul>
 				</div>
 				</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>
 					</ul>
 				</div>
 				</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>
 					</ul>
 				</div>
 				</div>
 			</section>
 			</section>
@@ -40,88 +72,117 @@
 </template>
 </template>
 
 
 <script>
 <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 {
 					} 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>
 </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;
 		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>
 </style>

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

@@ -1,160 +1,213 @@
 <template>
 <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>
 						<span>{{ playlist.displayName }}</span>
 						<!--Will play playlist in community station Kris-->
 						<!--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>
-							<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>
 							</a>
 						</div>
 						</div>
 					</li>
 					</li>
 				</ul>
 				</ul>
 			</aside>
 			</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>
 	</div>
 	</div>
 </template>
 </template>
 
 
 <script>
 <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>
 </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;
 		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>
 </style>

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

@@ -1,171 +1,287 @@
 <template>
 <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">
 					<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>
 					</p>
 				</figure>
 				</figure>
 				<div class="media-content">
 				<div class="media-content">
 					<div class="content">
 					<div class="content">
 						<p>
 						<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>
 						</p>
 					</div>
 					</div>
 				</div>
 				</div>
 				<div class="media-right">
 				<div class="media-right">
-					{{ $parent.formatTime($parent.currentSong.duration) }}
+					{{ $parent.formatTime(currentSong.duration) }}
 				</div>
 				</div>
 			</article>
 			</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="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>
 				</div>
 				<div class="media-right">
 				<div class="media-right">
-					{{ $parent.$parent.formatTime(song.duration) }}
+					{{ $parent.formatTime(song.duration) }}
 				</div>
 				</div>
 			</article>
 			</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>
 		</div>
 	</div>
 	</div>
 </template>
 </template>
 
 
 <script>
 <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) => {
 			io.getSocket((socket) => {
-				_this.socket = socket;
+				this.socket = socket;
 
 
-			});*/
-		}
-	}
+			}); */
+	},
+	components: { UserIdToUsername }
+};
 </script>
 </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>
 </style>

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

@@ -1,12 +1,19 @@
 <template>
 <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">
 			<aside class="menu">
 				<ul class="menu-list">
 				<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>
 					</li>
 				</ul>
 				</ul>
 			</aside>
 			</aside>
@@ -14,41 +21,60 @@
 	</div>
 	</div>
 </template>
 </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>
 </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>
 <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>
-			<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>
 							<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>
 							</p>
 						</div>
 						</div>
 					</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>
 					</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">
 								<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>
-					</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>
-		<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>
-					</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>
 			</div>
-		</div>
-		<div class="mobile-only" v-show="!noSong">
-			<div>
+			<div v-show="!noSong" class="mobile-only">
 				<div>
 				<div>
 					<div>
 					<div>
-						<h3>{{currentSong.title}}</h3>
-						<h4 class="thin">{{currentSong.artists}}</h4>
-						<h5>{{timeElapsed}} / {{formatTime(currentSong.duration)}}</h5>
 						<div>
 						<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>
 							<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>
 				</div>
 				</div>
 			</div>
 			</div>
 		</div>
 		</div>
+
+		<Z404 v-if="!exists"></Z404>
 	</div>
 	</div>
 </template>
 </template>
 
 
 <script>
 <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";
 				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) {
 				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 {
 								} 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 {
 					} 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
 					// 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
 						// 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
 						// 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);
 						console.log(connectionLatency, beforePing - afterPing);
 						// UNIX server time
 						// 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
 						// 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);
 						console.log("Difference: ", difference);
 						if (difference > 3000 || difference < -3000) {
 						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"));
 			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);
 			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>
 </script>
 
 
 <style lang="scss">
 <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;
 		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%;
 		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 {
 		.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%;
 			width: 100%;
-			height: 5.2px;
-			cursor: pointer;
-			box-shadow: 0;
-			background: #c2c0c2;
+			margin-top: 25px;
+			height: 40px;
 			border-radius: 0;
 			border-radius: 0;
+			background: rgb(3, 169, 244);
+			color: $white !important;
 			border: 0;
 			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;
 			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%;
 		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>
 </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>
 <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>
 		</div>
+		<main-footer />
 	</div>
 	</div>
-	<main-footer></main-footer>
 </template>
 </template>
 
 
 <script>
 <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;
 						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;
 						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>
 </script>
 
 
 <style lang="scss" scoped>
 <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>
 </style>

部分文件因文件數量過多而無法顯示