Explorar el Código

Merge pull request #39 from Musare/experimental

Release from experimental to staging
Vos hace 5 años
padre
commit
7e5101a58b
Se han modificado 100 ficheros con 14055 adiciones y 7088 borrados
  1. 0 20
      .editorconfig
  2. 0 3
      .env
  3. 18 0
      .env.example
  4. 10 6
      .gitignore
  5. 42 0
      .travis.yml
  6. 172 158
      README.md
  7. 2 1
      backend/Dockerfile
  8. 12 6
      backend/config/template.json
  9. 46 56
      backend/index.js
  10. 3 1
      backend/logic/actions/apis.js
  11. 2 2
      backend/logic/actions/news.js
  12. 11 9
      backend/logic/actions/playlists.js
  13. 49 10
      backend/logic/actions/queueSongs.js
  14. 46 18
      backend/logic/actions/songs.js
  15. 73 14
      backend/logic/actions/stations.js
  16. 16 20
      backend/logic/actions/users.js
  17. 3 3
      backend/logic/app.js
  18. 164 163
      backend/logic/db/index.js
  19. 0 1
      backend/logic/db/schemas/user.js
  20. 107 0
      backend/logic/discord.js
  21. 1 1
      backend/logic/logger.js
  22. 1 1
      backend/logic/playlists.js
  23. 1 1
      backend/logic/songs.js
  24. 84 0
      backend/logic/spotify.js
  25. 6 5
      backend/logic/stations.js
  26. 29 7
      backend/logic/utils.js
  27. 20 26
      backend/package.json
  28. 2017 0
      backend/yarn.lock
  29. 20 5
      docker-compose.yml
  30. 6 2
      frontend/.babelrc
  31. 2 0
      frontend/.eslintignore
  32. 30 8
      frontend/.eslintrc
  33. 3 0
      frontend/.prettierignore
  34. 5 0
      frontend/.prettierrc
  35. 21 0
      frontend/.snyk
  36. 232 258
      frontend/App.vue
  37. 11 5
      frontend/Dockerfile
  38. 76 0
      frontend/api/auth.js
  39. 13 13
      frontend/auth.js
  40. 9 0
      frontend/bootstrap.sh
  41. 0 10
      frontend/build/config/template.json
  42. 0 1
      frontend/build/vendor/jquery.min.js
  43. 19 17
      frontend/components/404.vue
  44. 498 0
      frontend/components/Admin/EditStation.vue
  45. 338 198
      frontend/components/Admin/News.vue
  46. 168 94
      frontend/components/Admin/Punishments.vue
  47. 210 124
      frontend/components/Admin/QueueSongs.vue
  48. 125 92
      frontend/components/Admin/Reports.vue
  49. 191 126
      frontend/components/Admin/Songs.vue
  50. 322 188
      frontend/components/Admin/Stations.vue
  51. 273 224
      frontend/components/Admin/Statistics.vue
  52. 115 85
      frontend/components/Admin/Users.vue
  53. 136 28
      frontend/components/MainFooter.vue
  54. 131 129
      frontend/components/MainHeader.vue
  55. 112 87
      frontend/components/Modals/AddSongToPlaylist.vue
  56. 189 110
      frontend/components/Modals/AddSongToQueue.vue
  57. 124 71
      frontend/components/Modals/CreateCommunityStation.vue
  58. 279 167
      frontend/components/Modals/EditNews.vue
  59. 767 398
      frontend/components/Modals/EditSong.vue
  60. 310 181
      frontend/components/Modals/EditStation.vue
  61. 174 94
      frontend/components/Modals/EditUser.vue
  62. 69 19
      frontend/components/Modals/IssuesModal.vue
  63. 113 51
      frontend/components/Modals/Login.vue
  64. 59 54
      frontend/components/Modals/MobileAlert.vue
  65. 31 25
      frontend/components/Modals/Modal.vue
  66. 97 65
      frontend/components/Modals/Playlists/Create.vue
  67. 359 208
      frontend/components/Modals/Playlists/Edit.vue
  68. 144 64
      frontend/components/Modals/Register.vue
  69. 244 175
      frontend/components/Modals/Report.vue
  70. 71 47
      frontend/components/Modals/ViewPunishment.vue
  71. 145 87
      frontend/components/Modals/WhatIsNew.vue
  72. 176 131
      frontend/components/Sidebars/Playlist.vue
  73. 230 117
      frontend/components/Sidebars/SongsList.vue
  74. 50 37
      frontend/components/Sidebars/UsersList.vue
  75. 407 296
      frontend/components/Station/CommunityHeader.vue
  76. 431 306
      frontend/components/Station/OfficialHeader.vue
  77. 1590 1000
      frontend/components/Station/Station.vue
  78. 125 81
      frontend/components/User/ResetPassword.vue
  79. 338 186
      frontend/components/User/Settings.vue
  80. 122 71
      frontend/components/User/Show.vue
  81. 37 0
      frontend/components/UserIdToUsername.vue
  82. 50 42
      frontend/components/pages/About.vue
  83. 172 94
      frontend/components/pages/Admin.vue
  84. 25 26
      frontend/components/pages/Banned.vue
  85. 432 310
      frontend/components/pages/Home.vue
  86. 125 80
      frontend/components/pages/News.vue
  87. 179 36
      frontend/components/pages/Privacy.vue
  88. 153 212
      frontend/components/pages/Team.vue
  89. 204 21
      frontend/components/pages/Terms.vue
  90. 33 0
      frontend/dev.nginx.conf
  91. 0 0
      frontend/dist/android-chrome-144x144.png
  92. 0 0
      frontend/dist/android-chrome-192x192.png
  93. 0 0
      frontend/dist/android-chrome-36x36.png
  94. 0 0
      frontend/dist/android-chrome-48x48.png
  95. 0 0
      frontend/dist/android-chrome-72x72.png
  96. 0 0
      frontend/dist/android-chrome-96x96.png
  97. 0 0
      frontend/dist/apple-touch-icon-114x114.png
  98. 0 0
      frontend/dist/apple-touch-icon-120x120.png
  99. 0 0
      frontend/dist/apple-touch-icon-144x144.png
  100. 0 0
      frontend/dist/apple-touch-icon-152x152.png

+ 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=

+ 10 - 6
.gitignore

@@ -2,24 +2,28 @@ Thumbs.db
 .DS_Store
 *.swp
 .idea/
+.vscode/
 .vagrant/
 
 startRedis.cmd
 startMongo.cmd
 .database
+.db
 .redis
-dump.rdb
+*.rdb
 npm-debug.log
 
-# Back End
+# Backend
 backend/node_modules/
 backend/config/default.json
 
-# Front End
+# Frontend
+frontend/yarn-error.log
 frontend/node_modules/
-frontend/build/*.js
-frontend/build/index.html
-frontend/build/config/default.json
+frontend/dist/build/
+!frontend/dist/lofig.min.js
+frontend/dist/index.html
+frontend/dist/config/default.json
 
 npm
 

+ 42 - 0
.travis.yml

@@ -0,0 +1,42 @@
+# .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
+        - docker-compose exec frontend /bin/bash -c "cd app && snyk test --dev" # scan for dependency/dev. dependency vunerabilities
+    - 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
+        - docker-compose exec backend /bin/bash -c "cd app && snyk test --dev" # scan for dependency/dev. dependency vunerabilities

+ 172 - 158
README.md

@@ -1,44 +1,53 @@
+
 # MusareNode
-This is a rewrite of the original [Musare](https://github.com/Musare/MusareMeteor)
-in NodeJS, Express, SocketIO and VueJS. Everything is ran in it's own docker container, but you can also run it without Docker.
 
-The site is available at [https://musare.com](https://musare.com).
+Based off of the original [Musare](https://github.com/Musare/MusareMeteor), which utilized Meteor.
+
+MusareNode now uses NodeJS, Express, SocketIO and VueJS - among other technologies. We have also implemented the ability to host Musare in [Docker Containers](https://www.docker.com/).
+
+The master branch is available at [musare.com](https://musare.com)
+You can also find the staging branch at [musare.dev](https://musare.dev)
+
+## Contact
+
+Get in touch with us via email at [core@musare.com](mailto:core@musare.com) or join our [Discord Guild](https://discord.gg/Y5NxYGP).
+
+You can also find us on [Facebook](https://www.facebook.com/MusareMusic) and [Twitter](https://twitter.com/MusareApp).
 
 ### Our Stack
 
-   * NodeJS
-   * MongoDB
-   * Redis
-   * Nginx (not required)
-   * VueJS
+- NodeJS
+- MongoDB
+- Redis
+- Nginx (not required)
+- VueJS
 
 ### Frontend
-The frontend is a [vue-cli](https://github.com/vuejs/vue-cli) generated,
-[vue-loader](https://github.com/vuejs/vue-loader) single page app, that's
-served over Nginx or express. The Nginx server not only serves the frontend, but
-also serves as a load balancer for requests going to the backend.
+
+The frontend is a [vue-cli](https://github.com/vuejs/vue-cli) generated, [vue-loader](https://github.com/vuejs/vue-loader) single page app, that's served over Nginx or Express. The Nginx server not only serves the frontend, but can also serve as a load balancer for requests going to the backend.
 
 ### Backend
-The backend is a scalable NodeJS / Redis / MongoDB app. Each backend
-server handles a group of SocketIO connections. User sessions are stored
-in a central Redis server. All data is stored in a central MongoDB server.
-The Redis and MongoDB servers are replicated to several secondary nodes,
-which can become the primary node if the current primary node goes down.
 
-We currently only have 1 backend, 1 MongoDB server and 1 Redis server running for production, though it is relatively easy to expand.
+The backend is a scalable NodeJS / Redis / MongoDB app. Each backend server handles a group of SocketIO connections. User sessions are stored in a central Redis server. All data is stored in a central MongoDB server. The Redis and MongoDB servers are replicated to several secondary nodes, which can become the primary node if the current primary node goes down.
+
+We currently only utilize 1 backend, 1 MongoDB server and 1 Redis server running for production, though it is relatively easy to expand.
 
 ## Requirements
-Option 1: (not recommended for Windows users)
- * [Docker](https://www.docker.com/)
+Installing with Docker: (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)
+Standard Installation:
+
+- [NodeJS](https://nodejs.org/en/download/)
+  _ nodemon: `yarn global add nodemon`
+  _ [node-gyp](https://github.com/nodejs/node-gyp#installation): `yarn global add node-gyp`
+- [Yarn (Windows)](https://yarnpkg.com/lang/en/docs/install/#windows-stable) [Yarn (Unix)](https://yarnpkg.com/lang/en/docs/install/#debian-stable) ([npm](https://www.npmjs.com/) can also be used)
+- [MongoDB](https://www.mongodb.com/download-center) Currently version 4.0
+- [Redis (Windows)](https://github.com/MSOpenTech/redis/releases/tag/win-3.2.100) [Redis (Unix)](https://redis.io/download)
 
 ## Getting Started
+
 Once you've installed the required tools:
 
 1. `git clone https://github.com/Musare/MusareNode.git`
@@ -47,37 +56,50 @@ Once you've installed the required tools:
 
 3. `cp backend/config/template.json backend/config/default.json`
 
-	Values:  
-   	The `secret` key can be whatever. It's used by express's session module.  
-   	The `domain` should be the url where the site will be accessible from, usually `http://localhost` for non-Docker.  
-   	The `serverDomain` should be the url where the backend will be accessible from, usually `http://localhost:8080` for non-Docker.  
-   	The `serverPort` should be the port where the backend will listen on, usually `8080` for non-Docker.  
-   	`isDocker` if you are using Docker or not.  
-   	The `apis.youtube.key` value can be obtained by setting up a [YouTube API Key](https://developers.google.com/youtube/v3/getting-started).  
-	To set up a GitHub OAuth Application, you need to fill in some value's. The homepage is the homepage of frontend. The authorization callback url is the backend url with `/auth/github/authorize/callback` added at the end. For example `http://localhost:8080/auth/github/authorize/callback`.
-   	The `apis.recaptcha.secret` value can be obtained by setting up a [ReCaptcha Site](https://www.google.com/recaptcha/admin).  
-   	The `apis.github` values can be obtained by setting up a [GitHub OAuth Application](https://github.com/settings/developers).  
-   	The `apis.discord.token` is the token for the Discord bot.  
-   	The `apis.discord.loggingServer` is the Discord logging server id.  
-   	The `apis.discord.loggingChannel` is the Discord logging channel id.  
-   	The `apis.mailgun` values can be obtained by setting up a [Mailgun account](http://www.mailgun.com/).  
-   	The `redis.url` url should be left alone for Docker, and changed to `redis://localhost:6379/0` for non-Docker.
-   	The `redis.password` should be the Redis password you either put in your `startRedis.cmd` file for Windows, or `.env` for docker.
-   	The `mongo.url` needs to have the proper password for the MongoDB musare user, and for non-Docker you need to replace `@musare:27017` with `@localhost:27017`.  
-   	The `cookie.domain` value should be the ip or address you use to access the site, without protocols (http/https), so for example `localhost`.   
-   	The `cookie.secure` value should be `true` for SSL connections, and `false` for normal http connections.  
+   Values:
+   The `mode` should be either "development" or "production". No more explanation needed.  
+   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, should always be `8080` for Docker, and is recommended 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). You need to use the YouTube Data API v3, and create an API key.  
+   The `apis.recaptcha.secret` value can be obtained by setting up a [ReCaptcha Site (v3)](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). 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`.  
+   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/), or you can disable it.  
+   The `apis.spotify` values can be obtained by setting up a [Spotify client id](https://developer.spotify.com/dashboard/applications), or you can disable it.  
+   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.  
 
 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.  
+   Values:  
+   The `serverDomain` should be the url where the backend will be accessible from, usually `http://localhost:8080` for non-Docker.
+   The `frontendDomain` should be the url where the frontend will be accessible from, usually `http://localhost` for docker or `http://localhost:80` for non-Docker.
+   The `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.
+   The `recaptcha.key` value can be obtained by setting up a [ReCaptcha Site (v3)](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.
+   The `siteSettings.logo` should be the path to the logo image, by default it is `/assets/wordmark.png`.
+   The `siteSettings.siteName` should be the name of the site.
+   The `siteSettings.socialLinks.` `github`,`twitter`,`facebook` and `github` are set to the official Musare accounts by default but can be changed. 
 
 Now you have different paths here.
 
-####Docker
+### Installing with Docker
+
+_Configuration_
+
+To configure docker simply `cp .env.example .env` and configure the .env file to match your settings in `backend/config/default.json`.  
+The configurable ports will be how you access the services on your machine, or what ports you will need to specify in your nginx files when using proxy_pass. 
+`COMPOSE_PROJECT_NAME` should be a unique name for this installation, especially if you have multiple instances of Musare on the same machine.
+`FRONTEND_MODE` should be either `dev` or `prod` (self-explanatory).
 
 1. Build the backend and frontend Docker images (from the main folder)
 
@@ -85,105 +107,91 @@ Now you have different paths here.
 
 2. Set up the MongoDB database
 
-	1. Disable auth
-	
-		In `docker-compose.yml` remove `--auth` from the line `command: "--auth"` for mongo.
-	
-	2. Start the database
-	
-		`docker-compose up mongo`
-		
-	3. Connect to Mongo
-	
-		`docker-compose exec mongo mongo admin`
-	
-	4. Create an admin user
-	
-		`db.createUser({user: 'admin', pwd: 'PASSWORD_HERE', roles: [{role: 'root', db: 'admin'}]})`
-		
-	5. Connect to the Musare database
-	
-		`use musare`
-		
-	6. Create the musare user
-	
-		`db.createUser({user: 'musare', pwd: 'OTHER_PASSWORD_HERE', roles: [{role: 'readWrite', db: 'musare'}]})`
-	
-	7. Exit
-	
-		`exit`
-	
-	8. Add back authentication
-	
-		In `docker-compose.yml` add back `--auth` on the line `command: ""` for mongo.
-	
-
-3. Start the databases and tools in the background, as we usually don't need to monitor these for errors
-
-   `docker-compose up -d mongo mongoclient redis`
-
-4. Start the backend and frontend in the foreground, so we can watch for errors during development
+   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`.
+
+   2. 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
 
    `docker-compose up backend frontend`
 
-5. You should now be able to begin development! The backend is auto reloaded when
+5) You should now be able to begin development! The backend is auto reloaded when
    you make changes and the frontend is auto compiled and live reloaded by webpack
    when you make changes. You should be able to access Musare in your local browser
    at `http://<docker-machine-ip>:8080/` where `<docker-machine-ip>` can be found below:
 
-   * 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`
+
+If you are using linting extensions in IDEs/want to run `yarn lint`, you need to install the following locally (outside of Docker):
+```
+yarn global add eslint
+yarn add eslint-config-airbnb-base
+```
 
-####Non-docker
+### Standard Installation
 
 Steps 1-4 are things you only have to do once. The steps to start servers follow.
 
-1. In the main folder, create a folder called `.database`
-
-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"
-
-	Make sure to adjust your paths accordingly.
-	
-3. Set up the MongoDB database
-	
-	1. Start the database by executing the script `startMongo.cmd` you just made
-		
-	2. Connect to Mongo from a command prompt
-	
-		`mongo admin`
-	
-	3. Create an admin user
-	
-		`db.createUser({user: 'admin', pwd: 'PASSWORD_HERE', roles: [{role: 'userAdminAnyDatabase', db: 'admin'}]})`
-		
-	4. Connect to the Musare database
-	
-		`use musare`
-		
-	5. Create the musare user
-	
-		`db.createUser({user: 'musare', pwd: 'OTHER_PASSWORD_HERE', roles: [{role: 'readWrite', db: 'musare'}]})`
-	
-	6. Exit
-	
-		`exit`
-	
-	7. Add the authentication
-	
-		In `startMongo.cmd` add ` --auth` at the end of the first line
-
-4. In the folder where you installed Redis, edit the `redis.windows.conf` file. In there, look for the property `notify-keyspace-events`. Make sure that property is uncommented and has the value `Ex`. It should look like `notify-keyspace-events Ex` when done.
-
-5. Create a file called `startRedis.cmd` in the main folder with the contents:
-
-		"D:\Redis\redis-server.exe" "D:\Redis\redis.windows.conf" "--requirepass" "PASSWORD"
-
-	And again, make sure that the paths lead to the proper config and executable. Replace `PASSWORD` with your Redis password.
-
-####Non-docker start servers
+1.  In the main folder, create a folder called `.database`
+
+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"
+
+    Make sure to adjust your paths accordingly.
+
+3.  Set up the MongoDB database
+
+    1. Start the database by executing the script `startMongo.cmd` you just made
+
+    2. Connect to Mongo from a command prompt
+
+       `mongo admin`
+
+    3. Create an admin user
+
+       `db.createUser({user: 'admin', pwd: 'PASSWORD_HERE', roles: [{role: 'userAdminAnyDatabase', db: 'admin'}]})`
+
+    4. Connect to the Musare database
+
+       `use musare`
+
+    5. Create the musare user
+
+       `db.createUser({user: 'musare', pwd: 'OTHER_PASSWORD_HERE', roles: [{role: 'readWrite', db: 'musare'}]})`
+
+    6. Exit
+
+       `exit`
+
+    7. Add the authentication
+
+       In `startMongo.cmd` add `--auth` at the end of the first line
+
+4.  In the folder where you installed Redis, edit the `redis.windows.conf` file. In there, look for the property `notify-keyspace-events`. Make sure that property is uncommented and has the value `Ex`. It should look like `notify-keyspace-events Ex` when done.
+
+5.  Create a file called `startRedis.cmd` in the main folder with the contents:
+
+        "D:\Redis\redis-server.exe" "D:\Redis\redis.windows.conf" "--requirepass" "PASSWORD"
+
+    And again, make sure that the paths lead to the proper config and executable. Replace `PASSWORD` with your Redis password.
+
+### Non-docker start servers
 
 **Automatic**
 
@@ -193,7 +201,7 @@ Steps 1-4 are things you only have to do once. The steps to start servers follow
 
 1. Run `startRedis.cmd` and `startMongo.cmd` to start Redis and Mongo.
 
-2. In a command prompt with the pwd of frontend, run `npm run development-watch`
+2. In a command prompt with the pwd of frontend, run `yarn run dev`
 
 3. In a command prompt with the pwd of backend, run `nodemon`
 
@@ -216,24 +224,25 @@ of the following commands to give Docker Toolbox access to those files.
 
    `"C:\Program Files\Oracle\VirtualBox\VBoxManage.exe" sharedfolder add default --name "d/Projects/MusareNode" --hostpath "D:\Projects\MusareNode" --automount`
 
-2. Now start the machine back up and ssh into it
+1. Now start the machine back up and ssh into it
 
    `docker-machine start default && docker-machine ssh default`
 
-3. Tell boot2docker to mount our volume at startup, by appending to its startup script
-	```bash
-	sudo tee -a /mnt/sda1/var/lib/boot2docker/profile >/dev/null <<EOF
+1. Tell boot2docker to mount our volume at startup, by appending to its startup script
+
+   ```bash
+   sudo tee -a /mnt/sda1/var/lib/boot2docker/profile >/dev/null <<EOF
 
-	mkdir -p /d/Projects/MusareNode
-	mount -t vboxsf -o uid=1000,gid=50 d/Projects/MusareNode /d/Projects/MusareNode
-	EOF
-	```
+   mkdir -p /d/Projects/MusareNode
+   mount -t vboxsf -o uid=1000,gid=50 d/Projects/MusareNode /d/Projects/MusareNode
+   EOF
+   ```
 
-4. Restart the docker machine so that it uses the new shared folder
+1. Restart the docker machine so that it uses the new shared folder
 
    `docker-machine restart default`
 
-5. You now should be good to go!
+1. You now should be good to go!
 
 ### Fixing the "couldn't connect to docker daemon" error
 
@@ -249,17 +258,17 @@ Run this command in your shell. You will have to do this command for every shell
 
 2. Install nodemon globally
 
-   `npm install nodemon -g`
+   `yarn global add nodemon`
 
 3. Install webpack globally
 
-   `npm install webpack -g`
+   `yarn global add webpack`
 
 4. Install node-gyp globally (first check out https://github.com/nodejs/node-gyp#installation)
 
-   `npm install node-gyp -g`.
+   `yarn global add node-gyp`.
 
-5. In both `frontend` and `backend` folders, do `npm install`.
+5. In both `frontend` and `backend` folders, do `yarn install`.
 
 6. `nodemon backend/index.js`
 
@@ -268,12 +277,17 @@ Run this command in your shell. You will have to do this command for every shell
 You can call Toasts using our custom package, [`vue-roaster`](https://github.com/atjonathan/vue-roaster), using the following code:
 
 ```js
-import { Toast } from 'vue-roaster';
-Toast.methods.addToast('', 0);
+import { Toast } from "vue-roaster";
+Toast.methods.addToast("", 0);
 ```
 
-## Contact
+### Set user role
+
+When setting up you will need to grant yourself the admin role, using the following commands:
 
-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).
+```
+docker-compose exec mongo mongo admin
 
-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).
+use musare
+db.auth("MUSAREDBUSER","MUSAREDBPASSWORD")
+db.users.update({username: "USERNAME"}, {$set: {role: "admin"}})

+ 2 - 1
backend/Dockerfile

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

+ 12 - 6
backend/config/template.json

@@ -1,8 +1,9 @@
 {
-	"secret": "",
-	"domain": "",
+	"mode": "development",
+	"secret": "default",
+	"domain": "http://localhost",
 	"frontendPort": 80,
-	"serverDomain": "",
+	"serverDomain": "http://localhost:8080",
   	"serverPort": 8080,
   	"isDocker": true,
 	"apis": {
@@ -25,7 +26,12 @@
 		"mailgun": {
 			"key": "",
 			"domain": "",
-		  	"enabled": true
+		  	"enabled": false
+		},
+		"spotify": {
+			"client": "",
+			"secret": "",
+			"enabled": false
 		}
 	},
 	"cors": {
@@ -40,10 +46,10 @@
 	    "password": "PASSWORD"
 	},
   	"mongo": {
-	  	"url": "mongodb://musare:PASSWORD@mongo:27017/musare"
+	  	"url": "mongodb://musare:OTHER_PASSWORD_HERE@mongo:27017/musare"
 	},
   	"cookie": {
-	  	"domain": "",
+	  	"domain": "localhost",
 	  	"secure": false
 	}
 }

+ 46 - 56
backend/index.js

@@ -5,9 +5,8 @@ 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 Discord = require("discord.js");
+//const client = new Discord.Client();
 const db = require('./logic/db');
 const app = require('./logic/app');
 const mail = require('./logic/mail');
@@ -15,8 +14,10 @@ const api = require('./logic/api');
 const io = require('./logic/io');
 const stations = require('./logic/stations');
 const songs = require('./logic/songs');
+const spotify = require('./logic/spotify');
 const playlists = require('./logic/playlists');
 const cache = require('./logic/cache');
+const discord = require('./logic/discord');
 const notifications = require('./logic/notifications');
 const punishments = require('./logic/punishments');
 const logger = require('./logic/logger');
@@ -42,51 +43,6 @@ const getError = (err) => {
 	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}.`);
-});
-
-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;
@@ -99,14 +55,28 @@ function lockdown() {
 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}]);
+	discord.sendAdminAlertMessage(message, "#FF0000", message, true, [{name: "Error:", value: err, inline: false}, {name: "Component:", value: component, inline: true}]); //TODO Maybe due to lockdown this won't work, and what if the Discord module was the one that failed?
+}
+
+function moduleStartFunction() {
+	logger.info("MODULE_START", `Starting to initialize component '${currentComponent}'`);
 }
 
 async.waterfall([
 
+	// setup our Discord module
+	(next) => {
+		currentComponent = 'Discord';
+		moduleStartFunction();
+		discord.init(config.get('apis.discord').token, config.get('apis.discord').loggingChannel, errorCb, () => {
+			next();
+		});
+	},
+
 	// setup our Redis cache
 	(next) => {
 		currentComponent = 'Cache';
+		moduleStartFunction();
 		cache.init(config.get('redis').url, config.get('redis').password, errorCb, () => {
 			next();
 		});
@@ -116,6 +86,7 @@ async.waterfall([
 	(next) => {
 		initializedComponents.push(cache);
 		currentComponent = 'DB';
+		moduleStartFunction();
 		db.init(config.get("mongo").url, errorCb, next);
 	},
 
@@ -123,6 +94,7 @@ async.waterfall([
 	(next) => {
 		initializedComponents.push(db);
 		currentComponent = 'App';
+		moduleStartFunction();
 		app.init(next);
 	},
 
@@ -130,13 +102,23 @@ async.waterfall([
 	(next) => {
 		initializedComponents.push(app);
 		currentComponent = 'Mail';
+		moduleStartFunction();
 		mail.init(next);
 	},
 
-	// setup the socket.io server (all client / server communication is done over this)
+	// setup the Spotify
 	(next) => {
 		initializedComponents.push(mail);
+		currentComponent = 'Spotify';
+		moduleStartFunction();
+		spotify.init(next);
+	},
+
+	// setup the socket.io server (all client / server communication is done over this)
+	(next) => {
+		initializedComponents.push(spotify);
 		currentComponent = 'IO';
+		moduleStartFunction();
 		io.init(next);
 	},
 
@@ -144,6 +126,7 @@ async.waterfall([
 	(next) => {
 		initializedComponents.push(io);
 		currentComponent = 'Punishments';
+		moduleStartFunction();
 		punishments.init(next);
 	},
 
@@ -151,6 +134,7 @@ async.waterfall([
 	(next) => {
 		initializedComponents.push(punishments);
 		currentComponent = 'Notifications';
+		moduleStartFunction();
 		notifications.init(config.get('redis').url, config.get('redis').password, errorCb, next);
 	},
 
@@ -158,6 +142,7 @@ async.waterfall([
 	(next) => {
 		initializedComponents.push(notifications);
 		currentComponent = 'Stations';
+		moduleStartFunction();
 		stations.init(next)
 	},
 
@@ -165,6 +150,7 @@ async.waterfall([
 	(next) => {
 		initializedComponents.push(stations);
 		currentComponent = 'Songs';
+		moduleStartFunction();
 		songs.init(next)
 	},
 
@@ -172,6 +158,7 @@ async.waterfall([
 	(next) => {
 		initializedComponents.push(songs);
 		currentComponent = 'Playlists';
+		moduleStartFunction();
 		playlists.init(next)
 	},
 
@@ -179,6 +166,7 @@ async.waterfall([
 	(next) => {
 		initializedComponents.push(playlists);
 		currentComponent = 'API';
+		moduleStartFunction();
 		api.init(next)
 	},
 
@@ -186,6 +174,7 @@ async.waterfall([
 	(next) => {
 		initializedComponents.push(api);
 		currentComponent = 'Logger';
+		moduleStartFunction();
 		logger.init(next)
 	},
 
@@ -193,6 +182,7 @@ async.waterfall([
 	(next) => {
 		initializedComponents.push(logger);
 		currentComponent = 'Tasks';
+		moduleStartFunction();
 		tasks.init(next)
 	},
 
@@ -200,22 +190,22 @@ async.waterfall([
 	(next) => {
 		initializedComponents.push(tasks);
 		currentComponent = 'Windows';
-		if (!config.get("isDocker")) {
+		moduleStartFunction();
+		if (!config.get("isDocker") && !(config.get("mode") === "development" || config.get("mode") === "dev")) {
 			const express = require('express');
 			const app = express();
 			app.listen(config.get("frontendPort"));
-			const rootDir = __dirname.substr(0, __dirname.lastIndexOf("backend")) + "frontend/build/";
+			const rootDir = __dirname.substr(0, __dirname.lastIndexOf("backend")) + "frontend/dist/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");
+				res.sendFile(`${rootDir}/index.html`);
 			});
 		}
 		if (lockdownB) return;
@@ -224,10 +214,10 @@ async.waterfall([
 ], (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}]);
+		discord.sendAdminAlertMessage("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, []);
+		discord.sendAdminAlertMessage("The backend server started successfully.", "#00AA00", "Startup", false, []);
 		console.info('Backend server has been successfully started');
 	}
 });

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

@@ -35,7 +35,9 @@ module.exports = {
 				next(null, JSON.parse(body));
 			}
 		], (err, data) => {
-			if (err) {
+			console.log(data.error);
+			if (err || data.error) {
+				if (!err) err = data.error.message;
 				err = utils.getError(err);
 				logger.error("APIS_SEARCH_YOUTUBE", `Searching youtube failed with query "${query}". "${err}"`);
 				return cb({status: 'failure', message: err});

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

@@ -115,7 +115,7 @@ module.exports = {
 	//TODO Pass in an id, not an object
 	//TODO Fix this
 	remove: hooks.adminRequired((session, news, cb, userId) => {
-		db.models.news.remove({ _id: news._id }, err => {
+		db.models.news.deleteOne({ _id: news._id }, err => {
 			if (err) {
 				err = utils.getError(err);
 				logger.error("NEWS_REMOVE", `Removing news "${news._id}" failed for user "${userId}". "${err}"`);
@@ -138,7 +138,7 @@ module.exports = {
 	 */
 	//TODO Fix this
 	update: hooks.adminRequired((session, _id, news, cb, userId) => {
-		db.models.news.update({ _id }, news, { upsert: true }, err => {
+		db.models.news.updateOne({ _id }, news, { upsert: true }, err => {
 			if (err) {
 				err = utils.getError(err);
 				logger.error("NEWS_UPDATE", `Updating news "${_id}" failed for user "${userId}". "${err}"`);

+ 11 - 9
backend/logic/actions/playlists.js

@@ -163,7 +163,9 @@ let lib = {
 			}
 			cache.pub('playlist.create', playlist._id);
 			logger.success("PLAYLIST_CREATE", `Successfully created private playlist for user "${userId}".`);
-			cb({ 'status': 'success', 'message': 'Successfully created playlist' });
+			cb({ status: 'success', message: 'Successfully created playlist', data: {
+				_id: playlist._id
+			} });
 		});
 	}),
 
@@ -212,7 +214,7 @@ let lib = {
 	update: hooks.loginRequired((session, playlistId, playlist, cb, userId) => {
 		async.waterfall([
 			(next) => {
-				db.models.playlist.update({ _id: playlistId, createdBy: userId }, playlist, {runValidators: true}, next);
+				db.models.playlist.updateOne({ _id: playlistId, createdBy: userId }, playlist, {runValidators: true}, next);
 			},
 
 			(res, next) => {
@@ -270,7 +272,7 @@ let lib = {
 				});
 			},
 			(newSong, next) => {
-				db.models.playlist.update({_id: playlistId}, {$push: {songs: newSong}}, {runValidators: true}, (err) => {
+				db.models.playlist.updateOne({_id: playlistId}, {$push: {songs: newSong}}, {runValidators: true}, (err) => {
 					if (err) return next(err);
 					playlists.updatePlaylist(playlistId, (err, playlist) => {
 						next(err, playlist, newSong);
@@ -362,7 +364,7 @@ let lib = {
 
 			(playlist, next) => {
 				if (!playlist || playlist.createdBy !== userId) return next('Playlist not found');
-				db.models.playlist.update({_id: playlistId}, {$pull: {songs: {songId: songId}}}, next);
+				db.models.playlist.updateOne({_id: playlistId}, {$pull: {songs: {songId: songId}}}, next);
 			},
 
 			(res, next) => {
@@ -392,7 +394,7 @@ let lib = {
 	updateDisplayName: hooks.loginRequired((session, playlistId, displayName, cb, userId) => {
 		async.waterfall([
 			(next) => {
-				db.models.playlist.update({ _id: playlistId, createdBy: userId }, { $set: { displayName } }, {runValidators: true}, next);
+				db.models.playlist.updateOne({ _id: playlistId, createdBy: userId }, { $set: { displayName } }, {runValidators: true}, next);
 			},
 
 			(res, next) => {
@@ -437,14 +439,14 @@ let lib = {
 			},
 
 			(song, next) => {
-				db.models.playlist.update({_id: playlistId}, {$pull: {songs: {songId}}}, (err) => {
+				db.models.playlist.updateOne({_id: playlistId}, {$pull: {songs: {songId}}}, (err) => {
 					if (err) return next(err);
 					return next(null, song);
 				});
 			},
 
 			(song, next) => {
-				db.models.playlist.update({_id: playlistId}, {
+				db.models.playlist.updateOne({_id: playlistId}, {
 					$push: {
 						songs: {
 							$each: [song],
@@ -496,14 +498,14 @@ let lib = {
 			},
 
 			(song, next) => {
-				db.models.playlist.update({_id: playlistId}, {$pull: {songs: {songId}}}, (err) => {
+				db.models.playlist.updateOne({_id: playlistId}, {$pull: {songs: {songId}}}, (err) => {
 					if (err) return next(err);
 					return next(null, song);
 				});
 			},
 
 			(song, next) => {
-				db.models.playlist.update({_id: playlistId}, {
+				db.models.playlist.updateOne({_id: playlistId}, {
 					$push: {
 						songs: song
 					}

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

@@ -26,7 +26,7 @@ cache.sub('queue.update', songId => {
 	});
 });
 
-module.exports = {
+let lib = {
 
 	/**
 	 * Gets all queuesongs
@@ -91,7 +91,7 @@ module.exports = {
 				let $set = {};
 				for (let prop in updatedSong) if (updatedSong[prop] !== song[prop]) $set[prop] = updatedSong[prop]; updated = true;
 				if (!updated) return next('No properties changed');
-				db.models.queueSong.update({_id: songId}, {$set}, {runValidators: true}, next);
+				db.models.queueSong.updateOne({_id: songId}, {$set}, {runValidators: true}, next);
 			}
 		], (err) => {
 			if (err) {
@@ -116,7 +116,7 @@ module.exports = {
 	remove: hooks.adminRequired((session, songId, cb, userId) => {
 		async.waterfall([
 			(next) => {
-				db.models.queueSong.remove({_id: songId}, next);
+				db.models.queueSong.deleteOne({_id: songId}, next);
 			}
 		], (err) => {
 			if (err) {
@@ -155,12 +155,11 @@ module.exports = {
 			(song, next) => {
 				if (song) return next('This song has already been added.');
 				//TODO Add err object as first param of callback
-				console.log(52, songId);
 				utils.getSongFromYouTube(songId, (song) => {
 					song.artists = [];
 					song.genres = [];
 					song.skipDuration = 0;
-					song.thumbnail = 'empty';
+					song.thumbnail = `${config.get("domain")}/assets/notes.png`;
 					song.explicit = false;
 					song.requestedBy = userId;
 					song.requestedAt = requestedAt;
@@ -168,15 +167,14 @@ module.exports = {
 				});
 			},
 			(newSong, next) => {
-				//TODO Add err object as first param of callback
-				utils.getSongFromSpotify(newSong, (song) => {
-					next(null, song);
+				utils.getSongFromSpotify(newSong, (err, song) => {
+					if (!song) next(null, newSong);
+					else next(err, song);
 				});
 			},
 			(newSong, next) => {
 				const song = new db.models.queueSong(newSong);
 				song.save((err, song) => {
-					console.log(err);
 					if (err) return next(err);
 					next(null, song);
 				});
@@ -203,5 +201,46 @@ module.exports = {
 			logger.success("QUEUE_ADD", `User "${userId}" successfully added queuesong "${songId}".`);
 			return cb({ status: 'success', message: 'Successfully added that song to the queue' });
 		});
+	}),
+
+	/**
+	 * Adds a set of songs to the queue
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} url - the url of the the YouTube playlist
+	 * @param {Function} cb - gets called with the result
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	addSetToQueue: hooks.loginRequired((session, url, cb, userId) => {
+		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();
+					});
+				}
+			}
+		], (err) => {
+			if (err) {
+				err = utils.getError(err);
+				logger.error("QUEUE_IMPORT", `Importing a YouTube playlist to the queue failed for user "${userId}". "${err}"`);
+				return cb({ status: 'failure', message: err});
+			} else {
+				logger.success("QUEUE_IMPORT", `Successfully imported a YouTube playlist to the queue for user "${userId}".`);
+				cb({ status: 'success', message: 'Playlist has been successfully imported.' });
+			}
+		});
 	})
-};
+};
+
+module.exports = lib;

+ 46 - 18
backend/logic/actions/songs.js

@@ -73,7 +73,7 @@ module.exports = {
 	length: hooks.adminRequired((session, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.song.count({}, next);
+				db.models.song.countDocuments({}, next);
 			}
 		], (err, count) => {
 			if (err) {
@@ -105,10 +105,38 @@ module.exports = {
 				return cb({'status': 'failure', 'message': err});
 			}
 			logger.success("SONGS_GET_SET", `Got set from songs successfully.`);
+			logger.stationIssue(songs.length, true);
+			logger.stationIssue(Math.max(songs.length - 15, 0), true);
 			cb(songs.splice(Math.max(songs.length - 15, 0)));
 		});
 	}),
 
+	/**
+	 * 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);
+			}
+		], (err, song) => {
+			if (err) {
+				err = 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 });
+			}
+		});
+	}),
+
 	/**
 	 * Updates a song
 	 *
@@ -120,7 +148,7 @@ module.exports = {
 	update: hooks.adminRequired((session, songId, song, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.song.update({_id: songId}, song, {runValidators: true}, next);
+				db.models.song.updateOne({_id: songId}, song, {runValidators: true}, next);
 			},
 
 			(res, next) => {
@@ -148,7 +176,7 @@ module.exports = {
 	remove: hooks.adminRequired((session, songId, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.song.remove({_id: songId}, next);
+				db.models.song.deleteOne({_id: songId}, next);
 			},
 
 			(res, next) => {//TODO Check if res gets returned from above
@@ -192,7 +220,7 @@ module.exports = {
 				newSong.save(next);
 			},
 
-			(next) => {
+			(res, next) => {
 				queueSongs.remove(session, song._id, () => {
 					next();
 				});
@@ -238,11 +266,11 @@ module.exports = {
 			songId = song._id;
 			db.models.user.findOne({ _id: userId }, (err, user) => {
 				if (user.liked.indexOf(songId) !== -1) return cb({ status: 'failure', message: 'You have already liked this song.' });
-				db.models.user.update({_id: userId}, {$push: {liked: songId}, $pull: {disliked: songId}}, err => {
+				db.models.user.updateOne({_id: userId}, {$push: {liked: songId}, $pull: {disliked: songId}}, err => {
 					if (!err) {
-						db.models.user.count({"liked": songId}, (err, likes) => {
+						db.models.user.countDocuments({"liked": songId}, (err, likes) => {
 							if (err) return cb({ status: 'failure', message: 'Something went wrong while liking this song.' });
-							db.models.user.count({"disliked": songId}, (err, dislikes) => {
+							db.models.user.countDocuments({"disliked": songId}, (err, dislikes) => {
 								if (err) return cb({ status: 'failure', message: 'Something went wrong while liking this song.' });
 								db.models.song.update({_id: songId}, {$set: {likes: likes, dislikes: dislikes}}, (err) => {
 									if (err) return cb({ status: 'failure', message: 'Something went wrong while liking this song.' });
@@ -286,11 +314,11 @@ module.exports = {
 			songId = song._id;
 			db.models.user.findOne({ _id: userId }, (err, user) => {
 				if (user.disliked.indexOf(songId) !== -1) return cb({ status: 'failure', message: 'You have already disliked this song.' });
-				db.models.user.update({_id: userId}, {$push: {disliked: songId}, $pull: {liked: songId}}, err => {
+				db.models.user.updateOne({_id: userId}, {$push: {disliked: songId}, $pull: {liked: songId}}, err => {
 					if (!err) {
-						db.models.user.count({"liked": songId}, (err, likes) => {
+						db.models.user.countDocuments({"liked": songId}, (err, likes) => {
 							if (err) return cb({ status: 'failure', message: 'Something went wrong while disliking this song.' });
-							db.models.user.count({"disliked": songId}, (err, dislikes) => {
+							db.models.user.countDocuments({"disliked": songId}, (err, dislikes) => {
 								if (err) return cb({ status: 'failure', message: 'Something went wrong while disliking this song.' });
 								db.models.song.update({_id: songId}, {$set: {likes: likes, dislikes: dislikes}}, (err, res) => {
 									if (err) return cb({ status: 'failure', message: 'Something went wrong while disliking this song.' });
@@ -337,14 +365,14 @@ module.exports = {
 					status: 'failure',
 					message: 'You have not disliked this song.'
 				});
-				db.models.user.update({_id: userId}, {$pull: {liked: songId, disliked: songId}}, err => {
+				db.models.user.updateOne({_id: userId}, {$pull: {liked: songId, disliked: songId}}, err => {
 					if (!err) {
-						db.models.user.count({"liked": songId}, (err, likes) => {
+						db.models.user.countDocuments({"liked": songId}, (err, likes) => {
 							if (err) return cb({
 								status: 'failure',
 								message: 'Something went wrong while undisliking this song.'
 							});
-							db.models.user.count({"disliked": songId}, (err, dislikes) => {
+							db.models.user.countDocuments({"disliked": songId}, (err, dislikes) => {
 								if (err) return cb({
 									status: 'failure',
 									message: 'Something went wrong while undisliking this song.'
@@ -403,13 +431,13 @@ module.exports = {
 			songId = song._id;
 			db.models.user.findOne({ _id: userId }, (err, user) => {
 				if (user.liked.indexOf(songId) === -1) return cb({ status: 'failure', message: 'You have not liked this song.' });
-				db.models.user.update({_id: userId}, {$pull: {liked: songId, disliked: songId}}, err => {
+				db.models.user.updateOne({_id: userId}, {$pull: {liked: songId, disliked: songId}}, err => {
 					if (!err) {
-						db.models.user.count({"liked": songId}, (err, likes) => {
+						db.models.user.countDocuments({"liked": songId}, (err, likes) => {
 							if (err) return cb({ status: 'failure', message: 'Something went wrong while unliking this song.' });
-							db.models.user.count({"disliked": songId}, (err, dislikes) => {
+							db.models.user.countDocuments({"disliked": songId}, (err, dislikes) => {
 								if (err) return cb({ status: 'failure', message: 'Something went wrong while undiking this song.' });
-								db.models.song.update({_id: songId}, {$set: {likes: likes, dislikes: dislikes}}, (err) => {
+								db.models.song.updateOne({_id: songId}, {$set: {likes: likes, dislikes: dislikes}}, (err) => {
 									if (err) return cb({ status: 'failure', message: 'Something went wrong while unliking this song.' });
 									songs.updateSong(songId, (err, song) => {});
 									cache.pub('song.unlike', JSON.stringify({ songId: oldSongId, userId: session.userId, likes: likes, dislikes: dislikes }));
@@ -465,4 +493,4 @@ module.exports = {
 			});
 		});
 	})
-};
+};

+ 73 - 14
backend/logic/actions/stations.js

@@ -134,7 +134,9 @@ cache.sub('privatePlaylist.selected', data => {
 });
 
 cache.sub('station.pause', stationId => {
-	utils.emitToRoom(`station.${stationId}`, "event:stations.pause");
+	stations.getStation(stationId, (err, station) => {
+		utils.emitToRoom(`station.${stationId}`, "event:stations.pause", { pausedAt: station.pausedAt });
+	});
 });
 
 cache.sub('station.resume', stationId => {
@@ -366,6 +368,7 @@ module.exports = {
 					startedAt: station.startedAt,
 					paused: station.paused,
 					timePaused: station.timePaused,
+					pausedAt: station.pausedAt,
 					description: station.description,
 					displayName: station.displayName,
 					privacy: station.privacy,
@@ -420,7 +423,7 @@ module.exports = {
 			},
 
 			(station, next) => {
-				db.models.station.update({ _id: stationId }, { $set: { locked: !station.locked} }, next);
+				db.models.station.updateOne({ _id: stationId }, { $set: { locked: !station.locked} }, next);
 			},
 
 			(res, next) => {
@@ -468,7 +471,7 @@ module.exports = {
 			},
 
 			(station, next) => {
-				db.models.station.update({_id: stationId}, {$push: {"currentSong.skipVotes": userId}}, next)
+				db.models.station.updateOne({_id: stationId}, {$push: {"currentSong.skipVotes": userId}}, next)
 			},
 
 			(res, next) => {
@@ -564,7 +567,7 @@ module.exports = {
 	updateName: hooks.ownerRequired((session, stationId, newName, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.station.update({_id: stationId}, {$set: {name: newName}}, {runValidators: true}, next);
+				db.models.station.updateOne({_id: stationId}, {$set: {name: newName}}, {runValidators: true}, next);
 			},
 
 			(res, next) => {
@@ -592,7 +595,7 @@ module.exports = {
 	updateDisplayName: hooks.ownerRequired((session, stationId, newDisplayName, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.station.update({_id: stationId}, {$set: {displayName: newDisplayName}}, {runValidators: true}, next);
+				db.models.station.updateOne({_id: stationId}, {$set: {displayName: newDisplayName}}, {runValidators: true}, next);
 			},
 
 			(res, next) => {
@@ -620,7 +623,7 @@ module.exports = {
 	updateDescription: hooks.ownerRequired((session, stationId, newDescription, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.station.update({_id: stationId}, {$set: {description: newDescription}}, {runValidators: true}, next);
+				db.models.station.updateOne({_id: stationId}, {$set: {description: newDescription}}, {runValidators: true}, next);
 			},
 
 			(res, next) => {
@@ -648,7 +651,7 @@ module.exports = {
 	updatePrivacy: hooks.ownerRequired((session, stationId, newPrivacy, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.station.update({_id: stationId}, {$set: {privacy: newPrivacy}}, {runValidators: true}, next);
+				db.models.station.updateOne({_id: stationId}, {$set: {privacy: newPrivacy}}, {runValidators: true}, next);
 			},
 
 			(res, next) => {
@@ -665,6 +668,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);
+			}
+		], (err) => {
+			if (err) {
+				err = 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);
+			}
+		], (err) => {
+			if (err) {
+				err = utils.getError(err);
+				logger.error("STATIONS_UPDATE_BLACKLISTED_GENRES", `Updating station "${stationId}" blacklisted genres to "${newBlacklistedGenres}" failed. "${err}"`);
+				return cb({'status': 'failure', 'message': err});
+			}
+			logger.success("STATIONS_UPDATE_BLACKLISTED_GENRES", `Updated station "${stationId}" blacklisted genres to "${newBlacklistedGenres}" successfully.`);
+			return cb({'status': 'success', 'message': 'Successfully updated the blacklisted genres.'});
+		});
+	}),
+
 	/**
 	 * Updates a station's party mode
 	 *
@@ -682,7 +741,7 @@ module.exports = {
 			(station, next) => {
 				if (!station) return next('Station not found.');
 				if (station.partyMode === newPartyMode) return next('The party mode was already ' + ((newPartyMode) ? 'enabled.' : 'disabled.'));
-				db.models.station.update({_id: stationId}, {$set: {partyMode: newPartyMode}}, {runValidators: true}, next);
+				db.models.station.updateOne({_id: stationId}, {$set: {partyMode: newPartyMode}}, {runValidators: true}, next);
 			},
 
 			(res, next) => {
@@ -717,7 +776,7 @@ module.exports = {
 			(station, next) => {
 				if (!station) return next('Station not found.');
 				if (station.paused) return next('That station was already paused.');
-				db.models.station.update({_id: stationId}, {$set: {paused: true, pausedAt: Date.now()}}, next);
+				db.models.station.updateOne({_id: stationId}, {$set: {paused: true, pausedAt: Date.now()}}, next);
 			},
 
 			(res, next) => {
@@ -753,7 +812,7 @@ module.exports = {
 				if (!station) return next('Station not found.');
 				if (!station.paused) return next('That station is not paused.');
 				station.timePaused += (Date.now() - station.pausedAt);
-				db.models.station.update({_id: stationId}, {$set: {paused: false}, $inc: {timePaused: Date.now() - station.pausedAt}}, next);
+				db.models.station.updateOne({_id: stationId}, {$set: {paused: false}, $inc: {timePaused: Date.now() - station.pausedAt}}, next);
 			},
 
 			(res, next) => {
@@ -781,7 +840,7 @@ module.exports = {
 	remove: hooks.ownerRequired((session, stationId, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.station.remove({ _id: stationId }, err => next(err));
+				db.models.station.deleteOne({ _id: stationId }, err => next(err));
 			},
 
 			(next) => {
@@ -972,7 +1031,7 @@ module.exports = {
 			},
 
 			(song, next) => {
-				db.models.station.update({_id: stationId}, {$push: {queue: song}}, {runValidators: true}, next);
+				db.models.station.updateOne({_id: stationId}, {$push: {queue: song}}, {runValidators: true}, next);
 			},
 
 			(res, next) => {
@@ -1019,7 +1078,7 @@ module.exports = {
 			},
 
 			(next) => {
-				db.models.station.update({_id: stationId}, {$pull: {queue: {songId: songId}}}, next);
+				db.models.station.updateOne({_id: stationId}, {$pull: {queue: {songId: songId}}}, next);
 			},
 
 			(res, next) => {
@@ -1098,7 +1157,7 @@ module.exports = {
 			(playlist, next) => {
 				if (!playlist) return next('Playlist not found.');
 				let currentSongIndex = (playlist.songs.length > 0) ? playlist.songs.length - 1 : 0;
-				db.models.station.update({_id: stationId}, {$set: {privatePlaylist: playlistId, currentSongIndex: currentSongIndex}}, {runValidators: true}, next);
+				db.models.station.updateOne({_id: stationId}, {$set: {privatePlaylist: playlistId, currentSongIndex: currentSongIndex}}, {runValidators: true}, next);
 			},
 
 			(res, next) => {

+ 16 - 20
backend/logic/actions/users.js

@@ -412,21 +412,17 @@ module.exports = {
 	 * @param {Function} cb - gets called with the result
 	 */
 	getUsernameFromId: (session, userId, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.user.findOne({ _id: userId }, next);
-			},
-		], (err, user) => {
+		db.models.user.findById(userId).then(user => {
+			logger.success("GET_USERNAME_FROM_ID", `Found username for userId "${userId}".`);
+			return cb({
+				status: 'success',
+				data: user.username
+			});
+		}).catch(err => {
 			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 {
-				logger.success("GET_USERNAME_FROM_ID", `Found username for userId "${userId}".`);
-				return cb({
-					status: 'success',
-					data: user.username
-				});
+				cb({ status: 'failure', message: err });
 			}
 		});
 	},
@@ -518,7 +514,7 @@ module.exports = {
 			},
 
 			(next) => {
-				db.models.user.update({ _id: updatingUserId }, {$set: {username: newUsername}}, {runValidators: true}, next);
+				db.models.user.updateOne({ _id: updatingUserId }, {$set: {username: newUsername}}, {runValidators: true}, next);
 			}
 		], (err) => {
 			if (err && err !== true) {
@@ -576,7 +572,7 @@ module.exports = {
 			},
 
 			(next) => {
-				db.models.user.update({_id: updatingUserId}, {$set: {"email.address": newEmail, "email.verified": false, "email.verificationToken": verificationToken}}, {runValidators: true}, next);
+				db.models.user.updateOne({_id: updatingUserId}, {$set: {"email.address": newEmail, "email.verified": false, "email.verificationToken": verificationToken}}, {runValidators: true}, next);
 			},
 
 			(res, next) => {
@@ -623,7 +619,7 @@ module.exports = {
 				else return next();
 			},
 			(next) => {
-				db.models.user.update({_id: updatingUserId}, {$set: {role: newRole}}, {runValidators: true}, next);
+				db.models.user.updateOne({_id: updatingUserId}, {$set: {role: newRole}}, {runValidators: true}, next);
 			}
 
 		], (err) => {
@@ -675,7 +671,7 @@ module.exports = {
 			},
 
 			(hashedPassword, next) => {
-				db.models.user.update({_id: userId}, {$set: {"services.password.password": hashedPassword}}, next);
+				db.models.user.updateOne({_id: userId}, {$set: {"services.password.password": hashedPassword}}, next);
 			}
 		], (err) => {
 			if (err) {
@@ -809,7 +805,7 @@ module.exports = {
 			},
 
 			(hashedPassword, next) => {
-				db.models.user.update({"services.password.set.code": code}, {$set: {"services.password.password": hashedPassword}, $unset: {"services.password.set": ''}}, {runValidators: true}, next);
+				db.models.user.updateOne({"services.password.set.code": code}, {$set: {"services.password.password": hashedPassword}, $unset: {"services.password.set": ''}}, {runValidators: true}, next);
 			}
 		], (err) => {
 			if (err && err !== true) {
@@ -843,7 +839,7 @@ module.exports = {
 			(user, next) => {
 				if (!user) return next('Not logged in.');
 				if (!user.services.github || !user.services.github.id) return next('You can\'t remove password login without having GitHub login.');
-				db.models.user.update({_id: userId}, {$unset: {"services.password": ''}}, next);
+				db.models.user.updateOne({_id: userId}, {$unset: {"services.password": ''}}, next);
 			}
 		], (err) => {
 			if (err && err !== true) {
@@ -877,7 +873,7 @@ module.exports = {
 			(user, next) => {
 				if (!user) return next('Not logged in.');
 				if (!user.services.password || !user.services.password.password) return next('You can\'t remove GitHub login without having password login.');
-				db.models.user.update({_id: userId}, {$unset: {"services.github": ''}}, next);
+				db.models.user.updateOne({_id: userId}, {$unset: {"services.github": ''}}, next);
 			}
 		], (err) => {
 			if (err && err !== true) {
@@ -1011,7 +1007,7 @@ module.exports = {
 			},
 
 			(hashedPassword, next) => {
-				db.models.user.update({"services.password.reset.code": code}, {$set: {"services.password.password": hashedPassword}, $unset: {"services.password.reset": ''}}, {runValidators: true}, next);
+				db.models.user.updateOne({"services.password.reset.code": code}, {$set: {"services.password.password": hashedPassword}, $unset: {"services.password.reset": ''}}, {runValidators: true}, next);
 			}
 		], (err) => {
 			if (err && err !== true) {

+ 3 - 3
backend/logic/app.js

@@ -115,7 +115,7 @@ const lib = {
 							(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) => {
+								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);
 								});
@@ -221,7 +221,7 @@ const lib = {
 				(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);
+					db.models.user.updateOne({"email.verificationToken": code}, {$set: {"email.verified": true}, $unset: {"email.verificationToken": ''}}, {runValidators: true}, next);
 				}
 			], (err) => {
 				if (err) {
@@ -232,7 +232,7 @@ const lib = {
 					return res.json({ status: 'failure', message: error});
 				}
 				logger.success("VERIFY_EMAIL", `Successfully verified email.`);
-				res.redirect(config.get("domain"));
+				res.redirect(`${config.get("domain")}?msg=Thank you for verifying your email`);
 			});
 		});
 

+ 164 - 163
backend/logic/db/index.js

@@ -29,170 +29,171 @@ let lib = {
 	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.');
-
-			/*
-			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) {
+		mongoose.connect(url, {
+			useNewUrlParser: true,
+			useCreateIndex: true
+		})
+			.then(() => {
+				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.countDocuments({ owner: owner }, (err, c) => {
+						callback(!(err || c >= 3));
+					});
+				}, 'User already has 3 stations.');
+	
+				/*
+				lib.schemas.station.path('queue').validate((queue, callback) => {
+					let totalDuration = 0;
+					queue.forEach((song) => {
 						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));
-				});
-			}, '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();
-		});
+					});
+					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, 100);
+				};
+				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) => {
+					lib.models.playlist.countDocuments({ createdBy: createdBy }, (err, c) => {
+						return !(err || c >= 10);
+					});
+				}, '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();
+			})
+			.catch(err => {
+				console.error(err);
+				errorCb('Database connection error.', err, 'DB');
+			});
 	},
 
 	passwordValid: (password) => {

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

@@ -1,5 +1,4 @@
 module.exports = {
-	_id: { type: String, required: true, index: true, unique: true, min: 12, max: 12 },
 	username: { type: String, required: true },
 	role: { type: String, default: 'default', required: true },
 	email: {

+ 107 - 0
backend/logic/discord.js

@@ -0,0 +1,107 @@
+let lockdown = false;
+
+const Discord = require("discord.js");
+const logger = require("./logger");
+const config = require("config");
+
+const client = new Discord.Client();
+
+let messagesToSend = [];
+
+let connected = false;
+
+// TODO Maybe we need to only finish init when ready is called, or maybe we don't wait for it
+module.exports = {
+  adminAlertChannelId: "",
+
+  init: function(discordToken, adminAlertChannelId, errorCb, cb) {
+    this.adminAlertChannelId = adminAlertChannelId;
+
+    client.on("ready", () => {
+      logger.info("DISCORD_READY", `Logged in as ${client.user.tag}!`);
+      connected = true;
+      messagesToSend.forEach(message => {
+        this.sendAdminAlertMessage(message.message, message.color, message.type, message.critical, message.extraFields);
+      });
+      messagesToSend = [];
+    });
+
+    client.on("invalidated", () => {
+      logger.info("DISCORD_INVALIDATED", `Discord client was invalidated.`);
+      connected = false;
+    });
+
+    client.on("disconnected", () => {
+      logger.info("DISCORD_DISCONNECTED", `Discord client was disconnected.`);
+      connected = false;
+    });
+
+    client.on("error", err => {
+      logger.info(
+        "DISCORD_ERROR",
+        `Discord client encountered an error: ${err.message}.`
+      );
+    });
+
+    client.login(discordToken);
+
+    if (lockdown) return this._lockdown();
+    cb();
+  },
+
+  sendAdminAlertMessage: function(message, color, type, critical, extraFields) {
+    if (!connected) return messagesToSend.push({message, color, type, critical, extraFields});
+    else {
+      let channel = client.channels.find("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 =>
+            logger.success(
+              "SEND_ADMIN_ALERT_MESSAGE",
+              `Sent admin alert message: ${message}`
+            )
+          )
+          .catch(() =>
+            logger.error(
+              "SEND_ADMIN_ALERT_MESSAGE",
+              "Couldn't send admin alert message"
+            )
+          );
+      } else {
+        logger.error(
+          "SEND_ADMIN_ALERT_MESSAGE",
+          "Couldn't send admin alert message, channel was not found."
+        );
+      }
+    }
+  },
+
+  _lockdown: () => {
+    lockdown = true;
+  }
+};

+ 1 - 1
backend/logic/logger.js

@@ -3,7 +3,7 @@
 const dir = `${__dirname}/../../log`;
 const fs = require('fs');
 const config = require('config');
-const Discord = require("discord.js");
+//const Discord = require("discord.js");
 let client;
 let utils;
 

+ 1 - 1
backend/logic/playlists.js

@@ -142,7 +142,7 @@ module.exports = {
 		async.waterfall([
 
 			(next) => {
-				db.models.playlist.remove({ _id: playlistId }, next);
+				db.models.playlist.deleteOne({ _id: playlistId }, next);
 			},
 
 			(res, next) => {

+ 1 - 1
backend/logic/songs.js

@@ -148,7 +148,7 @@ module.exports = {
 		async.waterfall([
 
 			(next) => {
-				db.models.song.remove({ songId }, next);
+				db.models.song.deleteOne({ songId }, next);
 			},
 
 			(next) => {

+ 84 - 0
backend/logic/spotify.js

@@ -0,0 +1,84 @@
+const config = require('config'),
+	  async  = require('async'),
+	  logger = require('./logger'),
+	  cache  = require('./cache');
+
+const client = config.get("apis.spotify.client");
+const secret = config.get("apis.spotify.secret");
+
+const OAuth2 = require('oauth').OAuth2;
+const SpotifyOauth = new OAuth2(
+	client,
+	secret, 
+	'https://accounts.spotify.com/', 
+	null,
+	'api/token',
+	null);
+
+let apiResults = {
+	access_token: "",
+	token_type: "",
+	expires_in: 0,
+	expires_at: 0,
+	scope: "",
+};
+
+let initialized = false;
+let lockdown = false;
+
+let lib = {
+	init: (cb) => {
+		async.waterfall([
+			(next) => {
+				cache.hget("api", "spotify", next, true);
+			},
+
+			(data, next) => {
+				if (data) apiResults = data;
+				next();
+			}
+		], (err) => {
+			if (lockdown) return this._lockdown();
+			if (err) {
+				err = utils.getError(err);
+				cb(err);
+			} else {
+				initialized = true;
+				cb();
+			}
+		});
+	},
+	getToken: () => {
+		return new Promise((resolve, reject) => {
+			if (Date.now() > apiResults.expires_at) {
+				lib.requestToken(() => {
+					resolve(apiResults.access_token);
+				});
+			} else resolve(apiResults.access_token);
+		});
+	},
+	requestToken: (cb) => {
+		async.waterfall([
+			(next) => {
+				logger.info("SPOTIFY_REQUEST_TOKEN", "Requesting new Spotify token.");
+				SpotifyOauth.getOAuthAccessToken(
+					'',
+					{ 'grant_type': 'client_credentials' },
+					next
+				);
+			},
+			(access_token, refresh_token, results, next) => {
+				apiResults = results;
+				apiResults.expires_at = Date.now() + (results.expires_in * 1000);
+				cache.hset("api", "spotify", apiResults, next, true);
+			}
+		], () => {
+			cb();
+		});
+	},
+	_lockdown: () => {
+		lockdown = true;
+	}
+};
+
+module.exports = lib;

+ 6 - 5
backend/logic/stations.js

@@ -179,7 +179,7 @@ module.exports = {
 			},
 
 			(playlist, next) => {
-				db.models.station.update({_id: station._id}, {$set: {playlist: playlist}}, {runValidators: true}, (err) => {
+				db.models.station.updateOne({_id: station._id}, {$set: {playlist: playlist}}, {runValidators: true}, (err) => {
 					_this.updateStation(station._id, () => {
 						next(err, playlist);
 					});
@@ -312,7 +312,7 @@ module.exports = {
 					if (!station) return next('Station not found.');
 					if (station.type === 'community' && station.partyMode && station.queue.length === 0) return next(null, null, -11, station); // Community station with party mode enabled and no songs in the queue
 					if (station.type === 'community' && station.partyMode && station.queue.length > 0) { // Community station with party mode enabled and songs in the queue
-						return db.models.station.update({_id: stationId}, {$pull: {queue: {_id: station.queue[0]._id}}}, (err) => {
+						return db.models.station.updateOne({_id: stationId}, {$pull: {queue: {_id: station.queue[0]._id}}}, (err) => {
 							if (err) return next(err);
 							next(null, station.queue[0], -12, station);
 						});
@@ -378,8 +378,9 @@ module.exports = {
 									});
 								});
 							}
-						}, (song) => {
-							return !!song;
+						}, (song, currentSongIndex, next) => {
+							if (!!song) return next(null, true, currentSongIndex);
+							else return next(null, false);
 						}, (err, song, currentSongIndex) => {
 							return next(err, song, currentSongIndex, station);
 						});
@@ -417,7 +418,7 @@ module.exports = {
 				},
 
 				($set, station, next) => {
-					db.models.station.update({_id: station._id}, {$set}, (err) => {
+					db.models.station.updateOne({_id: station._id}, {$set}, (err) => {
 						_this.updateStation(station._id, (err, station) => {
 							if (station.type === 'community' && station.partyMode === true)
 								cache.pub('station.queueUpdate', stationId);

+ 29 - 7
backend/logic/utils.js

@@ -3,6 +3,7 @@
 const moment  = require('moment'),
 	  io      = require('./io'),
 	  db      = require('./db'),
+	  spotify = require('./spotify'),
 	  config  = require('config'),
 	  async	  = require('async'),
 	  request = require('request'),
@@ -288,7 +289,7 @@ module.exports = {
 				body = JSON.parse(body);
 
 				//TODO Clean up duration converter
-				let dur = body.items[0].contentDetails.duration;
+  				let dur = body.items[0].contentDetails.duration;
 				dur = dur.replace('PT', '');
 				let duration = 0;
 				dur = dur.replace(/([\d]*)H/, (v, v2) => {
@@ -353,17 +354,26 @@ module.exports = {
 		}
 		getPage(null, []);
 	},
-	getSongFromSpotify: (song, cb) => {
+	getSongFromSpotify: async (song, cb) => {
+		if (!config.get("apis.spotify.enabled")) return cb("Spotify is not enabled", null);
+
 		const spotifyParams = [
 			`q=${encodeURIComponent(song.title)}`,
 			`type=track`
 		].join('&');
 
-		request(`https://api.spotify.com/v1/search?${spotifyParams}`, (err, res, body) => {
+		const token = await spotify.getToken();
+		const options = {
+			url: `https://api.spotify.com/v1/search?${spotifyParams}`,
+			headers: {
+				Authorization: `Bearer ${token}`
+			}
+		};
 
+		request(options, (err, res, body) => {
 			if (err) console.error(err);
-
 			body = JSON.parse(body);
+			if (body.error) console.error(body.error);
 
 			durationArtistLoop:
 			for (let i in body) {
@@ -388,18 +398,30 @@ module.exports = {
 				}
 			}
 
-			cb(song);
+			cb(null, song);
 		});
 	},
-	getSongsFromSpotify: (title, artist, cb) => {
+	getSongsFromSpotify: async (title, artist, cb) => {
+		if (!config.get("apis.spotify.enabled")) return cb([]);
+
 		const spotifyParams = [
 			`q=${encodeURIComponent(title)}`,
 			`type=track`
 		].join('&');
+		
+		const token = await spotify.getToken();
+		const options = {
+			url: `https://api.spotify.com/v1/search?${spotifyParams}`,
+			headers: {
+				Authorization: `Bearer ${token}`
+			}
+		};
 
-		request(`https://api.spotify.com/v1/search?${spotifyParams}`, (err, res, body) => {
+		request(options, (err, res, body) => {
 			if (err) return console.error(err);
 			body = JSON.parse(body);
+			if (body.error) return console.error(body.error);
+
 			let songs = [];
 
 			for (let i in body) {

+ 20 - 26
backend/package.json

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

+ 2017 - 0
backend/yarn.lock

@@ -0,0 +1,2017 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+"@types/node@^8.0.7":
+  version "8.10.51"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-8.10.51.tgz#80600857c0a47a8e8bafc2dae6daed6db58e3627"
+  integrity sha512-cArrlJp3Yv6IyFT/DYe+rlO8o3SIHraALbBW/+CcCYW/a9QucpLI+n2p4sRxAvl2O35TiecpX2heSZtJjvEO+Q==
+
+abbrev@1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
+  integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==
+
+accepts@~1.3.4, accepts@~1.3.7:
+  version "1.3.7"
+  resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd"
+  integrity sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==
+  dependencies:
+    mime-types "~2.1.24"
+    negotiator "0.6.2"
+
+after@0.8.2:
+  version "0.8.2"
+  resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f"
+  integrity sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8=
+
+agent-base@4, agent-base@^4.2.0, agent-base@^4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee"
+  integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==
+  dependencies:
+    es6-promisify "^5.0.0"
+
+agent-base@~4.2.1:
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.2.1.tgz#d89e5999f797875674c07d87f260fc41e83e8ca9"
+  integrity sha512-JVwXMr9nHYTUXsBFKUqhJwvlcYU/blreOEUkhNR2eXZIvwd+c+o5V4MgDPKWnMS/56awN3TRzIP+KoPn+roQtg==
+  dependencies:
+    es6-promisify "^5.0.0"
+
+ajv@^6.5.5:
+  version "6.10.2"
+  resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.10.2.tgz#d3cea04d6b017b2894ad69040fec8b623eb4bd52"
+  integrity sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw==
+  dependencies:
+    fast-deep-equal "^2.0.1"
+    fast-json-stable-stringify "^2.0.0"
+    json-schema-traverse "^0.4.1"
+    uri-js "^4.2.2"
+
+ansi-regex@^2.0.0:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df"
+  integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8=
+
+ansi-regex@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998"
+  integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=
+
+aproba@^1.0.3:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a"
+  integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==
+
+are-we-there-yet@~1.1.2:
+  version "1.1.5"
+  resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21"
+  integrity sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==
+  dependencies:
+    delegates "^1.0.0"
+    readable-stream "^2.0.6"
+
+array-flatten@1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2"
+  integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=
+
+arraybuffer.slice@~0.0.7:
+  version "0.0.7"
+  resolved "https://registry.yarnpkg.com/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz#3bbc4275dd584cc1b10809b89d4e8b63a69e7675"
+  integrity sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog==
+
+asn1@~0.2.3:
+  version "0.2.4"
+  resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136"
+  integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==
+  dependencies:
+    safer-buffer "~2.1.0"
+
+assert-plus@1.0.0, assert-plus@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525"
+  integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=
+
+ast-types@0.x.x:
+  version "0.13.2"
+  resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.13.2.tgz#df39b677a911a83f3a049644fb74fdded23cea48"
+  integrity sha512-uWMHxJxtfj/1oZClOxDEV1sQ1HCDkA4MG8Gr69KKeBjEVH0R84WlejZ0y2DcwyBlpAEMltmVYkVgqfLFb2oyiA==
+
+async-limiter@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.0.tgz#78faed8c3d074ab81f22b4e985d79e8738f720f8"
+  integrity sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg==
+
+async@2.6.2:
+  version "2.6.2"
+  resolved "https://registry.yarnpkg.com/async/-/async-2.6.2.tgz#18330ea7e6e313887f5d2f2a904bac6fe4dd5381"
+  integrity sha512-H1qVYh1MYhEEFLsP97cVKqCGo7KfCyTt6uEWqsTBr9SO84oK9Uwbyd/yCW+6rKJLHksBNUVWZDAjfS+Ccx0Bbg==
+  dependencies:
+    lodash "^4.17.11"
+
+async@3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/async/-/async-3.1.0.tgz#42b3b12ae1b74927b5217d8c0016baaf62463772"
+  integrity sha512-4vx/aaY6j/j3Lw3fbCHNWP0pPaTCew3F6F3hYyl/tHs/ndmV1q7NW9T5yuJ2XAGwdQrP+6Wu20x06U4APo/iQQ==
+
+async@^2.6.1:
+  version "2.6.3"
+  resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff"
+  integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==
+  dependencies:
+    lodash "^4.17.14"
+
+asynckit@^0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
+  integrity sha1-x57Zf380y48robyXkLzDZkdLS3k=
+
+aws-sign2@~0.7.0:
+  version "0.7.0"
+  resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
+  integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=
+
+aws4@^1.8.0:
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f"
+  integrity sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==
+
+backo2@1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947"
+  integrity sha1-MasayLEpNjRj41s+u2n038+6eUc=
+
+balanced-match@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
+  integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c=
+
+base64-arraybuffer@0.1.5:
+  version "0.1.5"
+  resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz#73926771923b5a19747ad666aa5cd4bf9c6e9ce8"
+  integrity sha1-c5JncZI7Whl0etZmqlzUv5xunOg=
+
+base64id@1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/base64id/-/base64id-1.0.0.tgz#47688cb99bb6804f0e06d3e763b1c32e57d8e6b6"
+  integrity sha1-R2iMuZu2gE8OBtPnY7HDLlfY5rY=
+
+bcrypt-pbkdf@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e"
+  integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=
+  dependencies:
+    tweetnacl "^0.14.3"
+
+bcrypt@^3.0.6:
+  version "3.0.6"
+  resolved "https://registry.yarnpkg.com/bcrypt/-/bcrypt-3.0.6.tgz#f607846df62d27e60d5e795612c4f67d70206eb2"
+  integrity sha512-taA5bCTfXe7FUjKroKky9EXpdhkVvhE5owfxfLYodbrAR1Ul3juLmIQmIQBK4L9a5BuUcE6cqmwT+Da20lF9tg==
+  dependencies:
+    nan "2.13.2"
+    node-pre-gyp "0.12.0"
+
+better-assert@~1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/better-assert/-/better-assert-1.0.2.tgz#40866b9e1b9e0b55b481894311e68faffaebc522"
+  integrity sha1-QIZrnhueC1W0gYlDEeaPr/rrxSI=
+  dependencies:
+    callsite "1.0.0"
+
+blob@0.0.5:
+  version "0.0.5"
+  resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.5.tgz#d680eeef25f8cd91ad533f5b01eed48e64caf683"
+  integrity sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==
+
+bluebird@3.5.1:
+  version "3.5.1"
+  resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.1.tgz#d9551f9de98f1fcda1e683d17ee91a0602ee2eb9"
+  integrity sha512-MKiLiV+I1AA596t9w1sQJ8jkiSr5+ZKi0WKrYGUn6d1Fx+Ij4tIj+m2WMQSGczs5jZVxV339chE8iwk6F64wjA==
+
+bluebird@^3.5.5:
+  version "3.5.5"
+  resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.5.tgz#a8d0afd73251effbbd5fe384a77d73003c17a71f"
+  integrity sha512-5am6HnnfN+urzt4yfg7IgTbotDjIT/u8AJpEt0sIU9FtXfVeezXAPKswrG+xKUCOYAINpSdgZVDU6QFh+cuH3w==
+
+body-parser@1.19.0, body-parser@^1.19.0:
+  version "1.19.0"
+  resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"
+  integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==
+  dependencies:
+    bytes "3.1.0"
+    content-type "~1.0.4"
+    debug "2.6.9"
+    depd "~1.1.2"
+    http-errors "1.7.2"
+    iconv-lite "0.4.24"
+    on-finished "~2.3.0"
+    qs "6.7.0"
+    raw-body "2.4.0"
+    type-is "~1.6.17"
+
+brace-expansion@^1.1.7:
+  version "1.1.11"
+  resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
+  integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==
+  dependencies:
+    balanced-match "^1.0.0"
+    concat-map "0.0.1"
+
+bson@^1.1.1, bson@~1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/bson/-/bson-1.1.1.tgz#4330f5e99104c4e751e7351859e2d408279f2f13"
+  integrity sha512-jCGVYLoYMHDkOsbwJZBCqwMHyH4c+wzgI9hG7Z6SZJRXWr+x58pdIbm2i9a/jFGCkRJqRUr8eoI7lDWa0hTkxg==
+
+bytes@3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
+  integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==
+
+callsite@1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/callsite/-/callsite-1.0.0.tgz#280398e5d664bd74038b6f0905153e6e8af1bc20"
+  integrity sha1-KAOY5dZkvXQDi28JBRU+borxvCA=
+
+caseless@~0.12.0:
+  version "0.12.0"
+  resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
+  integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=
+
+chownr@^1.1.1:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.2.tgz#a18f1e0b269c8a6a5d3c86eb298beb14c3dd7bf6"
+  integrity sha512-GkfeAQh+QNy3wquu9oIZr6SS5x7wGdSgNQvD10X3r+AZr1Oys22HW8kAmDMvNg2+Dm0TeGaEuO8gFwdBXxwO8A==
+
+co@^4.6.0:
+  version "4.6.0"
+  resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
+  integrity sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=
+
+code-point-at@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
+  integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=
+
+combined-stream@^1.0.6, combined-stream@~1.0.6:
+  version "1.0.8"
+  resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
+  integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
+  dependencies:
+    delayed-stream "~1.0.0"
+
+component-bind@1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/component-bind/-/component-bind-1.0.0.tgz#00c608ab7dcd93897c0009651b1d3a8e1e73bbd1"
+  integrity sha1-AMYIq33Nk4l8AAllGx06jh5zu9E=
+
+component-emitter@1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6"
+  integrity sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=
+
+component-inherit@0.0.3:
+  version "0.0.3"
+  resolved "https://registry.yarnpkg.com/component-inherit/-/component-inherit-0.0.3.tgz#645fc4adf58b72b649d5cae65135619db26ff143"
+  integrity sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM=
+
+concat-map@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
+  integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
+
+config@^3.2.0:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/config/-/config-3.2.1.tgz#c687cdd0ba22433422ff4f6e3edffbe3930aa141"
+  integrity sha512-EMA/IU0gBI3OZHi41B2JaosXEc6tJMN8RT3Pm5cHuRfbtfAQbNmYB6bFq0JK8tRu8F2WZ8s+5tnUX7acEy37xw==
+  dependencies:
+    json5 "^1.0.1"
+
+console-control-strings@^1.0.0, console-control-strings@~1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e"
+  integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=
+
+content-disposition@0.5.3:
+  version "0.5.3"
+  resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd"
+  integrity sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==
+  dependencies:
+    safe-buffer "5.1.2"
+
+content-type@~1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
+  integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
+
+convert-hex@~0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/convert-hex/-/convert-hex-0.1.0.tgz#08c04568922c27776b8a2e81a95d393362ea0b65"
+  integrity sha1-CMBFaJIsJ3drii6BqV05M2LqC2U=
+
+convert-string@~0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/convert-string/-/convert-string-0.1.0.tgz#79ce41a9bb0d03bcf72cdc6a8f3c56fbbc64410a"
+  integrity sha1-ec5BqbsNA7z3LNxqjzxW+7xkQQo=
+
+cookie-parser@^1.4.4:
+  version "1.4.4"
+  resolved "https://registry.yarnpkg.com/cookie-parser/-/cookie-parser-1.4.4.tgz#e6363de4ea98c3def9697b93421c09f30cf5d188"
+  integrity sha512-lo13tqF3JEtFO7FyA49CqbhaFkskRJ0u/UAiINgrIXeRCY41c88/zxtrECl8AKH3B0hj9q10+h3Kt8I7KlW4tw==
+  dependencies:
+    cookie "0.3.1"
+    cookie-signature "1.0.6"
+
+cookie-signature@1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
+  integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw=
+
+cookie@0.3.1:
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb"
+  integrity sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=
+
+cookie@0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba"
+  integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==
+
+core-util-is@1.0.2, core-util-is@~1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
+  integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=
+
+cors@^2.8.5:
+  version "2.8.5"
+  resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29"
+  integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==
+  dependencies:
+    object-assign "^4"
+    vary "^1"
+
+dashdash@^1.12.0:
+  version "1.14.1"
+  resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0"
+  integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=
+  dependencies:
+    assert-plus "^1.0.0"
+
+data-uri-to-buffer@2:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-2.0.1.tgz#ca8f56fe38b1fd329473e9d1b4a9afcd8ce1c045"
+  integrity sha512-OkVVLrerfAKZlW2ZZ3Ve2y65jgiWqBKsTfUIAFbn8nVbPcCZg6l6gikKlEYv0kXcmzqGm6mFq/Jf2vriuEkv8A==
+  dependencies:
+    "@types/node" "^8.0.7"
+
+debug@2.6.9:
+  version "2.6.9"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
+  integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
+  dependencies:
+    ms "2.0.0"
+
+debug@3.1.0, debug@~3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
+  integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==
+  dependencies:
+    ms "2.0.0"
+
+debug@4, debug@^4.1.0, debug@~4.1.0:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791"
+  integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==
+  dependencies:
+    ms "^2.1.1"
+
+debug@^3.1.0, debug@^3.2.6:
+  version "3.2.6"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b"
+  integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==
+  dependencies:
+    ms "^2.1.1"
+
+deep-extend@^0.6.0:
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
+  integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
+
+deep-is@~0.1.3:
+  version "0.1.3"
+  resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34"
+  integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=
+
+degenerator@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/degenerator/-/degenerator-1.0.4.tgz#fcf490a37ece266464d9cc431ab98c5819ced095"
+  integrity sha1-/PSQo37OJmRk2cxDGrmMWBnO0JU=
+  dependencies:
+    ast-types "0.x.x"
+    escodegen "1.x.x"
+    esprima "3.x.x"
+
+delayed-stream@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
+  integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk=
+
+delegates@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
+  integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=
+
+depd@~1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
+  integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=
+
+destroy@~1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80"
+  integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=
+
+detect-libc@^1.0.2:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
+  integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=
+
+discord.js@^11.5.1:
+  version "11.5.1"
+  resolved "https://registry.yarnpkg.com/discord.js/-/discord.js-11.5.1.tgz#910fb9f6410328581093e044cafb661783a4d9e8"
+  integrity sha512-tGhV5xaZXE3Z+4uXJb3hYM6gQ1NmnSxp9PClcsSAYFVRzH6AJH74040mO3afPDMWEAlj8XsoPXXTJHTxesqcGw==
+  dependencies:
+    long "^4.0.0"
+    prism-media "^0.0.3"
+    snekfetch "^3.6.4"
+    tweetnacl "^1.0.0"
+    ws "^6.0.0"
+
+double-ended-queue@^2.1.0-0:
+  version "2.1.0-0"
+  resolved "https://registry.yarnpkg.com/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz#103d3527fd31528f40188130c841efdd78264e5c"
+  integrity sha1-ED01J/0xUo9AGIEwyEHv3XgmTlw=
+
+ecc-jsbn@~0.1.1:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"
+  integrity sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=
+  dependencies:
+    jsbn "~0.1.0"
+    safer-buffer "^2.1.0"
+
+ee-first@1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
+  integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
+
+encodeurl@~1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
+  integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=
+
+engine.io-client@~3.3.1:
+  version "3.3.2"
+  resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.3.2.tgz#04e068798d75beda14375a264bb3d742d7bc33aa"
+  integrity sha512-y0CPINnhMvPuwtqXfsGuWE8BB66+B6wTtCofQDRecMQPYX3MYUZXFNKDhdrSe3EVjgOu4V3rxdeqN/Tr91IgbQ==
+  dependencies:
+    component-emitter "1.2.1"
+    component-inherit "0.0.3"
+    debug "~3.1.0"
+    engine.io-parser "~2.1.1"
+    has-cors "1.1.0"
+    indexof "0.0.1"
+    parseqs "0.0.5"
+    parseuri "0.0.5"
+    ws "~6.1.0"
+    xmlhttprequest-ssl "~1.5.4"
+    yeast "0.1.2"
+
+engine.io-parser@~2.1.0, engine.io-parser@~2.1.1:
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-2.1.3.tgz#757ab970fbf2dfb32c7b74b033216d5739ef79a6"
+  integrity sha512-6HXPre2O4Houl7c4g7Ic/XzPnHBvaEmN90vtRO9uLmwtRqQmTOw0QMevL1TOfL2Cpu1VzsaTmMotQgMdkzGkVA==
+  dependencies:
+    after "0.8.2"
+    arraybuffer.slice "~0.0.7"
+    base64-arraybuffer "0.1.5"
+    blob "0.0.5"
+    has-binary2 "~1.0.2"
+
+engine.io@~3.3.1:
+  version "3.3.2"
+  resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-3.3.2.tgz#18cbc8b6f36e9461c5c0f81df2b830de16058a59"
+  integrity sha512-AsaA9KG7cWPXWHp5FvHdDWY3AMWeZ8x+2pUVLcn71qE5AtAzgGbxuclOytygskw8XGmiQafTmnI9Bix3uihu2w==
+  dependencies:
+    accepts "~1.3.4"
+    base64id "1.0.0"
+    cookie "0.3.1"
+    debug "~3.1.0"
+    engine.io-parser "~2.1.0"
+    ws "~6.1.0"
+
+es6-promise@^4.0.3:
+  version "4.2.8"
+  resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a"
+  integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==
+
+es6-promisify@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-5.0.0.tgz#5109d62f3e56ea967c4b63505aef08291c8a5203"
+  integrity sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=
+  dependencies:
+    es6-promise "^4.0.3"
+
+escape-html@~1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
+  integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=
+
+escodegen@1.x.x:
+  version "1.11.1"
+  resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.11.1.tgz#c485ff8d6b4cdb89e27f4a856e91f118401ca510"
+  integrity sha512-JwiqFD9KdGVVpeuRa68yU3zZnBEOcPs0nKW7wZzXky8Z7tffdYUHbe11bPCV5jYlK6DVdKLWLm0f5I/QlL0Kmw==
+  dependencies:
+    esprima "^3.1.3"
+    estraverse "^4.2.0"
+    esutils "^2.0.2"
+    optionator "^0.8.1"
+  optionalDependencies:
+    source-map "~0.6.1"
+
+esprima@3.x.x, esprima@^3.1.3:
+  version "3.1.3"
+  resolved "https://registry.yarnpkg.com/esprima/-/esprima-3.1.3.tgz#fdca51cee6133895e3c88d535ce49dbff62a4633"
+  integrity sha1-/cpRzuYTOJXjyI1TXOSdv/YqRjM=
+
+estraverse@^4.2.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.2.0.tgz#0dee3fed31fcd469618ce7342099fc1afa0bdb13"
+  integrity sha1-De4/7TH81GlhjOc0IJn8GvoL2xM=
+
+esutils@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b"
+  integrity sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=
+
+etag@~1.8.1:
+  version "1.8.1"
+  resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
+  integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=
+
+express@^4.17.1:
+  version "4.17.1"
+  resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134"
+  integrity sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==
+  dependencies:
+    accepts "~1.3.7"
+    array-flatten "1.1.1"
+    body-parser "1.19.0"
+    content-disposition "0.5.3"
+    content-type "~1.0.4"
+    cookie "0.4.0"
+    cookie-signature "1.0.6"
+    debug "2.6.9"
+    depd "~1.1.2"
+    encodeurl "~1.0.2"
+    escape-html "~1.0.3"
+    etag "~1.8.1"
+    finalhandler "~1.1.2"
+    fresh "0.5.2"
+    merge-descriptors "1.0.1"
+    methods "~1.1.2"
+    on-finished "~2.3.0"
+    parseurl "~1.3.3"
+    path-to-regexp "0.1.7"
+    proxy-addr "~2.0.5"
+    qs "6.7.0"
+    range-parser "~1.2.1"
+    safe-buffer "5.1.2"
+    send "0.17.1"
+    serve-static "1.14.1"
+    setprototypeof "1.1.1"
+    statuses "~1.5.0"
+    type-is "~1.6.18"
+    utils-merge "1.0.1"
+    vary "~1.1.2"
+
+extend@~3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
+  integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
+
+extsprintf@1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05"
+  integrity sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=
+
+extsprintf@^1.2.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f"
+  integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8=
+
+fast-deep-equal@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49"
+  integrity sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=
+
+fast-json-stable-stringify@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2"
+  integrity sha1-1RQsDK7msRifh9OnYREGT4bIu/I=
+
+fast-levenshtein@~2.0.4:
+  version "2.0.6"
+  resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
+  integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=
+
+file-uri-to-path@1:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd"
+  integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==
+
+finalhandler@~1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d"
+  integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==
+  dependencies:
+    debug "2.6.9"
+    encodeurl "~1.0.2"
+    escape-html "~1.0.3"
+    on-finished "~2.3.0"
+    parseurl "~1.3.3"
+    statuses "~1.5.0"
+    unpipe "~1.0.0"
+
+forever-agent@~0.6.1:
+  version "0.6.1"
+  resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
+  integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=
+
+form-data@^2.3.3:
+  version "2.5.0"
+  resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.5.0.tgz#094ec359dc4b55e7d62e0db4acd76e89fe874d37"
+  integrity sha512-WXieX3G/8side6VIqx44ablyULoGruSde5PNTxoUyo5CeyAMX6nVWUd0rgist/EuX655cjhUhTo1Fo3tRYqbcA==
+  dependencies:
+    asynckit "^0.4.0"
+    combined-stream "^1.0.6"
+    mime-types "^2.1.12"
+
+form-data@~2.3.2:
+  version "2.3.3"
+  resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6"
+  integrity sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==
+  dependencies:
+    asynckit "^0.4.0"
+    combined-stream "^1.0.6"
+    mime-types "^2.1.12"
+
+forwarded@~0.1.2:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84"
+  integrity sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=
+
+fresh@0.5.2:
+  version "0.5.2"
+  resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
+  integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=
+
+fs-minipass@^1.2.5:
+  version "1.2.6"
+  resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.6.tgz#2c5cc30ded81282bfe8a0d7c7c1853ddeb102c07"
+  integrity sha512-crhvyXcMejjv3Z5d2Fa9sf5xLYVCF5O1c71QxbVnbLsmYMBEvDAftewesN/HhY03YRoA7zOMxjNGrF5svGaaeQ==
+  dependencies:
+    minipass "^2.2.1"
+
+fs.realpath@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
+  integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
+
+ftp@~0.3.10:
+  version "0.3.10"
+  resolved "https://registry.yarnpkg.com/ftp/-/ftp-0.3.10.tgz#9197d861ad8142f3e63d5a83bfe4c59f7330885d"
+  integrity sha1-kZfYYa2BQvPmPVqDv+TFn3MwiF0=
+  dependencies:
+    readable-stream "1.1.x"
+    xregexp "2.0.0"
+
+gauge@~2.7.3:
+  version "2.7.4"
+  resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7"
+  integrity sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=
+  dependencies:
+    aproba "^1.0.3"
+    console-control-strings "^1.0.0"
+    has-unicode "^2.0.0"
+    object-assign "^4.1.0"
+    signal-exit "^3.0.0"
+    string-width "^1.0.1"
+    strip-ansi "^3.0.1"
+    wide-align "^1.1.0"
+
+get-uri@^2.0.0:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/get-uri/-/get-uri-2.0.3.tgz#fa13352269781d75162c6fc813c9e905323fbab5"
+  integrity sha512-x5j6Ks7FOgLD/GlvjKwgu7wdmMR55iuRHhn8hj/+gA+eSbxQvZ+AEomq+3MgVEZj1vpi738QahGbCCSIDtXtkw==
+  dependencies:
+    data-uri-to-buffer "2"
+    debug "4"
+    extend "~3.0.2"
+    file-uri-to-path "1"
+    ftp "~0.3.10"
+    readable-stream "3"
+
+getpass@^0.1.1:
+  version "0.1.7"
+  resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa"
+  integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=
+  dependencies:
+    assert-plus "^1.0.0"
+
+glob@^7.1.3:
+  version "7.1.4"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.4.tgz#aa608a2f6c577ad357e1ae5a5c26d9a8d1969255"
+  integrity sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==
+  dependencies:
+    fs.realpath "^1.0.0"
+    inflight "^1.0.4"
+    inherits "2"
+    minimatch "^3.0.4"
+    once "^1.3.0"
+    path-is-absolute "^1.0.0"
+
+har-schema@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92"
+  integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=
+
+har-validator@~5.1.0:
+  version "5.1.3"
+  resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.3.tgz#1ef89ebd3e4996557675eed9893110dc350fa080"
+  integrity sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==
+  dependencies:
+    ajv "^6.5.5"
+    har-schema "^2.0.0"
+
+has-binary2@~1.0.2:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/has-binary2/-/has-binary2-1.0.3.tgz#7776ac627f3ea77250cfc332dab7ddf5e4f5d11d"
+  integrity sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw==
+  dependencies:
+    isarray "2.0.1"
+
+has-cors@1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/has-cors/-/has-cors-1.1.0.tgz#5e474793f7ea9843d1bb99c23eef49ff126fff39"
+  integrity sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk=
+
+has-unicode@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9"
+  integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=
+
+http-errors@1.7.2:
+  version "1.7.2"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.2.tgz#4f5029cf13239f31036e5b2e55292bcfbcc85c8f"
+  integrity sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==
+  dependencies:
+    depd "~1.1.2"
+    inherits "2.0.3"
+    setprototypeof "1.1.1"
+    statuses ">= 1.5.0 < 2"
+    toidentifier "1.0.0"
+
+http-errors@1.7.3, http-errors@~1.7.2:
+  version "1.7.3"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06"
+  integrity sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==
+  dependencies:
+    depd "~1.1.2"
+    inherits "2.0.4"
+    setprototypeof "1.1.1"
+    statuses ">= 1.5.0 < 2"
+    toidentifier "1.0.0"
+
+http-proxy-agent@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-2.1.0.tgz#e4821beef5b2142a2026bd73926fe537631c5405"
+  integrity sha512-qwHbBLV7WviBl0rQsOzH6o5lwyOIvwp/BdFnvVxXORldu5TmjFfjzBcWUWS5kWAZhmv+JtiDhSuQCp4sBfbIgg==
+  dependencies:
+    agent-base "4"
+    debug "3.1.0"
+
+http-signature@~1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1"
+  integrity sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=
+  dependencies:
+    assert-plus "^1.0.0"
+    jsprim "^1.2.2"
+    sshpk "^1.7.0"
+
+https-proxy-agent@^2.2.1:
+  version "2.2.2"
+  resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-2.2.2.tgz#271ea8e90f836ac9f119daccd39c19ff7dfb0793"
+  integrity sha512-c8Ndjc9Bkpfx/vCJueCPy0jlP4ccCCSNDp8xwCZzPjKJUm+B+u9WX2x98Qx4n1PiMNTWo3D7KK5ifNV/yJyRzg==
+  dependencies:
+    agent-base "^4.3.0"
+    debug "^3.1.0"
+
+iconv-lite@0.4.24, iconv-lite@^0.4.4:
+  version "0.4.24"
+  resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
+  integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
+  dependencies:
+    safer-buffer ">= 2.1.2 < 3"
+
+ignore-walk@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.1.tgz#a83e62e7d272ac0e3b551aaa82831a19b69f82f8"
+  integrity sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ==
+  dependencies:
+    minimatch "^3.0.4"
+
+indexof@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d"
+  integrity sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=
+
+inflection@~1.12.0:
+  version "1.12.0"
+  resolved "https://registry.yarnpkg.com/inflection/-/inflection-1.12.0.tgz#a200935656d6f5f6bc4dc7502e1aecb703228416"
+  integrity sha1-ogCTVlbW9fa8TcdQLhrstwMihBY=
+
+inflection@~1.3.0:
+  version "1.3.8"
+  resolved "https://registry.yarnpkg.com/inflection/-/inflection-1.3.8.tgz#cbd160da9f75b14c3cc63578d4f396784bf3014e"
+  integrity sha1-y9Fg2p91sUw8xjV41POWeEvzAU4=
+
+inflight@^1.0.4:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
+  integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=
+  dependencies:
+    once "^1.3.0"
+    wrappy "1"
+
+inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
+  integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
+
+inherits@2.0.3:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
+  integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=
+
+ini@~1.3.0:
+  version "1.3.5"
+  resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"
+  integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==
+
+ip@^1.1.5:
+  version "1.1.5"
+  resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a"
+  integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=
+
+ipaddr.js@1.9.0:
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.0.tgz#37df74e430a0e47550fe54a2defe30d8acd95f65"
+  integrity sha512-M4Sjn6N/+O6/IXSJseKqHoFc+5FdGJ22sXqnjTpdZweHK64MzEPAyQZyEU3R/KRv2GLoa7nNtg/C2Ev6m7z+eA==
+
+is-fullwidth-code-point@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb"
+  integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs=
+  dependencies:
+    number-is-nan "^1.0.0"
+
+is-fullwidth-code-point@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f"
+  integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=
+
+is-stream@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
+  integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ=
+
+is-typedarray@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
+  integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=
+
+isarray@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
+  integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=
+
+isarray@2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.1.tgz#a37d94ed9cda2d59865c9f76fe596ee1f338741e"
+  integrity sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4=
+
+isarray@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
+  integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=
+
+isstream@~0.1.2:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
+  integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=
+
+jsbn@~0.1.0:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
+  integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM=
+
+json-schema-traverse@^0.4.1:
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
+  integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==
+
+json-schema@0.2.3:
+  version "0.2.3"
+  resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13"
+  integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=
+
+json-stringify-safe@~5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
+  integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=
+
+json5@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe"
+  integrity sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==
+  dependencies:
+    minimist "^1.2.0"
+
+jsprim@^1.2.2:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2"
+  integrity sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=
+  dependencies:
+    assert-plus "1.0.0"
+    extsprintf "1.3.0"
+    json-schema "0.2.3"
+    verror "1.10.0"
+
+kareem@2.3.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/kareem/-/kareem-2.3.0.tgz#ef33c42e9024dce511eeaf440cd684f3af1fc769"
+  integrity sha512-6hHxsp9e6zQU8nXsP+02HGWXwTkOEw6IROhF2ZA28cYbUk4eJ6QbtZvdqZOdD9YPKghG3apk5eOCvs+tLl3lRg==
+
+levn@~0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee"
+  integrity sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=
+  dependencies:
+    prelude-ls "~1.1.2"
+    type-check "~0.3.2"
+
+lodash@^4.17.11, lodash@^4.17.14:
+  version "4.17.15"
+  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
+  integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
+
+long@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28"
+  integrity sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==
+
+lru-cache@^4.1.2:
+  version "4.1.5"
+  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd"
+  integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==
+  dependencies:
+    pseudomap "^1.0.2"
+    yallist "^2.1.2"
+
+mailgun-js@^0.22.0:
+  version "0.22.0"
+  resolved "https://registry.yarnpkg.com/mailgun-js/-/mailgun-js-0.22.0.tgz#128942b5e47a364a470791608852bf68c96b3a05"
+  integrity sha512-a2alg5nuTZA9Psa1pSEIEsbxr1Zrmqx4VkgGCQ30xVh0kIH7Bu57AYILo+0v8QLSdXtCyLaS+KVmdCrQo0uWFA==
+  dependencies:
+    async "^2.6.1"
+    debug "^4.1.0"
+    form-data "^2.3.3"
+    inflection "~1.12.0"
+    is-stream "^1.1.0"
+    path-proxy "~1.0.0"
+    promisify-call "^2.0.2"
+    proxy-agent "^3.0.3"
+    tsscmp "^1.0.6"
+
+media-typer@0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
+  integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=
+
+memory-pager@^1.0.2:
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/memory-pager/-/memory-pager-1.5.0.tgz#d8751655d22d384682741c972f2c3d6dfa3e66b5"
+  integrity sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==
+
+merge-descriptors@1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
+  integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=
+
+methods@~1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
+  integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=
+
+mime-db@1.40.0:
+  version "1.40.0"
+  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.40.0.tgz#a65057e998db090f732a68f6c276d387d4126c32"
+  integrity sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==
+
+mime-types@^2.1.12, mime-types@~2.1.19, mime-types@~2.1.24:
+  version "2.1.24"
+  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.24.tgz#b6f8d0b3e951efb77dedeca194cff6d16f676f81"
+  integrity sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==
+  dependencies:
+    mime-db "1.40.0"
+
+mime@1.6.0:
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
+  integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
+
+minimatch@^3.0.4:
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
+  integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
+  dependencies:
+    brace-expansion "^1.1.7"
+
+minimist@0.0.8:
+  version "0.0.8"
+  resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
+  integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=
+
+minimist@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284"
+  integrity sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=
+
+minipass@^2.2.1, minipass@^2.3.5:
+  version "2.3.5"
+  resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.3.5.tgz#cacebe492022497f656b0f0f51e2682a9ed2d848"
+  integrity sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA==
+  dependencies:
+    safe-buffer "^5.1.2"
+    yallist "^3.0.0"
+
+minizlib@^1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.2.1.tgz#dd27ea6136243c7c880684e8672bb3a45fd9b614"
+  integrity sha512-7+4oTUOWKg7AuL3vloEWekXY2/D20cevzsrNT2kGWm+39J9hGTCBv8VI5Pm5lXZ/o3/mdR4f8rflAPhnQb8mPA==
+  dependencies:
+    minipass "^2.2.1"
+
+mkdirp@^0.5.0, mkdirp@^0.5.1:
+  version "0.5.1"
+  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903"
+  integrity sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=
+  dependencies:
+    minimist "0.0.8"
+
+moment@^2.24.0:
+  version "2.24.0"
+  resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b"
+  integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==
+
+mongodb-core@3.2.7:
+  version "3.2.7"
+  resolved "https://registry.yarnpkg.com/mongodb-core/-/mongodb-core-3.2.7.tgz#a8ef1fe764a192c979252dacbc600dc88d77e28f"
+  integrity sha512-WypKdLxFNPOH/Jy6i9z47IjG2wIldA54iDZBmHMINcgKOUcWJh8og+Wix76oGd7EyYkHJKssQ2FAOw5Su/n4XQ==
+  dependencies:
+    bson "^1.1.1"
+    require_optional "^1.0.1"
+    safe-buffer "^5.1.2"
+  optionalDependencies:
+    saslprep "^1.0.0"
+
+mongodb@3.2.7:
+  version "3.2.7"
+  resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-3.2.7.tgz#8ba149e4be708257cad0dea72aebb2bbb311a7ac"
+  integrity sha512-2YdWrdf1PJgxcCrT1tWoL6nHuk6hCxhddAAaEh8QJL231ci4+P9FLyqopbTm2Z2sAU6mhCri+wd9r1hOcHdoMw==
+  dependencies:
+    mongodb-core "3.2.7"
+    safe-buffer "^5.1.2"
+
+mongoose-legacy-pluralize@1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/mongoose-legacy-pluralize/-/mongoose-legacy-pluralize-1.0.2.tgz#3ba9f91fa507b5186d399fb40854bff18fb563e4"
+  integrity sha512-Yo/7qQU4/EyIS8YDFSeenIvXxZN+ld7YdV9LqFVQJzTLye8unujAWPZ4NWKfFA+RNjh+wvTWKY9Z3E5XM6ZZiQ==
+
+mongoose@^5.6.4:
+  version "5.6.5"
+  resolved "https://registry.yarnpkg.com/mongoose/-/mongoose-5.6.5.tgz#fdddf9a3c588a670b81b23bec203440f5ddf2876"
+  integrity sha512-c8bIo8mxbf1ybwo9jgPKcJRICQBlIMKwDWt2A+M7h0AutroQ5EqzRAYOK1vrHwwwq00EcJyVwjVBW2wv8E9Wfw==
+  dependencies:
+    async "2.6.2"
+    bson "~1.1.1"
+    kareem "2.3.0"
+    mongodb "3.2.7"
+    mongodb-core "3.2.7"
+    mongoose-legacy-pluralize "1.0.2"
+    mpath "0.6.0"
+    mquery "3.2.1"
+    ms "2.1.2"
+    regexp-clone "1.0.0"
+    safe-buffer "5.1.2"
+    sift "7.0.1"
+    sliced "1.0.1"
+
+mpath@0.6.0:
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/mpath/-/mpath-0.6.0.tgz#aa922029fca4f0f641f360e74c5c1b6a4c47078e"
+  integrity sha512-i75qh79MJ5Xo/sbhxrDrPSEG0H/mr1kcZXJ8dH6URU5jD/knFxCVqVC/gVSW7GIXL/9hHWlT9haLbCXWOll3qw==
+
+mquery@3.2.1:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/mquery/-/mquery-3.2.1.tgz#8b059a49cdae0a8a9e804284ef64c2f58d3ac05d"
+  integrity sha512-kY/K8QToZWTTocm0U+r8rqcJCp5PRl6e8tPmoDs5OeSO3DInZE2rAL6AYH+V406JTo8305LdASOQcxRDqHojyw==
+  dependencies:
+    bluebird "3.5.1"
+    debug "3.1.0"
+    regexp-clone "^1.0.0"
+    safe-buffer "5.1.2"
+    sliced "1.0.1"
+
+ms@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
+  integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=
+
+ms@2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a"
+  integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==
+
+ms@2.1.2, ms@^2.1.1:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
+  integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
+
+nan@2.13.2:
+  version "2.13.2"
+  resolved "https://registry.yarnpkg.com/nan/-/nan-2.13.2.tgz#f51dc7ae66ba7d5d55e1e6d4d8092e802c9aefe7"
+  integrity sha512-TghvYc72wlMGMVMluVo9WRJc0mB8KxxF/gZ4YYFy7V2ZQX9l7rgbPg7vjS9mt6U5HXODVFVI2bOduCzwOMv/lw==
+
+needle@^2.2.1:
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/needle/-/needle-2.4.0.tgz#6833e74975c444642590e15a750288c5f939b57c"
+  integrity sha512-4Hnwzr3mi5L97hMYeNl8wRW/Onhy4nUKR/lVemJ8gJedxxUyBLm9kkrDColJvoSfwi0jCNhD+xCdOtiGDQiRZg==
+  dependencies:
+    debug "^3.2.6"
+    iconv-lite "^0.4.4"
+    sax "^1.2.4"
+
+negotiator@0.6.2:
+  version "0.6.2"
+  resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
+  integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==
+
+netmask@^1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/netmask/-/netmask-1.0.6.tgz#20297e89d86f6f6400f250d9f4f6b4c1945fcd35"
+  integrity sha1-ICl+idhvb2QA8lDZ9Pa0wZRfzTU=
+
+node-pre-gyp@0.12.0:
+  version "0.12.0"
+  resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.12.0.tgz#39ba4bb1439da030295f899e3b520b7785766149"
+  integrity sha512-4KghwV8vH5k+g2ylT+sLTjy5wmUOb9vPhnM8NHvRf9dHmnW/CndrFXy2aRPaPST6dugXSdHXfeaHQm77PIz/1A==
+  dependencies:
+    detect-libc "^1.0.2"
+    mkdirp "^0.5.1"
+    needle "^2.2.1"
+    nopt "^4.0.1"
+    npm-packlist "^1.1.6"
+    npmlog "^4.0.2"
+    rc "^1.2.7"
+    rimraf "^2.6.1"
+    semver "^5.3.0"
+    tar "^4"
+
+nopt@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d"
+  integrity sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=
+  dependencies:
+    abbrev "1"
+    osenv "^0.1.4"
+
+npm-bundled@^1.0.1:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.6.tgz#e7ba9aadcef962bb61248f91721cd932b3fe6bdd"
+  integrity sha512-8/JCaftHwbd//k6y2rEWp6k1wxVfpFzB6t1p825+cUb7Ym2XQfhwIC5KwhrvzZRJu+LtDE585zVaS32+CGtf0g==
+
+npm-packlist@^1.1.6:
+  version "1.4.4"
+  resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.4.4.tgz#866224233850ac534b63d1a6e76050092b5d2f44"
+  integrity sha512-zTLo8UcVYtDU3gdeaFu2Xu0n0EvelfHDGuqtNIn5RO7yQj4H1TqNdBc/yZjxnWA0PVB8D3Woyp0i5B43JwQ6Vw==
+  dependencies:
+    ignore-walk "^3.0.1"
+    npm-bundled "^1.0.1"
+
+npmlog@^4.0.2:
+  version "4.1.2"
+  resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b"
+  integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==
+  dependencies:
+    are-we-there-yet "~1.1.2"
+    console-control-strings "~1.1.0"
+    gauge "~2.7.3"
+    set-blocking "~2.0.0"
+
+number-is-nan@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
+  integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=
+
+oauth-sign@~0.9.0:
+  version "0.9.0"
+  resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455"
+  integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==
+
+oauth@^0.9.15:
+  version "0.9.15"
+  resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.9.15.tgz#bd1fefaf686c96b75475aed5196412ff60cfb9c1"
+  integrity sha1-vR/vr2hslrdUda7VGWQS/2DPucE=
+
+object-assign@^4, object-assign@^4.1.0:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
+  integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
+
+object-component@0.0.3:
+  version "0.0.3"
+  resolved "https://registry.yarnpkg.com/object-component/-/object-component-0.0.3.tgz#f0c69aa50efc95b866c186f400a33769cb2f1291"
+  integrity sha1-8MaapQ78lbhmwYb0AKM3acsvEpE=
+
+on-finished@~2.3.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
+  integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=
+  dependencies:
+    ee-first "1.1.1"
+
+once@^1.3.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
+  integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
+  dependencies:
+    wrappy "1"
+
+optionator@^0.8.1:
+  version "0.8.2"
+  resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.2.tgz#364c5e409d3f4d6301d6c0b4c05bba50180aeb64"
+  integrity sha1-NkxeQJ0/TWMB1sC0wFu6UBgK62Q=
+  dependencies:
+    deep-is "~0.1.3"
+    fast-levenshtein "~2.0.4"
+    levn "~0.3.0"
+    prelude-ls "~1.1.2"
+    type-check "~0.3.2"
+    wordwrap "~1.0.0"
+
+os-homedir@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3"
+  integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M=
+
+os-tmpdir@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
+  integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=
+
+osenv@^0.1.4:
+  version "0.1.5"
+  resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410"
+  integrity sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==
+  dependencies:
+    os-homedir "^1.0.0"
+    os-tmpdir "^1.0.0"
+
+pac-proxy-agent@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/pac-proxy-agent/-/pac-proxy-agent-3.0.0.tgz#11d578b72a164ad74bf9d5bac9ff462a38282432"
+  integrity sha512-AOUX9jES/EkQX2zRz0AW7lSx9jD//hQS8wFXBvcnd/J2Py9KaMJMqV/LPqJssj1tgGufotb2mmopGPR15ODv1Q==
+  dependencies:
+    agent-base "^4.2.0"
+    debug "^3.1.0"
+    get-uri "^2.0.0"
+    http-proxy-agent "^2.1.0"
+    https-proxy-agent "^2.2.1"
+    pac-resolver "^3.0.0"
+    raw-body "^2.2.0"
+    socks-proxy-agent "^4.0.1"
+
+pac-resolver@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/pac-resolver/-/pac-resolver-3.0.0.tgz#6aea30787db0a891704deb7800a722a7615a6f26"
+  integrity sha512-tcc38bsjuE3XZ5+4vP96OfhOugrX+JcnpUbhfuc4LuXBLQhoTthOstZeoQJBDnQUDYzYmdImKsbz0xSl1/9qeA==
+  dependencies:
+    co "^4.6.0"
+    degenerator "^1.0.4"
+    ip "^1.1.5"
+    netmask "^1.0.6"
+    thunkify "^2.1.2"
+
+parseqs@0.0.5:
+  version "0.0.5"
+  resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.5.tgz#d5208a3738e46766e291ba2ea173684921a8b89d"
+  integrity sha1-1SCKNzjkZ2bikbouoXNoSSGouJ0=
+  dependencies:
+    better-assert "~1.0.0"
+
+parseuri@0.0.5:
+  version "0.0.5"
+  resolved "https://registry.yarnpkg.com/parseuri/-/parseuri-0.0.5.tgz#80204a50d4dbb779bfdc6ebe2778d90e4bce320a"
+  integrity sha1-gCBKUNTbt3m/3G6+J3jZDkvOMgo=
+  dependencies:
+    better-assert "~1.0.0"
+
+parseurl@~1.3.3:
+  version "1.3.3"
+  resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
+  integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
+
+path-is-absolute@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
+  integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18=
+
+path-proxy@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/path-proxy/-/path-proxy-1.0.0.tgz#18e8a36859fc9d2f1a53b48dee138543c020de5e"
+  integrity sha1-GOijaFn8nS8aU7SN7hOFQ8Ag3l4=
+  dependencies:
+    inflection "~1.3.0"
+
+path-to-regexp@0.1.7:
+  version "0.1.7"
+  resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c"
+  integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=
+
+performance-now@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
+  integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=
+
+prelude-ls@~1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
+  integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=
+
+prism-media@^0.0.3:
+  version "0.0.3"
+  resolved "https://registry.yarnpkg.com/prism-media/-/prism-media-0.0.3.tgz#8842d4fae804f099d3b48a9a38e3c2bab6f4855b"
+  integrity sha512-c9KkNifSMU/iXT8FFTaBwBMr+rdVcN+H/uNv1o+CuFeTThNZNTOrQ+RgXA1yL/DeLk098duAeRPP3QNPNbhxYQ==
+
+process-nextick-args@~2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
+  integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
+
+promisify-call@^2.0.2:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/promisify-call/-/promisify-call-2.0.4.tgz#d48c2d45652ccccd52801ddecbd533a6d4bd5fba"
+  integrity sha1-1IwtRWUszM1SgB3ey9UzptS9X7o=
+  dependencies:
+    with-callback "^1.0.2"
+
+proxy-addr@~2.0.5:
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.5.tgz#34cbd64a2d81f4b1fd21e76f9f06c8a45299ee34"
+  integrity sha512-t/7RxHXPH6cJtP0pRG6smSr9QJidhB+3kXu0KgXnbGYMgzEnUxRQ4/LDdfOwZEMyIh3/xHb8PX3t+lfL9z+YVQ==
+  dependencies:
+    forwarded "~0.1.2"
+    ipaddr.js "1.9.0"
+
+proxy-agent@^3.0.3:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/proxy-agent/-/proxy-agent-3.1.0.tgz#3cf86ee911c94874de4359f37efd9de25157c113"
+  integrity sha512-IkbZL4ClW3wwBL/ABFD2zJ8iP84CY0uKMvBPk/OceQe/cEjrxzN1pMHsLwhbzUoRhG9QbSxYC+Z7LBkTiBNvrA==
+  dependencies:
+    agent-base "^4.2.0"
+    debug "^3.1.0"
+    http-proxy-agent "^2.1.0"
+    https-proxy-agent "^2.2.1"
+    lru-cache "^4.1.2"
+    pac-proxy-agent "^3.0.0"
+    proxy-from-env "^1.0.0"
+    socks-proxy-agent "^4.0.1"
+
+proxy-from-env@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.0.0.tgz#33c50398f70ea7eb96d21f7b817630a55791c7ee"
+  integrity sha1-M8UDmPcOp+uW0h97gXYwpVeRx+4=
+
+pseudomap@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
+  integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM=
+
+psl@^1.1.24:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/psl/-/psl-1.2.0.tgz#df12b5b1b3a30f51c329eacbdef98f3a6e136dc6"
+  integrity sha512-GEn74ZffufCmkDDLNcl3uuyF/aSD6exEyh1v/ZSdAomB82t6G9hzJVRx0jBmLDW+VfZqks3aScmMw9DszwUalA==
+
+punycode@^1.4.1:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e"
+  integrity sha1-wNWmOycYgArY4esPpSachN1BhF4=
+
+punycode@^2.1.0:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
+  integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
+
+qs@6.7.0:
+  version "6.7.0"
+  resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"
+  integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==
+
+qs@~6.5.2:
+  version "6.5.2"
+  resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
+  integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
+
+range-parser@~1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
+  integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
+
+raw-body@2.4.0:
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.0.tgz#a1ce6fb9c9bc356ca52e89256ab59059e13d0332"
+  integrity sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==
+  dependencies:
+    bytes "3.1.0"
+    http-errors "1.7.2"
+    iconv-lite "0.4.24"
+    unpipe "1.0.0"
+
+raw-body@^2.2.0:
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.1.tgz#30ac82f98bb5ae8c152e67149dac8d55153b168c"
+  integrity sha512-9WmIKF6mkvA0SLmA2Knm9+qj89e+j1zqgyn8aXGd7+nAduPoqgI9lO57SAZNn/Byzo5P7JhXTyg9PzaJbH73bA==
+  dependencies:
+    bytes "3.1.0"
+    http-errors "1.7.3"
+    iconv-lite "0.4.24"
+    unpipe "1.0.0"
+
+rc@^1.2.7:
+  version "1.2.8"
+  resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
+  integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==
+  dependencies:
+    deep-extend "^0.6.0"
+    ini "~1.3.0"
+    minimist "^1.2.0"
+    strip-json-comments "~2.0.1"
+
+readable-stream@1.1.x:
+  version "1.1.14"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9"
+  integrity sha1-fPTFTvZI44EwhMY23SB54WbAgdk=
+  dependencies:
+    core-util-is "~1.0.0"
+    inherits "~2.0.1"
+    isarray "0.0.1"
+    string_decoder "~0.10.x"
+
+readable-stream@3:
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.4.0.tgz#a51c26754658e0a3c21dbf59163bd45ba6f447fc"
+  integrity sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==
+  dependencies:
+    inherits "^2.0.3"
+    string_decoder "^1.1.1"
+    util-deprecate "^1.0.1"
+
+readable-stream@^2.0.6:
+  version "2.3.6"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf"
+  integrity sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==
+  dependencies:
+    core-util-is "~1.0.0"
+    inherits "~2.0.3"
+    isarray "~1.0.0"
+    process-nextick-args "~2.0.0"
+    safe-buffer "~5.1.1"
+    string_decoder "~1.1.1"
+    util-deprecate "~1.0.1"
+
+redis-commands@^1.2.0:
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.5.0.tgz#80d2e20698fe688f227127ff9e5164a7dd17e785"
+  integrity sha512-6KxamqpZ468MeQC3bkWmCB1fp56XL64D4Kf0zJSwDZbVLLm7KFkoIcHrgRvQ+sk8dnhySs7+yBg94yIkAK7aJg==
+
+redis-parser@^2.6.0:
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-2.6.0.tgz#52ed09dacac108f1a631c07e9b69941e7a19504b"
+  integrity sha1-Uu0J2srBCPGmMcB+m2mUHnoZUEs=
+
+redis@^2.8.0:
+  version "2.8.0"
+  resolved "https://registry.yarnpkg.com/redis/-/redis-2.8.0.tgz#202288e3f58c49f6079d97af7a10e1303ae14b02"
+  integrity sha512-M1OkonEQwtRmZv4tEWF2VgpG0JWJ8Fv1PhlgT5+B+uNq2cA3Rt1Yt/ryoR+vQNOQcIEgdCdfH0jr3bDpihAw1A==
+  dependencies:
+    double-ended-queue "^2.1.0-0"
+    redis-commands "^1.2.0"
+    redis-parser "^2.6.0"
+
+regexp-clone@1.0.0, regexp-clone@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/regexp-clone/-/regexp-clone-1.0.0.tgz#222db967623277056260b992626354a04ce9bf63"
+  integrity sha512-TuAasHQNamyyJ2hb97IuBEif4qBHGjPHBS64sZwytpLEqtBQ1gPJTnOaQ6qmpET16cK14kkjbazl6+p0RRv0yw==
+
+request@^2.88.0:
+  version "2.88.0"
+  resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef"
+  integrity sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==
+  dependencies:
+    aws-sign2 "~0.7.0"
+    aws4 "^1.8.0"
+    caseless "~0.12.0"
+    combined-stream "~1.0.6"
+    extend "~3.0.2"
+    forever-agent "~0.6.1"
+    form-data "~2.3.2"
+    har-validator "~5.1.0"
+    http-signature "~1.2.0"
+    is-typedarray "~1.0.0"
+    isstream "~0.1.2"
+    json-stringify-safe "~5.0.1"
+    mime-types "~2.1.19"
+    oauth-sign "~0.9.0"
+    performance-now "^2.1.0"
+    qs "~6.5.2"
+    safe-buffer "^5.1.2"
+    tough-cookie "~2.4.3"
+    tunnel-agent "^0.6.0"
+    uuid "^3.3.2"
+
+require_optional@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/require_optional/-/require_optional-1.0.1.tgz#4cf35a4247f64ca3df8c2ef208cc494b1ca8fc2e"
+  integrity sha512-qhM/y57enGWHAe3v/NcwML6a3/vfESLe/sGM2dII+gEO0BpKRUkWZow/tyloNqJyN6kXSl3RyyM8Ll5D/sJP8g==
+  dependencies:
+    resolve-from "^2.0.0"
+    semver "^5.1.0"
+
+resolve-from@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-2.0.0.tgz#9480ab20e94ffa1d9e80a804c7ea147611966b57"
+  integrity sha1-lICrIOlP+h2egKgEx+oUdhGWa1c=
+
+rimraf@^2.6.1:
+  version "2.6.3"
+  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab"
+  integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==
+  dependencies:
+    glob "^7.1.3"
+
+safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
+  version "5.1.2"
+  resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
+  integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
+
+safe-buffer@^5.0.1, safe-buffer@^5.1.2:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519"
+  integrity sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==
+
+"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
+  integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
+
+saslprep@^1.0.0:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/saslprep/-/saslprep-1.0.3.tgz#4c02f946b56cf54297e347ba1093e7acac4cf226"
+  integrity sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==
+  dependencies:
+    sparse-bitfield "^3.0.3"
+
+sax@^1.2.4:
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
+  integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
+
+semver@^5.1.0, semver@^5.3.0:
+  version "5.7.0"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.0.tgz#790a7cf6fea5459bac96110b29b60412dc8ff96b"
+  integrity sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==
+
+send@0.17.1:
+  version "0.17.1"
+  resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8"
+  integrity sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==
+  dependencies:
+    debug "2.6.9"
+    depd "~1.1.2"
+    destroy "~1.0.4"
+    encodeurl "~1.0.2"
+    escape-html "~1.0.3"
+    etag "~1.8.1"
+    fresh "0.5.2"
+    http-errors "~1.7.2"
+    mime "1.6.0"
+    ms "2.1.1"
+    on-finished "~2.3.0"
+    range-parser "~1.2.1"
+    statuses "~1.5.0"
+
+serve-static@1.14.1:
+  version "1.14.1"
+  resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.14.1.tgz#666e636dc4f010f7ef29970a88a674320898b2f9"
+  integrity sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==
+  dependencies:
+    encodeurl "~1.0.2"
+    escape-html "~1.0.3"
+    parseurl "~1.3.3"
+    send "0.17.1"
+
+set-blocking@~2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
+  integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc=
+
+setprototypeof@1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683"
+  integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==
+
+sha256@^0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/sha256/-/sha256-0.2.0.tgz#73a0b418daab7035bff86e8491e363412fc2ab05"
+  integrity sha1-c6C0GNqrcDW/+G6EkeNjQS/CqwU=
+  dependencies:
+    convert-hex "~0.1.0"
+    convert-string "~0.1.0"
+
+sift@7.0.1:
+  version "7.0.1"
+  resolved "https://registry.yarnpkg.com/sift/-/sift-7.0.1.tgz#47d62c50b159d316f1372f8b53f9c10cd21a4b08"
+  integrity sha512-oqD7PMJ+uO6jV9EQCl0LrRw1OwsiPsiFQR5AR30heR+4Dl7jBBbDLnNvWiak20tzZlSE1H7RB30SX/1j/YYT7g==
+
+signal-exit@^3.0.0:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"
+  integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=
+
+sliced@1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/sliced/-/sliced-1.0.1.tgz#0b3a662b5d04c3177b1926bea82b03f837a2ef41"
+  integrity sha1-CzpmK10Ewxd7GSa+qCsD+Dei70E=
+
+smart-buffer@4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.0.2.tgz#5207858c3815cc69110703c6b94e46c15634395d"
+  integrity sha512-JDhEpTKzXusOqXZ0BUIdH+CjFdO/CR3tLlf5CN34IypI+xMmXW1uB16OOY8z3cICbJlDAVJzNbwBhNO0wt9OAw==
+
+snekfetch@^3.6.4:
+  version "3.6.4"
+  resolved "https://registry.yarnpkg.com/snekfetch/-/snekfetch-3.6.4.tgz#d13e80a616d892f3d38daae4289f4d258a645120"
+  integrity sha512-NjxjITIj04Ffqid5lqr7XdgwM7X61c/Dns073Ly170bPQHLm6jkmelye/eglS++1nfTWktpP6Y2bFXjdPlQqdw==
+
+socket.io-adapter@~1.1.0:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-1.1.1.tgz#2a805e8a14d6372124dd9159ad4502f8cb07f06b"
+  integrity sha1-KoBeihTWNyEk3ZFZrUUC+MsH8Gs=
+
+socket.io-client@2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.2.0.tgz#84e73ee3c43d5020ccc1a258faeeb9aec2723af7"
+  integrity sha512-56ZrkTDbdTLmBIyfFYesgOxsjcLnwAKoN4CiPyTVkMQj3zTUh0QAx3GbvIvLpFEOvQWu92yyWICxB0u7wkVbYA==
+  dependencies:
+    backo2 "1.0.2"
+    base64-arraybuffer "0.1.5"
+    component-bind "1.0.0"
+    component-emitter "1.2.1"
+    debug "~3.1.0"
+    engine.io-client "~3.3.1"
+    has-binary2 "~1.0.2"
+    has-cors "1.1.0"
+    indexof "0.0.1"
+    object-component "0.0.3"
+    parseqs "0.0.5"
+    parseuri "0.0.5"
+    socket.io-parser "~3.3.0"
+    to-array "0.1.4"
+
+socket.io-parser@~3.3.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.3.0.tgz#2b52a96a509fdf31440ba40fed6094c7d4f1262f"
+  integrity sha512-hczmV6bDgdaEbVqhAeVMM/jfUfzuEZHsQg6eOmLgJht6G3mPKMxYm75w2+qhAQZ+4X+1+ATZ+QFKeOZD5riHng==
+  dependencies:
+    component-emitter "1.2.1"
+    debug "~3.1.0"
+    isarray "2.0.1"
+
+socket.io@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.2.0.tgz#f0f633161ef6712c972b307598ecd08c9b1b4d5b"
+  integrity sha512-wxXrIuZ8AILcn+f1B4ez4hJTPG24iNgxBBDaJfT6MsyOhVYiTXWexGoPkd87ktJG8kQEcL/NBvRi64+9k4Kc0w==
+  dependencies:
+    debug "~4.1.0"
+    engine.io "~3.3.1"
+    has-binary2 "~1.0.2"
+    socket.io-adapter "~1.1.0"
+    socket.io-client "2.2.0"
+    socket.io-parser "~3.3.0"
+
+socks-proxy-agent@^4.0.1:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-4.0.2.tgz#3c8991f3145b2799e70e11bd5fbc8b1963116386"
+  integrity sha512-NT6syHhI9LmuEMSK6Kd2V7gNv5KFZoLE7V5udWmn0de+3Mkj3UMA/AJPLyeNUVmElCurSHtUdM3ETpR3z770Wg==
+  dependencies:
+    agent-base "~4.2.1"
+    socks "~2.3.2"
+
+socks@~2.3.2:
+  version "2.3.2"
+  resolved "https://registry.yarnpkg.com/socks/-/socks-2.3.2.tgz#ade388e9e6d87fdb11649c15746c578922a5883e"
+  integrity sha512-pCpjxQgOByDHLlNqlnh/mNSAxIUkyBBuwwhTcV+enZGbDaClPvHdvm6uvOwZfFJkam7cGhBNbb4JxiP8UZkRvQ==
+  dependencies:
+    ip "^1.1.5"
+    smart-buffer "4.0.2"
+
+source-map@~0.6.1:
+  version "0.6.1"
+  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
+  integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
+
+sparse-bitfield@^3.0.3:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz#ff4ae6e68656056ba4b3e792ab3334d38273ca11"
+  integrity sha1-/0rm5oZWBWuks+eSqzM004JzyhE=
+  dependencies:
+    memory-pager "^1.0.2"
+
+sshpk@^1.7.0:
+  version "1.16.1"
+  resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877"
+  integrity sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==
+  dependencies:
+    asn1 "~0.2.3"
+    assert-plus "^1.0.0"
+    bcrypt-pbkdf "^1.0.0"
+    dashdash "^1.12.0"
+    ecc-jsbn "~0.1.1"
+    getpass "^0.1.1"
+    jsbn "~0.1.0"
+    safer-buffer "^2.0.2"
+    tweetnacl "~0.14.0"
+
+"statuses@>= 1.5.0 < 2", statuses@~1.5.0:
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
+  integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=
+
+string-width@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3"
+  integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=
+  dependencies:
+    code-point-at "^1.0.0"
+    is-fullwidth-code-point "^1.0.0"
+    strip-ansi "^3.0.0"
+
+"string-width@^1.0.2 || 2":
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e"
+  integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==
+  dependencies:
+    is-fullwidth-code-point "^2.0.0"
+    strip-ansi "^4.0.0"
+
+string_decoder@^1.1.1:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.2.0.tgz#fe86e738b19544afe70469243b2a1ee9240eae8d"
+  integrity sha512-6YqyX6ZWEYguAxgZzHGL7SsCeGx3V2TtOTqZz1xSTSWnqsbWwbptafNyvf/ACquZUXV3DANr5BDIwNYe1mN42w==
+  dependencies:
+    safe-buffer "~5.1.0"
+
+string_decoder@~0.10.x:
+  version "0.10.31"
+  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94"
+  integrity sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=
+
+string_decoder@~1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"
+  integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==
+  dependencies:
+    safe-buffer "~5.1.0"
+
+strip-ansi@^3.0.0, strip-ansi@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf"
+  integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=
+  dependencies:
+    ansi-regex "^2.0.0"
+
+strip-ansi@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f"
+  integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8=
+  dependencies:
+    ansi-regex "^3.0.0"
+
+strip-json-comments@~2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
+  integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo=
+
+tar@^4:
+  version "4.4.10"
+  resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.10.tgz#946b2810b9a5e0b26140cf78bea6b0b0d689eba1"
+  integrity sha512-g2SVs5QIxvo6OLp0GudTqEf05maawKUxXru104iaayWA09551tFCTI8f1Asb4lPfkBr91k07iL4c11XO3/b0tA==
+  dependencies:
+    chownr "^1.1.1"
+    fs-minipass "^1.2.5"
+    minipass "^2.3.5"
+    minizlib "^1.2.1"
+    mkdirp "^0.5.0"
+    safe-buffer "^5.1.2"
+    yallist "^3.0.3"
+
+thunkify@^2.1.2:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/thunkify/-/thunkify-2.1.2.tgz#faa0e9d230c51acc95ca13a361ac05ca7e04553d"
+  integrity sha1-+qDp0jDFGsyVyhOjYawFyn4EVT0=
+
+to-array@0.1.4:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/to-array/-/to-array-0.1.4.tgz#17e6c11f73dd4f3d74cda7a4ff3238e9ad9bf890"
+  integrity sha1-F+bBH3PdTz10zaek/zI46a2b+JA=
+
+toidentifier@1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
+  integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
+
+tough-cookie@~2.4.3:
+  version "2.4.3"
+  resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781"
+  integrity sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ==
+  dependencies:
+    psl "^1.1.24"
+    punycode "^1.4.1"
+
+tsscmp@^1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb"
+  integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==
+
+tunnel-agent@^0.6.0:
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"
+  integrity sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=
+  dependencies:
+    safe-buffer "^5.0.1"
+
+tweetnacl@^0.14.3, tweetnacl@~0.14.0:
+  version "0.14.5"
+  resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
+  integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=
+
+tweetnacl@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-1.0.1.tgz#2594d42da73cd036bd0d2a54683dd35a6b55ca17"
+  integrity sha512-kcoMoKTPYnoeS50tzoqjPY3Uv9axeuuFAZY9M/9zFnhoVvRfxz9K29IMPD7jGmt2c8SW7i3gT9WqDl2+nV7p4A==
+
+type-check@~0.3.2:
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72"
+  integrity sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=
+  dependencies:
+    prelude-ls "~1.1.2"
+
+type-is@~1.6.17, type-is@~1.6.18:
+  version "1.6.18"
+  resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
+  integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
+  dependencies:
+    media-typer "0.3.0"
+    mime-types "~2.1.24"
+
+underscore@^1.9.1:
+  version "1.9.1"
+  resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.9.1.tgz#06dce34a0e68a7babc29b365b8e74b8925203961"
+  integrity sha512-5/4etnCkd9c8gwgowi5/om/mYO5ajCaOgdzj/oW+0eQV9WxKBDZw5+ycmKmeaTXjInS/W0BzpGLo2xR2aBwZdg==
+
+unpipe@1.0.0, unpipe@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
+  integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=
+
+uri-js@^4.2.2:
+  version "4.2.2"
+  resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0"
+  integrity sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==
+  dependencies:
+    punycode "^2.1.0"
+
+util-deprecate@^1.0.1, util-deprecate@~1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
+  integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
+
+utils-merge@1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
+  integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
+
+uuid@^3.3.2:
+  version "3.3.2"
+  resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131"
+  integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==
+
+vary@^1, vary@~1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
+  integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=
+
+verror@1.10.0:
+  version "1.10.0"
+  resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400"
+  integrity sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=
+  dependencies:
+    assert-plus "^1.0.0"
+    core-util-is "1.0.2"
+    extsprintf "^1.2.0"
+
+wide-align@^1.1.0:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457"
+  integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==
+  dependencies:
+    string-width "^1.0.2 || 2"
+
+with-callback@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/with-callback/-/with-callback-1.0.2.tgz#a09629b9a920028d721404fb435bdcff5c91bc21"
+  integrity sha1-oJYpuakgAo1yFAT7Q1vc/1yRvCE=
+
+wordwrap@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
+  integrity sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=
+
+wrappy@1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
+  integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
+
+ws@^6.0.0:
+  version "6.2.1"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.1.tgz#442fdf0a47ed64f59b6a5d8ff130f4748ed524fb"
+  integrity sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA==
+  dependencies:
+    async-limiter "~1.0.0"
+
+ws@~6.1.0:
+  version "6.1.4"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-6.1.4.tgz#5b5c8800afab925e94ccb29d153c8d02c1776ef9"
+  integrity sha512-eqZfL+NE/YQc1/ZynhojeV8q+H050oR8AZ2uIev7RU10svA9ZnJUddHcOUZTJLinZ9yEfdA2kSATS2qZK5fhJA==
+  dependencies:
+    async-limiter "~1.0.0"
+
+xmlhttprequest-ssl@~1.5.4:
+  version "1.5.5"
+  resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz#c2876b06168aadc40e57d97e81191ac8f4398b3e"
+  integrity sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4=
+
+xregexp@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/xregexp/-/xregexp-2.0.0.tgz#52a63e56ca0b84a7f3a5f3d61872f126ad7a5943"
+  integrity sha1-UqY+VsoLhKfzpfPWGHLxJq16WUM=
+
+yallist@^2.1.2:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
+  integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=
+
+yallist@^3.0.0, yallist@^3.0.3:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.0.3.tgz#b4b049e314be545e3ce802236d6cd22cd91c3de9"
+  integrity sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==
+
+yeast@0.1.2:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419"
+  integrity sha1-AI4G2AlDIMNy28L47XagymyKxBk=

+ 20 - 5
docker-compose.yml

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

+ 6 - 2
frontend/.babelrc

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

+ 2 - 0
frontend/.eslintignore

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

+ 30 - 8
frontend/.eslintrc

@@ -1,14 +1,36 @@
 {
-	"rules": {
-		"indent": [2, "tab", { "SwitchCase": 1 }]
+	"root": true,
+	"env": {
+		"browser": true,
+		"amd": true,
+		"node": true,
+		"es6": true,
+		"jquery": true
 	},
 	"parserOptions": {
-		"ecmaVersion": 6,
-		"sourceType": "module"
+		"ecmaVersion": 2018,
+		"sourceType": "module",
+		"parser": "babel-eslint"
+	},
+	"extends": [
+		"airbnb-base",
+		"plugin:vue/essential",
+		"plugin:prettier/recommended",
+		"eslint:recommended"
+	],
+	"globals": {
+		"lofig": "writable",
+		"grecaptcha": "readonly",
+		"ga": "readonly",
+		"moment": "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
 	}
 }

+ 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
+}

+ 21 - 0
frontend/.snyk

@@ -0,0 +1,21 @@
+# 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:
+  'npm:vue:20170401':
+    - vue-roaster > vue:
+        reason: temp
+        expires: '2019-09-04T02:07:16.079Z'
+  'npm:vue:20170829':
+    - vue-roaster > vue:
+        reason: temp
+        expires: '2019-09-04T02:07:16.079Z'
+  'npm:vue:20180222':
+    - vue-roaster > vue:
+        reason: temp
+        expires: '2019-09-04T02:07:16.079Z'
+  'npm:vue:20180802':
+    - vue-roaster > vue:
+        reason: temp
+        expires: '2019-09-04T02:07:16.079Z'
+patch: {}

+ 232 - 258
frontend/App.vue

@@ -1,298 +1,272 @@
 <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 />
+			<toast />
+			<what-is-new />
+			<mobile-alert />
+			<login-modal v-if="modals.header.login" />
+			<register-modal v-if="modals.header.register" />
+		</div>
 	</div>
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
-
-	import Banned from './components/pages/Banned.vue';
-	import WhatIsNew from './components/Modals/WhatIsNew.vue';
-	import MobileAlert from './Components/Modals/MobileAlert.vue';
-	import LoginModal from './components/Modals/Login.vue';
-	import RegisterModal from './components/Modals/Register.vue';
-	import auth from './auth';
-	import io from './io';
-	import validation from './validation';
-
-	export default {
-		replace: false,
-		data() {
-			return {
-				banned: false,
-				ban: {},
-				register: {
-					email: '',
-					username: '',
-					password: ''
-				},
-				login: {
-					email: '',
-					password: ''
-				},
-				loggedIn: false,
-				role: '',
-				username: '',
-				userId: '',
-				isRegisterActive: false,
-				isLoginActive: false,
-				serverDomain: '',
-				socketConnected: true,
-				userIdMap: {},
-				currentlyGettingUsernameFrom: {}
-			}
+import { mapState, mapActions } from "vuex";
+
+import { Toast } from "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";
+
+export default {
+	replace: false,
+	data() {
+		return {
+			banned: false,
+			ban: {},
+			loggedIn: false,
+			role: "",
+			username: "",
+			userId: "",
+			serverDomain: "",
+			socketConnected: true
+		};
+	},
+	computed: mapState({
+		modals: state => state.modals.modals,
+		currentlyActive: state => state.modals.currentlyActive
+	}),
+	methods: {
+		logout() {
+			const _this = this;
+			_this.socket.emit("users.logout", result => {
+				if (result.status === "success") {
+					document.cookie =
+						"SID=;expires=Thu, 01 Jan 1970 00:00:01 GMT;";
+					window.location.reload();
+				} else Toast.methods.addToast(result.message, 4000);
+			});
 		},
-		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;
-						});
-					});
-				}
-			}
+		submitOnEnter: (cb, event) => {
+			if (event.which === 13) cb();
 		},
-		ready: function () {
-			let _this = this;
-			if (localStorage.getItem('github_redirect')) {
-			    this.$router.go(localStorage.getItem('github_redirect'));
-			    localStorage.removeItem('github_redirect');
-			}
-			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;
-			});
+		...mapActions("modals", ["closeCurrentModal"])
+	},
+	mounted() {
+		document.onkeydown = ev => {
+			const event = ev || window.event;
+			if (
+				event.keyCode === 27 &&
+				Object.keys(this.currentlyActive).length !== 0
+			)
+				this.closeCurrentModal();
+		};
+
+		const _this = this;
+		if (localStorage.getItem("github_redirect")) {
+			this.$router.go(localStorage.getItem("github_redirect"));
+			localStorage.removeItem("github_redirect");
+		}
+		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;
+		});
+		_this.$router.onReady(() => {
 			if (_this.$route.query.err) {
-				let err = _this.$route.query.err;
-				err = err.replace(new RegExp('<', 'g'), '&lt;').replace(new RegExp('>', 'g'), '&gt;');
+				let { err } = _this.$route.query;
+				err = err
+					.replace(new RegExp("<", "g"), "&lt;")
+					.replace(new RegExp(">", "g"), "&gt;");
+				_this.$router.push({ query: {} });
 				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');
+			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: {} });
+				Toast.methods.addToast(msg, 20000);
 			}
-		},
-		components: { Toast, WhatIsNew, MobileAlert, LoginModal, RegisterModal, Banned }
+		});
+		io.getSocket(true, socket => {
+			socket.on("keep.event:user.session.removed", () => {
+				window.location.reload();
+			});
+		});
+	},
+	components: {
+		Toast,
+		WhatIsNew,
+		MobileAlert,
+		LoginModal,
+		RegisterModal,
+		Banned
 	}
+};
 </script>
 
-<style type='scss'>
-	.center { text-align: center; }
-
-	#toast-container { z-index: 10000 !important; }
-
-	html {
-		overflow: auto !important;
-	}
-
-	.modal-card {
-		margin: 0 !important;
-	}
-
-	.absolute-a {
-		width: 100%;
-		height: 100%;
+<style lang="scss">
+.center {
+	text-align: center;
+}
+
+#toast-container {
+	z-index: 10000 !important;
+}
+
+html {
+	overflow: auto !important;
+}
+
+.modal-card {
+	margin: 0 !important;
+}
+
+.absolute-a {
+	width: 100%;
+	height: 100%;
+	position: absolute;
+	top: 0;
+	left: 0;
+}
+
+.alert {
+	padding: 20px;
+	color: white;
+	background-color: red;
+	position: fixed;
+	top: 50px;
+	right: 50px;
+	font-size: 2em;
+	border-radius: 5px;
+	z-index: 10000000;
+}
+
+.tooltip {
+	position: relative;
+
+	&:after {
 		position: absolute;
-		top: 0;
-		left: 0;
+		min-width: 80px;
+		margin-left: -75%;
+		text-align: center;
+		padding: 7.5px 6px;
+		border-radius: 2px;
+		background-color: #323232;
+		font-size: 0.9em;
+		color: #fff;
+		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;
-
-		&: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;
-		}
-
-		&:hover:after {
-			 opacity: 1;
-			 visibility: visible;
-		}
+.tooltip-top {
+	&:after {
+		bottom: 150%;
 	}
 
-	.tooltip-top {
+	&:hover {
 		&:after {
-			 bottom: 150%;
-		}
-
-		&:hover {
-			&:after { bottom: 120%; }
+			bottom: 120%;
 		}
 	}
+}
 
+.tooltip-bottom {
+	&:after {
+		top: 155%;
+	}
 
-	.tooltip-bottom {
+	&:hover {
 		&:after {
-			 top: 155%;
+			top: 125%;
 		}
+	}
+}
 
-		&:hover {
-			&:after { top: 125%; }
-		}
+.tooltip-left {
+	&:after {
+		bottom: -10px;
+		right: 130%;
+		min-width: 100px;
 	}
 
-	.tooltip-left {
+	&:hover {
 		&:after {
-			 bottom: -10px;
-			 right: 130%;
-			 min-width: 100px;
+			right: 110%;
 		}
+	}
+}
 
-		&:hover {
-			&:after { right: 110%; }
-		}
+.tooltip-right {
+	&:after {
+		bottom: -10px;
+		left: 190%;
+		min-width: 100px;
 	}
 
-	.tooltip-right {
+	&:hover {
 		&:after {
-			 bottom: -10px;
-			 left: 190%;
-			 min-width: 100px;
-		}
-
-		&:hover {
-			 &:after { left: 200%; }
+			left: 200%;
 		}
 	}
-
-	.button:focus, .button:active { border-color: #dbdbdb !important; }
-	.input:focus, .input:active { border-color: #ff4545 !important; }
-	button.delete:focus { background-color: rgba(10, 10, 10, 0.3); }
-
-	.tag { padding-right: 6px !important; }
-
-	.button.is-success { background-color: #00B16A !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;
+}
+
+.button.is-success {
+	background-color: #00b16a !important;
+}
 </style>

+ 11 - 5
frontend/Dockerfile

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

+ 76 - 0
frontend/api/auth.js

@@ -0,0 +1,76 @@
+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", 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", 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 = `SID=${
+								res.SID
+							}; expires=${date.toGMTString()}; ${domain}${secure}path=/`;
+							return resolve({ status: "success" });
+						});
+					}
+
+					return reject(new Error(res.message));
+				});
+			});
+		});
+	}
+};

+ 13 - 13
frontend/auth.js

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

+ 9 - 0
frontend/bootstrap.sh

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

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

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

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 1
frontend/build/vendor/jquery.min.js


+ 19 - 17
frontend/components/404.vue

@@ -1,25 +1,27 @@
 <template>
 	<div class="wrapper">
 		<h3><strong>404</strong>&nbsp;Not Found</h3>
-		<button class="button is-black" @click="$router.go('/')">Back to Home</button>
+		<router-link class="button is-black" to="/">
+			Back to Home
+		</router-link>
 	</div>
 </template>
 
-<style type="scss" scoped>
-	* {
-		margin: 0;
-		padding: 0;
-	}
+<style lang="scss" scoped>
+* {
+	margin: 0;
+	padding: 0;
+}
 
-	.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>

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

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

+ 338 - 198
frontend/components/Admin/News.vue

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

+ 168 - 94
frontend/components/Admin/Punishments.vue

@@ -1,114 +1,188 @@
 <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>
+		<div class="container">
+			<table class="table is-striped">
+				<thead>
+					<tr>
+						<td>Type</td>
+						<td>Value</td>
+						<td>Reason</td>
+						<td>Status</td>
+						<td>Options</td>
+					</tr>
+				</thead>
+				<tbody>
+					<tr
+						v-for="(punishment, index) in sortedPunishments"
+						:key="index"
+					>
+						<td v-if="punishment.type === 'banUserId'">
+							User ID
+						</td>
+						<td v-else>
+							IP Address
+						</td>
+						<td>{{ punishment.value }}</td>
+						<td>{{ punishment.reason }}</td>
+						<td>
+							{{
+								punishment.active &&
+								new Date(punishment.expiresAt).getTime() >
+									Date.now()
+									? "Active"
+									: "Inactive"
+							}}
+						</td>
+						<td>
+							<button
+								class="button is-primary"
+								@click="view(punishment)"
+							>
+								View
+							</button>
+						</td>
+					</tr>
+				</tbody>
+			</table>
+			<div class="card is-fullwidth">
+				<header class="card-header">
+					<p class="card-header-title">
+						Ban an IP
 					</p>
+				</header>
+				<div class="card-content">
+					<div class="content">
+						<label class="label">Expires In</label>
+						<select v-model="ipBan.expiresAt">
+							<option value="1h">
+								1 Hour
+							</option>
+							<option value="12h">
+								12 Hours
+							</option>
+							<option value="1d">
+								1 Day
+							</option>
+							<option value="1w">
+								1 Week
+							</option>
+							<option value="1m">
+								1 Month
+							</option>
+							<option value="3m">
+								3 Months
+							</option>
+							<option value="6m">
+								6 Months
+							</option>
+							<option value="1y">
+								1 Year
+							</option>
+						</select>
+						<label class="label">IP</label>
+						<p class="control is-expanded">
+							<input
+								v-model="ipBan.ip"
+								class="input"
+								type="text"
+								placeholder="IP address (xxx.xxx.xxx.xxx)"
+							/>
+						</p>
+						<label class="label">Reason</label>
+						<p class="control is-expanded">
+							<input
+								v-model="ipBan.reason"
+								class="input"
+								type="text"
+								placeholder="Reason"
+							/>
+						</p>
+					</div>
 				</div>
+				<footer class="card-footer">
+					<a class="card-footer-item" v-on:click="banIP()" href="#"
+						>Ban IP</a
+					>
+				</footer>
 			</div>
-			<footer class='card-footer'>
-				<a class='card-footer-item' @click='banIP()' href='#'>Ban IP</a>
-			</footer>
 		</div>
+		<view-punishment v-if="modals.viewPunishment" />
 	</div>
-	<view-punishment v-show='modals.viewPunishment'></view-punishment>
 </template>
 
 <script>
-	import ViewPunishment from '../Modals/ViewPunishment.vue';
-	import { Toast } from 'vue-roaster';
-	import io from '../../io';
+import { mapState, mapActions } from "vuex";
+import { Toast } from "vue-roaster";
 
-	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;
+		},
+		...mapState("modals", {
+			modals: state => state.modals.admin
+		})
+	},
+	methods: {
+		view(punishment) {
+			this.viewPunishment(punishment);
+			this.openModal({ sector: "admin", modal: "viewPunishment" });
 		},
-		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 => {
+		banIP() {
+			const _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', () => {});
-			}
+				}
+			);
 		},
-		ready: function () {
-			let _this = this;
-			io.getSocket(socket => {
-				_this.socket = socket;
-				if (_this.socket.connected) _this.init();
-				io.onConnect(() => _this.init());
+		init() {
+			const _this = this;
+			_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() {
+		const _this = this;
+		io.getSocket(socket => {
+			_this.socket = socket;
+			if (_this.socket.connected) _this.init();
+			io.onConnect(() => _this.init());
+		});
 	}
+};
 </script>
 
-<style lang='scss' scoped>
-	body { font-family: 'Roboto', sans-serif; }
+<style lang="scss" scoped>
+body {
+	font-family: "Roboto", sans-serif;
+}
 
-	td { vertical-align: middle; }
-	select { margin-bottom: 10px; }
+td {
+	vertical-align: middle;
+}
+select {
+	margin-bottom: 10px;
+}
 </style>

+ 210 - 124
frontend/components/Admin/QueueSongs.vue

@@ -1,149 +1,235 @@
 <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>
+		<div class="container">
+			<input
+				v-model="searchQuery"
+				type="text"
+				class="input"
+				placeholder="Search for Songs"
+			/>
+			<br />
+			<br />
+			<table class="table is-striped">
+				<thead>
+					<tr>
+						<td>Thumbnail</td>
+						<td>Title</td>
+						<td>ID</td>
+						<td>YouTube ID</td>
+						<td>Artists</td>
+						<td>Genres</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._id }}</td>
+						<td>
+							<a
+								:href="
+									'https://www.youtube.com/watch?v=' +
+										`${song.songId}`
+								"
+								target="_blank"
+							>
+								{{ song.songId }}</a
+							>
+						</td>
+						<td>{{ song.artists.join(", ") }}</td>
+						<td>{{ song.genres.join(", ") }}</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>
+		<nav class="pagination">
+			<a
+				v-if="position > 1"
+				class="button"
+				href="#"
+				@click="getSet(position - 1)"
+			>
+				<i class="material-icons">navigate_before</i>
+			</a>
+			<a
+				v-if="maxPosition > position"
+				class="button"
+				href="#"
+				@click="getSet(position + 1)"
+			>
+				<i class="material-icons">navigate_next</i>
+			</a>
+		</nav>
+		<edit-song v-if="modals.editSong" />
 	</div>
-	<nav class="pagination">
-		<a class="button" href='#' @click='getSet(position - 1)' v-if='position > 1'><i class="material-icons">navigate_before</i></a>
-		<a class="button" href='#' @click='getSet(position + 1)' v-if='maxPosition > position'><i class="material-icons">navigate_next</i></a>
-	</nav>
-	<edit-song v-show='modals.editSong'></edit-song>
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
+import { mapState, mapActions } from "vuex";
 
-	import EditSong from '../Modals/EditSong.vue';
-	import io from '../../io';
+import { Toast } from "vue-roaster";
 
-	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: []
+		};
+	},
+	computed: {
+		filteredSongs() {
+			return this.songs;
+			// return this.songs.filter(song => song.indexOf(song.searchQuery) !== -1);
 		},
-		computed: {
-			filteredSongs: function () {
-				return this.$eval('songs | filterBy searchQuery');
-			}
+		...mapState("modals", {
+			modals: state => state.modals.admin
+		})
+	},
+	// watch: {
+	//   "modals.editSong": function(value) {
+	//     console.log(value);
+	//     if (value === false) this.stopVideo();
+	//   }
+	// },
+	methods: {
+		getSet(position) {
+			const _this = this;
+			this.socket.emit("queueSongs.getSet", position, data => {
+				_this.songs = data;
+				this.position = position;
+			});
 		},
-		watch: {
-			'modals.editSong': function (value) {
-				if (!value) this.$broadcast('stopVideo');
-			}
+		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" });
 		},
-		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);
+		add(song) {
+			this.socket.emit("songs.add", song, res => {
+				if (res.status === "success")
+					Toast.methods.addToast(res.message, 2000);
 				else Toast.methods.addToast(res.message, 4000);
-				});
-			},
-			init: function() {
-				let _this = this;
-				_this.socket.emit('queueSongs.index', data => {
-					_this.songs = data.songs;
-					_this.maxPosition = Math.round(data.maxLength / 50);
-				});
-				_this.socket.emit('apis.joinAdminRoom', 'queue', data => {});
-			}
+			});
 		},
-		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;
-						});
+		remove(id) {
+			this.socket.emit("queueSongs.remove", id, res => {
+				if (res.status === "success")
+					Toast.methods.addToast(res.message, 2000);
+				else Toast.methods.addToast(res.message, 4000);
+			});
+		},
+		init() {
+			const _this = this;
+			_this.socket.emit("queueSongs.index", data => {
+				_this.songs = data.songs;
+				_this.maxPosition = Math.round(data.maxLength / 50);
+			});
+			_this.socket.emit("apis.joinAdminRoom", "queue", () => {});
+		},
+		...mapActions("admin/songs", ["stopVideo", "editSong"]),
+		...mapActions("modals", ["openModal"])
+	},
+	mounted() {
+		const _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(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];
+				});
+				_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) {
 								_this.songs.$set(i, updatedSong);
 							}
 						}
-					});
-				}
-				io.onConnect(() => {
-					_this.init();
-				});
+					}
+				);
+			}
+			io.onConnect(() => {
+				_this.init();
 			});
-		}
+		});
 	}
+};
 </script>
 
-<style lang='scss' scoped>
-	.song-thumbnail {
-		display: block;
-		max-width: 50px;
-		margin: 0 auto;
+<style lang="scss" scoped>
+.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: #029ce3 !important;
+}
 </style>

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

@@ -1,109 +1,142 @@
 <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>
+		<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="(report, index) in reports" :key="index">
+						<td>
+							<span>{{ report.songId }}</span>
+						</td>
+						<td>
+							<user-id-to-username
+								:userId="report.createdBy"
+								:link="true"
+							/>
+						</td>
+						<td>
+							<span>{{ report.createdAt }}</span>
+						</td>
+						<td>
+							<span>{{ report.description }}</span>
+						</td>
+						<td>
+							<a
+								class="button is-warning"
+								href="#"
+								@click="view(report)"
+								>View</a
+							>
+							<a
+								class="button is-primary"
+								href="#"
+								@click="resolve(report._id)"
+								>Resolve</a
+							>
+						</td>
+					</tr>
+				</tbody>
+			</table>
+		</div>
 
-	<issues-modal v-if='modals.report'></issues-modal>
+		<issues-modal v-if="modals.viewReport" />
+	</div>
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
-	import io from '../../io';
+import { mapState, mapActions } from "vuex";
 
-	import IssuesModal from '../Modals/IssuesModal.vue';
+import { Toast } from "vue-roaster";
+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() {
+		const _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;
 				});
-			}
+			});
+			_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
+					Toast.methods.addToast(
+						"Report with that ID not found",
+						3000
+					);
+			});
+		}
+	},
+	computed: {
+		...mapState("modals", {
+			modals: state => state.modals.admin
+		})
+	},
+	methods: {
+		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) {
+			const _this = this;
+			this.socket.emit("reports.resolve", reportId, res => {
+				Toast.methods.addToast(res.message, 3000);
+				if (res.status === "success" && this.modals.viewReport)
+					_this.closeModal({
+						sector: "admin",
+						modal: "viewReport"
 					});
-				});
-				_this.socket.on('event:admin.report.created', report => {
-					_this.reports.push(report);
-				});
-				io.onConnect(() => {
-					_this.init();
-				});
 			});
-			if (this.$route.query.id) {
-				this.socket.emit('reports.findOne', this.$route.query.id, res => {
-					if (res.status === 'success') _this.toggleModal(res.data);
-					else Toast.methods.addToast('Report with that ID not found', 3000);
-				});
-			}
 		},
-		components: { IssuesModal }
+		...mapActions("modals", ["openModal", "closeModal"]),
+		...mapActions("admin/reports", ["viewReport"])
 	}
+};
 </script>
 
-<style lang='scss' scoped>
-	.tag:not(:last-child) { margin-right: 5px; }
+<style lang="scss" scoped>
+.tag:not(:last-child) {
+	margin-right: 5px;
+}
 
-	td {
-		word-wrap: break-word;
-		max-width: 10vw;
-		vertical-align: middle;
-	}
+td {
+	word-wrap: break-word;
+	max-width: 10vw;
+	vertical-align: middle;
+}
 </style>

+ 191 - 126
frontend/components/Admin/Songs.vue

@@ -1,152 +1,217 @@
 <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>
+		<div class="container">
+			<input
+				v-model="searchQuery"
+				type="text"
+				class="input"
+				placeholder="Search for Songs"
+			/>
+			<br />
+			<br />
+			<table class="table is-striped">
+				<thead>
+					<tr>
+						<td>Thumbnail</td>
+						<td>Title</td>
+						<td>ID</td>
+						<td>YouTube ID</td>
+						<td>Artists</td>
+						<td>Genres</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._id }}</td>
+						<td>
+							<a
+								:href="
+									'https://www.youtube.com/watch?v=' +
+										`${song.songId}`
+								"
+								target="_blank"
+							>
+								{{ song.songId }}</a
+							>
+						</td>
+						<td>{{ song.artists.join(", ") }}</td>
+						<td>{{ song.genres.join(", ") }}</td>
+						<td>
+							<user-id-to-username
+								:userId="song.requestedBy"
+								:link="true"
+							/>
+						</td>
+						<td class="optionsColumn">
+							<button
+								class="button is-primary"
+								@click="edit(song)"
+							>
+								<i class="material-icons">edit</i>
+							</button>
+							<button
+								class="button is-danger"
+								@click="remove(song._id, index)"
+							>
+								<i class="material-icons">cancel</i>
+							</button>
+						</td>
+					</tr>
+				</tbody>
+			</table>
+		</div>
+		<edit-song v-if="modals.editSong" />
 	</div>
-	<edit-song v-show='modals.editSong'></edit-song>
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
+import { mapState, mapActions } from "vuex";
 
-	import EditSong from '../Modals/EditSong.vue';
-	import io from '../../io';
+import { Toast } from "vue-roaster";
 
-	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 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,
+			songs: [],
+			searchQuery: "",
+			editing: {
+				index: 0,
+				song: {}
 			}
+		};
+	},
+	computed: {
+		filteredSongs() {
+			return this.songs;
+			// return this.songs.filter(song => song.indexOf(song.searchQuery) !== -1);
 		},
-		computed: {
-			filteredSongs: function () {
-				return this.$eval('songs | filterBy searchQuery');
-			}
+		...mapState("modals", {
+			modals: state => state.modals.admin
+		})
+	},
+	watch: {
+		"modals.editSong": 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")
+					Toast.methods.addToast(res.message, 4000);
+				else Toast.methods.addToast(res.message, 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() {
+			const _this = this;
+			_this.socket.emit("songs.getSet", _this.position, data => {
+				data.forEach(song => {
+					_this.songs.push(song);
 				});
-			},
-			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.round(length / 15);
-					_this.getSet();
-				});
-				_this.socket.emit('apis.joinAdminRoom', 'songs', () => {});
-			}
+				_this.position += 1;
+				if (_this.maxPosition > _this.position - 1) _this.getSet();
+			});
 		},
-		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;
-						});
+		init() {
+			const _this = this;
+			_this.songs = [];
+			_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"]),
+		...mapActions("modals", ["openModal", "closeModal"])
+	},
+	mounted() {
+		const _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(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);
-							}
+				});
+				_this.socket.on("event:admin.song.updated", updatedSong => {
+					for (let i = 0; i < _this.songs.length; i += 1) {
+						const song = _this.songs[i];
+						if (song._id === updatedSong._id) {
+							_this.songs.$set(i, updatedSong);
 						}
-					});
-				}
-				io.onConnect(() => {
-					_this.init();
+					}
 				});
+			}
+			io.onConnect(() => {
+				_this.init();
+			});
+		});
+
+		if (this.$route.query.id) {
+			this.socket.emit("songs.getSong", this.$route.query.id, res => {
+				if (res.status === "success") {
+					this.edit(res.data);
+					this.closeModal({ sector: "admin", modal: "viewReport" });
+				} else
+					Toast.methods.addToast("Song with that ID not found", 3000);
 			});
 		}
 	}
+};
 </script>
 
-<style lang='scss' scoped>
-	body { font-family: 'Roboto', sans-serif; }
+<style lang="scss" scoped>
+body {
+	font-family: "Roboto", sans-serif;
+}
 
-	.song-thumbnail {
-		display: block;
-		max-width: 50px;
-		margin: 0 auto;
+.optionsColumn {
+	width: 100px;
+	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: #029ce3 !important;
+}
 </style>

+ 322 - 188
frontend/components/Admin/Stations.vue

@@ -1,226 +1,360 @@
 <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'>
+	<div>
+		<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>{{ station.name }}</span>
+						</td>
+						<td>
+							<span>{{ station.type }}</span>
+						</td>
+						<td>
+							<span>{{ station.displayName }}</span>
+						</td>
+						<td>
+							<span>{{ station.description }}</span>
+						</td>
+						<td>
+							<user-id-to-username
+								: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>
-					<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>
-							</span>
+				</header>
+				<div class="card-content">
+					<div class="content">
+						<div class="control is-horizontal">
+							<div class="control is-grouped">
+								<p class="control is-expanded">
+									<input
+										v-model="newStation.name"
+										class="input"
+										type="text"
+										placeholder="Name"
+									/>
+								</p>
+								<p class="control is-expanded">
+									<input
+										v-model="newStation.displayName"
+										class="input"
+										type="text"
+										placeholder="Display Name"
+									/>
+								</p>
+							</div>
 						</div>
-						<div class="sector">
-							<p class='control has-addons'>
-								<input class='input' id='new-blacklisted-genre' type='text' placeholder='Blacklisted Genre' v-on:keyup.enter='addBlacklistedGenre()'>
-								<a class='button is-info' href='#' @click='addBlacklistedGenre()'>Add blacklisted genre</a>
-							</p>
-							<span class='tag is-info' v-for='(index, genre) in newStation.blacklistedGenres' track-by='$index'>
-								{{ genre }}
-								<button class='delete is-info' @click='removeBlacklistedGenre(index)'></button>
-							</span>
+						<label class="label">Description</label>
+						<p class="control is-expanded">
+							<input
+								v-model="newStation.description"
+								class="input"
+								type="text"
+								placeholder="Short description"
+							/>
+						</p>
+						<div class="control is-grouped genre-wrapper">
+							<div class="sector">
+								<p class="control has-addons">
+									<input
+										id="new-genre"
+										class="input"
+										type="text"
+										placeholder="Genre"
+										@keyup.enter="addGenre()"
+									/>
+									<a
+										class="button is-info"
+										href="#"
+										@click="addGenre()"
+										>Add genre</a
+									>
+								</p>
+								<span
+									v-for="(genre, index) in newStation.genres"
+									:key="index"
+									class="tag is-info"
+								>
+									{{ genre }}
+									<button
+										class="delete is-info"
+										@click="removeGenre(index)"
+									/>
+								</span>
+							</div>
+							<div class="sector">
+								<p class="control has-addons">
+									<input
+										id="new-blacklisted-genre"
+										class="input"
+										type="text"
+										placeholder="Blacklisted Genre"
+										@keyup.enter="addBlacklistedGenre()"
+									/>
+									<a
+										class="button is-info"
+										href="#"
+										@click="addBlacklistedGenre()"
+										>Add blacklisted genre</a
+									>
+								</p>
+								<span
+									v-for="(genre,
+									index) in newStation.blacklistedGenres"
+									:key="index"
+									class="tag is-info"
+								>
+									{{ genre }}
+									<button
+										class="delete is-info"
+										@click="removeBlacklistedGenre(index)"
+									/>
+								</span>
+							</div>
 						</div>
 					</div>
 				</div>
+				<footer class="card-footer">
+					<a
+						class="card-footer-item"
+						href="#"
+						@click="createStation()"
+						>Create</a
+					>
+				</footer>
 			</div>
-			<footer class='card-footer'>
-				<a class='card-footer-item' @click='createStation()' href='#'>Create</a>
-			</footer>
 		</div>
-	</div>
 
-	<edit-station v-show='modals.editStation'></edit-station>
+		<edit-station v-if="modals.editStation" />
+	</div>
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
-	import io from '../../io';
+import { mapState, mapActions } from "vuex";
 
-	import EditStation from '../Modals/EditStation.vue';
+import { Toast } from "vue-roaster";
+import io from "../../io";
 
-	export default {
-		components: { EditStation },
-		data() {
-			return {
-				stations: [],
-				newStation: {
-					genres: [],
-					blacklistedGenres: []
-				},
-				modals: { editStation: false }
+import EditStation from "./EditStation.vue";
+import UserIdToUsername from "../UserIdToUsername.vue";
+
+export default {
+	components: { EditStation, UserIdToUsername },
+	data() {
+		return {
+			stations: [],
+			newStation: {
+				genres: [],
+				blacklistedGenres: []
 			}
-		},
-		methods: {
-			toggleModal: function () {
-				this.modals.editStation	= !this.modals.editStation;
-			},
-			createStation: function () {
-				let _this = this;
-				let { newStation: { name, displayName, description, genres, blacklistedGenres } } = this;
+		};
+	},
+	computed: {
+		...mapState("modals", {
+			modals: state => state.modals.station
+		})
+	},
+	methods: {
+		createStation() {
+			const _this = this;
+			const {
+				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);
+			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
+				);
 
-				_this.socket.emit('stations.create', {
+			return _this.socket.emit(
+				"stations.create",
+				{
 					name,
-					type: 'official',
+					type: "official",
 					displayName,
 					description,
 					genres,
-					blacklistedGenres,
-				}, result => {
+					blacklistedGenres
+				},
+				result => {
 					Toast.methods.addToast(result.message, 3000);
-					if (result.status == 'success') this.newStation = {
-						genres: [],
-						blacklistedGenres: []
-					}
-				});
-			},
-			removeStation: function (index) {
-				this.socket.emit('stations.remove', this.stations[index]._id, res => {
+					if (result.status === "success")
+						this.newStation = {
+							genres: [],
+							blacklistedGenres: []
+						};
+				}
+			);
+		},
+		removeStation(index) {
+			this.socket.emit(
+				"stations.remove",
+				this.stations[index]._id,
+				res => {
 					Toast.methods.addToast(res.message, 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
-				});
-			},
-			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);
+			);
+		},
+		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 Toast.methods.addToast("Genre already exists", 3000);
+			if (genre) {
+				this.newStation.genres.push(genre);
+				document.getElementById(`new-genre`).value = "";
+				return true;
+			}
+			return Toast.methods.addToast("Genre cannot be empty", 3000);
+		},
+		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 Toast.methods.addToast("Genre already exists", 3000);
 
-				if (genre) {
-					this.newStation.blacklistedGenres.push(genre);
-					$('#new-blacklisted-genre').val('');
-				}
-				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;
-				});
-				_this.socket.emit('apis.joinAdminRoom', 'stations', data => {});
+			if (genre) {
+				this.newStation.blacklistedGenres.push(genre);
+				document.getElementById(`new-blacklisted-genre`).value = "";
+				return true;
 			}
+			return Toast.methods.addToast("Genre cannot be empty", 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;
-					});
-				});
-				io.onConnect(() => {
-					_this.init();
+		removeBlacklistedGenre(index) {
+			this.newStation.blacklistedGenres.splice(index, 1);
+		},
+		init() {
+			const _this = this;
+			_this.socket.emit("stations.index", data => {
+				_this.stations = data.stations;
+			});
+			_this.socket.emit("apis.joinAdminRoom", "stations", () => {});
+		},
+		...mapActions("modals", ["openModal"]),
+		...mapActions("admin/stations", ["editStation"])
+	},
+	mounted() {
+		const _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;
 				});
 			});
-		}
+			io.onConnect(() => {
+				_this.init();
+			});
+		});
 	}
+};
 </script>
 
-<style lang='scss' scoped>
-	.tag {
-		margin-top: 5px;
-		&:not(:last-child) {
-			margin-right: 5px;
-		}
+<style lang="scss" scoped>
+.tag {
+	margin-top: 5px;
+	&:not(:last-child) {
+		margin-right: 5px;
 	}
+}
 
-	td {
-		word-wrap: break-word;
-		max-width: 10vw;
-		vertical-align: middle;
-	}
+td {
+	word-wrap: break-word;
+	max-width: 10vw;
+	vertical-align: middle;
+}
 
-	.is-info:focus { background-color: #0398db; }
+.is-info:focus {
+	background-color: #0398db;
+}
 
-	.genre-wrapper {
-		display: flex;
-    	justify-content: space-around;
-	}
+.genre-wrapper {
+	display: flex;
+	justify-content: space-around;
+}
 
-	.card-footer-item { color: #029ce3; }
+.card-footer-item {
+	color: #029ce3;
+}
 </style>

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

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

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

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

+ 136 - 28
frontend/components/MainFooter.vue

@@ -1,47 +1,155 @@
 <template>
-	<footer class='footer'>
-		<div class='container'>
-			<div class='content has-text-centered'>
-				<p>
-					© Copyright Musare 2015 - 2017
-				</p>
-				<p>
-					<a class='icon' href='https://github.com/Musare/MusareNode' target='_blank' title='GitHub Repository'>
-						<img src='/assets/social/github.svg'/>
+	<footer class="footer">
+		<div class="container">
+			<div class="content has-text-centered">
+				<p class="socialIcons">
+					<a
+						class="icon"
+						:href="`${this.socialLinks.github}`"
+						target="_blank"
+						title="GitHub Repository"
+					>
+						<img src="/assets/social/github.svg" />
 					</a>
-					<a class='icon' href='https://twitter.com/MusareApp' target='_blank' title='Twitter Account'>
-						<img src='/assets/social/twitter.svg'/>
+					<a
+						class="icon"
+						:href="`${this.socialLinks.twitter}`"
+						target="_blank"
+						title="Twitter Account"
+					>
+						<img src="/assets/social/twitter.svg" />
 					</a>
-					<a class='icon' href='https://www.facebook.com/MusareMusic/' target='_blank' title='Facebook Page'>
-						<img src='/assets/social/facebook.svg'/>
+					<a
+						class="icon"
+						:href="`${this.socialLinks.facebook}`"
+						target="_blank"
+						title="Facebook Page"
+					>
+						<img src="/assets/social/facebook.svg" />
 					</a>
-					<a class='icon' href='https://discord.gg/Y5NxYGP' target='_blank' title='Discord Server'>
-						<img src='/assets/social/discord.svg'/>
+					<a
+						class="icon"
+						:href="`${this.socialLinks.discord}`"
+						target="_blank"
+						title="Discord Server"
+					>
+						<img src="/assets/social/discord.svg" />
 					</a>
 				</p>
+				<a href="https://musare.com" target="_blank"
+					><img
+						class="musareFooterLogo"
+						src="/assets/wordmark.png"
+						alt="Musare"
+				/></a>
+				<p class="footerLinks">
+					<router-link title="About Musare" to="/about">
+						About
+					</router-link>
+					<router-link title="The Musare Team" to="/team">
+						Team
+					</router-link>
+					<router-link title="News" to="/news">
+						News
+					</router-link>
+				</p>
+				<p>
+					© Copyright Musare 2015 - 2019
+				</p>
 			</div>
 		</div>
 	</footer>
 </template>
 
-<style lang='scss' scoped>
-	.content a:not(.button) { border: 0; }
+<script>
+export default {
+	data() {
+		return {
+			socialLinks: {
+				github: "",
+				twitter: "",
+				facebook: "",
+				discord: ""
+			}
+		};
+	},
+	mounted() {
+		lofig.get("siteSettings.socialLinks", res => {
+			this.socialLinks = res;
+		});
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.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: #ffffff;
+	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 #03a9f4;
+		}
+		a {
+			padding: 0 5px;
+			font-size: 18px;
+			color: #03a9f4;
 		}
+		a:hover {
+			color: #03a9f4;
+			text-decoration: underline;
+		}
+	}
+}
 
+@media only screen and (min-width: 992px) {
+	.footer {
+		height: 180px;
+		.socialIcons {
+			left: 0;
+			top: 35px;
+			position: absolute;
+		}
+		.footerLinks {
+			right: 0;
+			top: 35px;
+			position: absolute;
+		}
 	}
+}
 </style>

+ 131 - 129
frontend/components/MainHeader.vue

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

+ 112 - 87
frontend/components/Modals/AddSongToPlaylist.vue

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

+ 189 - 110
frontend/components/Modals/AddSongToQueue.vue

@@ -1,13 +1,33 @@
 <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="$parent.$parent.loggedIn && $parent.type === 'community'"
+			>
+				<ul class="menu-list">
+					<li v-for="(playlist, index) in playlists" :key="index">
+						<a
+							href="#"
+							target="_blank"
+							v-on:click="$parent.editPlaylist(playlist._id)"
+							>{{ playlist.displayName }}</a
+						>
+						<div class="controls">
+							<a
+								href="#"
+								v-on:click="selectPlaylist(playlist._id)"
+								v-if="!isPlaylistSelected(playlist._id)"
+							>
+								<i class="material-icons">panorama_fish_eye</i>
+							</a>
+							<a
+								href="#"
+								v-on:click="unSelectPlaylist()"
+								v-if="isPlaylistSelected(playlist._id)"
+							>
+								<i class="material-icons">lens</i>
+							</a>
 						</div>
 					</li>
 				</ul>
@@ -15,27 +35,59 @@
 			</aside>
 			<div class="control is-grouped">
 				<p class="control is-expanded">
-					<input class="input" type="text" placeholder="YouTube Query" v-model='querySearch' autofocus @keyup.enter='submitQuery()'>
+					<input
+						class="input"
+						type="text"
+						placeholder="YouTube Query"
+						v-model="querySearch"
+						autofocus
+						@keyup.enter="submitQuery()"
+					/>
 				</p>
 				<p class="control">
-					<a class="button is-info" @click="submitQuery()" href='#'>
-						Search
-					</a>
+					<a
+						class="button is-info"
+						v-on:click="submitQuery()"
+						href="#"
+						>Search</a
+					>
 				</p>
 			</div>
-			<table class="table">
+			<div class="control is-grouped" v-if="$parent.type === 'official'">
+				<p class="control is-expanded">
+					<input
+						class="input"
+						type="text"
+						placeholder="YouTube Playlist URL"
+						v-model="importQuery"
+						@keyup.enter="importPlaylist()"
+					/>
+				</p>
+				<p class="control">
+					<a
+						class="button is-info"
+						v-on:click="importPlaylist()"
+						href="#"
+						>Import</a
+					>
+				</p>
+			</div>
+			<table class="table" v-if="queryResults.length > 0">
 				<tbody>
-				<tr v-for="result in queryResults">
-					<td>
-						<img :src="result.thumbnail" />
-					</td>
-					<td>{{ result.title }}</td>
-					<td>
-						<a class="button is-success" @click="addSongToQueue(result.id)" href='#'>
-							Add
-						</a>
-					</td>
-				</tr>
+					<tr v-for="(result, index) in queryResults" :key="index">
+						<td>
+							<img :src="result.thumbnail" />
+						</td>
+						<td>{{ result.title }}</td>
+						<td>
+							<a
+								class="button is-success"
+								v-on:click="addSongToQueue(result.id)"
+								href="#"
+								>Add</a
+							>
+						</td>
+					</tr>
 				</tbody>
 			</table>
 		</div>
@@ -43,103 +95,130 @@
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
-	import Modal from './Modal.vue';
-	import io from '../../io';
-	import auth from '../../auth';
+import { Toast } from "vue-roaster";
+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: ""
+		};
+	},
+	methods: {
+		isPlaylistSelected(playlistId) {
+			return this.privatePlaylistQueueSelected === playlistId;
+		},
+		selectPlaylist(playlistId) {
+			const _this = this;
+			if (_this.$parent.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);
+		unSelectPlaylist() {
+			const _this = this;
+			if (_this.$parent.type === "community") {
+				_this.privatePlaylistQueueSelected = null;
+				_this.$parent.privatePlaylistQueueSelected = null;
+			}
+		},
+		addSongToQueue(songId) {
+			const _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);
-					});
-				}
-			},
-			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
-						});
 					}
+				);
+			} 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);
 				});
 			}
 		},
-		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() {
+			const _this = this;
+			Toast.methods.addToast(
+				"Starting to import your playlist. This can take some time to do.",
+				4000
+			);
+			this.socket.emit(
+				"queueSongs.addSetToQueue",
+				_this.importQuery,
+				res => {
+					Toast.methods.addToast(res.message, 4000);
+				}
+			);
 		},
-		events: {
-			closeModal: function () {
-				this.$parent.modals.addSongToQueue = !this.$parent.modals.addSongToQueue;
+		submitQuery() {
+			const _this = this;
+			let query = _this.querySearch;
+			if (query.indexOf("&index=") !== -1) {
+				query = query.split("&index=");
+				query.pop();
+				query = query.join("");
 			}
-		},
-		components: { Modal }
-	}
+			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
+					});
+				}
+			});
+		}
+	},
+	mounted() {
+		const _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;
+		});
+	},
+	components: { Modal }
+};
 </script>
 
-<style type='scss' scoped>
-	tr td {
-		vertical-align: middle;
+<style lang="scss" scoped>
+tr td {
+	vertical-align: middle;
 
-		img { width: 55px; }
+	img {
+		width: 55px;
 	}
+}
+
+.table {
+	margin-bottom: 0;
+}
 </style>

+ 124 - 71
frontend/components/Modals/CreateCommunityStation.vue

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

+ 279 - 167
frontend/components/Modals/EditNews.vue

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

+ 767 - 398
frontend/components/Modals/EditSong.vue

@@ -1,151 +1,281 @@
 <template>
 	<div>
-		<modal title='Edit Song'>
-			<div slot='body'>
-				<h5 class='has-text-centered'>Video Preview</h5>
-				<div class='video-container'>
-					<div id='player'></div>
+		<modal title="Edit Song">
+			<div slot="body">
+				<h5 class="has-text-centered">Video Preview</h5>
+				<div class="video-container">
+					<div id="player"></div>
+					<canvas
+						id="durationCanvas"
+						height="40"
+						width="560"
+					></canvas>
 					<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()">
+								<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>
+						<p class="control has-addons">
+							<button
+								class="button"
+								v-on: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
+								class="button"
+								v-on: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
+								class="button"
+								v-on:click="settings('stop')"
+							>
+								<i class="material-icons">stop</i>
 							</button>
-							<button class='button' @click='settings("skipToLast10Secs")'>
-								<i class='material-icons'>fast_forward</i>
+							<button
+								class="button"
+								v-on:click="settings('skipToLast10Secs')"
+							>
+								<i class="material-icons">fast_forward</i>
 							</button>
 						</p>
+						<p>
+							YouTube:
+							<span>{{ youtubeVideoCurrentTime }}</span> /
+							<span>{{ youtubeVideoDuration }}</span>
+							{{ youtubeVideoNote }}
+						</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'">
+				<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'>
+						<input
+							class="input"
+							type="text"
+							v-model="editing.song.thumbnail"
+						/>
 					</div>
 				</div>
 
-				<h5 class='has-text-centered'>Edit Information</h5>
+				<h5 class="has-text-centered">Edit Information</h5>
 
-				<p class='control'>
-					<label class='checkbox'>
-						<input type='checkbox' v-model='editing.song.explicit'>
+				<p class="control">
+					<label class="checkbox">
+						<input
+							type="checkbox"
+							v-model="editing.song.explicit"
+						/>
 						Explicit
 					</label>
 				</p>
-				<label class='label'>Song ID & Title</label>
+				<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 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 class="control is-expanded">
+							<input
+								class="input"
+								type="text"
+								v-model="editing.song.title"
+								autofocus
+							/>
 						</p>
 					</div>
 				</div>
-				<label class='label'>Artists & Genres</label>
-				<div class='control is-horizontal'>
-					<div class='control is-grouped artist-genres'>
+				<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 class="control has-addons">
+								<input
+									class="input"
+									id="new-artist"
+									type="text"
+									placeholder="Artist"
+								/>
+								<button
+									class="button is-info"
+									v-on:click="addTag('artists')"
+								>
+									Add Artist
+								</button>
 							</p>
-							<span class='tag is-info' v-for='(index, artist) in editing.song.artists' track-by='$index'>
+							<span
+								class="tag is-info"
+								v-for="(artist, index) in editing.song.artists"
+								:key="index"
+							>
 								{{ artist }}
-								<button class='delete is-info' @click='removeTag("artists", index)'></button>
+								<button
+									class="delete is-info"
+									v-on:click="removeTag('artists', index)"
+								></button>
 							</span>
 						</div>
 						<div>
-							<p class='control has-addons'>
-								<input class='input' id='new-genre' type='text' placeholder='Genre'>
-								<button class='button is-info' @click='addTag("genres")'>Add Genre</button>
+							<p class="control has-addons">
+								<input
+									class="input"
+									id="new-genre"
+									type="text"
+									placeholder="Genre"
+								/>
+								<button
+									class="button is-info"
+									v-on:click="addTag('genres')"
+								>
+									Add Genre
+								</button>
 							</p>
-							<span class='tag is-info' v-for='(index, genre) in editing.song.genres' track-by='$index'>
+							<span
+								class="tag is-info"
+								v-for="(genre, index) in editing.song.genres"
+								:key="index"
+							>
 								{{ genre }}
-								<button class='delete is-info' @click='removeTag("genres", index)'></button>
+								<button
+									class="delete is-info"
+									v-on:click="removeTag('genres', index)"
+								></button>
 							</span>
 						</div>
 					</div>
 				</div>
-				<label class='label'>Song Duration</label>
-				<p class='control'>
-					<input class='input' type='text' v-model='editing.song.duration'>
+				<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'>
+				<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-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 v-for="(report, index) in reports" :key="index">
+							<router-link
+								:to="{
+									path: '/admin/reports',
+									query: { id: report, returnToSong: true }
+								}"
+								class="report-link"
+							>
+								Report - {{ report }}
+							</router-link>
 						</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'>
+				<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'>
+				<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()'>
+				<button
+					class="button is-success"
+					v-on:click="getSpotifySongs()"
+				>
 					Get Spotify songs
 				</button>
 				<hr />
-				<article class="media" v-for='song in spotify.songs'>
+				<article
+					class="media"
+					v-for="(song, index) in spotify.songs"
+					:key="index"
+				>
 					<figure class="media-left">
 						<p class="image is-64x64">
-							<img :src="song.thumbnail" onerror="this.src='/assets/notes-transparent.png'">
+							<img
+								:src="song.thumbnail"
+								onerror="this.src='/assets/notes-transparent.png'"
+							/>
 						</p>
 					</figure>
 					<div class="media-content">
 						<div class="content">
 							<p>
-								<strong>{{song.title}}</strong>
+								<strong>{{ song.title }}</strong>
 								<br />
-								<small>Artists: {{song.artists}}</small>, <small>Duration: {{song.duration}}</small>, <small>Explicit: {{song.explicit}}</small>
+								<small>Artists: {{ song.artists }}</small
+								>, <small>Duration: {{ song.duration }}</small
+								>,
+								<small>Explicit: {{ song.explicit }}</small>
 								<br />
-								<small>Thumbnail: {{song.thumbnail}}</small>
+								<small>Thumbnail: {{ song.thumbnail }}</small>
 							</p>
 						</div>
 					</div>
 				</article>
 			</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">
+				<button
+					class="button is-success"
+					v-on:click="save(editing.song, false)"
+				>
+					<i class="material-icons save-changes">done</i>
 					<span>&nbsp;Save</span>
 				</button>
-				<button class='button is-success' @click='save(editing.song, true)'>
-					<i class='material-icons save-changes'>done</i>
+				<button
+					class="button is-success"
+					v-on:click="save(editing.song, true)"
+				>
+					<i class="material-icons save-changes">done</i>
 					<span>&nbsp;Save and close</span>
 				</button>
-				<button class='button is-danger' @click='$parent.toggleModal()'>
+				<button
+					class="button is-danger"
+					v-on:click="
+						closeModal({ sector: 'admin', modal: 'editSong' })
+					"
+				>
 					<span>&nbsp;Close</span>
 				</button>
 			</div>
@@ -154,378 +284,617 @@
 </template>
 
 <script>
-	import io from '../../io';
-	import validation from '../../validation';
-	import { Toast } from 'vue-roaster';
-	import Modal from './Modal.vue';
-
-	export default {
-		components: { Modal },
-		data() {
-			return {
-				editing: {
-					index: 0,
-					song: {},
-					type: ''
-				},
-				reports: 0,
-				video: {
-					player: null,
-					paused: true,
-					playerReady: false,
-					autoPlayed: false
-				},
-				spotify: {
-					title: '',
-					artist: '',
-					songs: []
-				}
+import { mapState, mapActions } from "vuex";
+import { Toast } from "vue-roaster";
+
+import io from "../../io";
+import validation from "../../validation";
+import Modal from "./Modal.vue";
+
+export default {
+	components: { Modal },
+	data() {
+		return {
+			reports: 0,
+			spotify: {
+				title: "",
+				artist: "",
+				songs: []
+			},
+			youtubeVideoDuration: 0.0,
+			youtubeVideoCurrentTime: 0.0,
+			youtubeVideoNote: "",
+			useHTTPS: false
+		};
+	},
+	computed: {
+		...mapState("admin/songs", {
+			video: state => state.video,
+			editing: state => state.editing
+		}),
+		...mapState("modals", {
+			modals: state => state.modals.admin
+		})
+	},
+	methods: {
+		save(song, close) {
+			const _this = this;
+
+			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
+				);
+
+			// Duration
+			if (
+				Number(song.skipDuration) + Number(song.duration) >
+				this.youtubeVideoDuration
+			) {
+				return Toast.methods.addToast(
+					"Duration can't be higher than the length of the video",
+					8000
+				);
 			}
-		},
-		methods: {
-			save: function (song, close) {
-				let _this = this;
-
-				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);
-
-
-				// 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);
-
-
-				// 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".';
-				});
-				if (error) return Toast.methods.addToast(error, 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.';
-				});
-				if (error) return Toast.methods.addToast(error, 8000);
+			// Title
+			if (!validation.isLength(song.title, 1, 100))
+				return Toast.methods.addToast(
+					"Title must have between 1 and 100 characters.",
+					8000
+				);
+			/* if (!validation.regex.ascii.test(song.title))
+				return Toast.methods.addToast(
+					"Invalid title format. Only ascii characters are allowed.",
+					8000
+				); */
+
+			// 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)) {
+					error = "Artist must have between 1 and 32 characters.";
+					return error;
+				}
+				if (!validation.regex.ascii.test(artist)) {
+					error =
+						"Invalid artist format. Only ascii characters are allowed.";
+					return error;
+				}
+				if (artist === "NONE") {
+					error =
+						'Invalid artist format. Artists are not allowed to be named "NONE".';
+					return error;
+				}
 
+				return false;
+			});
+			if (error) return Toast.methods.addToast(error, 8000);
+
+			// Genres
+			error = undefined;
+			song.genres.forEach(genre => {
+				if (!validation.isLength(genre, 1, 16)) {
+					error = "Genre must have between 1 and 16 characters.";
+					return error;
+				}
+				if (!validation.regex.az09_.test(genre)) {
+					error =
+						"Invalid genre format. Only ascii characters are allowed.";
+					return error;
+				}
 
-				// 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);
+				return false;
+			});
+			if (error) return Toast.methods.addToast(error, 8000);
+
+			// Thumbnail
+			if (!validation.isLength(song.thumbnail, 8, 256))
+				return Toast.methods.addToast(
+					"Thumbnail must have between 8 and 256 characters.",
+					8000
+				);
+			if (this.useHTTPS && song.thumbnail.indexOf("https://") !== 0) {
+				return Toast.methods.addToast(
+					'Thumbnail must start with "https://".',
+					8000
+				);
+			}
 
+			if (!this.useHTTPS && song.thumbnail.indexOf("http://") !== 0) {
+				return Toast.methods.addToast(
+					'Thumbnail must start with "http://".',
+					8000
+				);
+			}
 
-				this.socket.emit(`${_this.editing.type}.update`, song._id, song, res => {
+			return this.socket.emit(
+				`${_this.editing.type}.update`,
+				song._id,
+				song,
+				res => {
 					Toast.methods.addToast(res.message, 4000);
-					if (res.status === 'success') {
-						_this.$parent.songs.forEach(lSong => {
-							if (song._id === lSong._id) {
-								for (let n in song) {
-									lSong[n] = song[n];
-								}
+					if (res.status === "success") {
+						_this.$parent.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();
-				});
-			},
-			settings: function (type) {
-				let _this = this;
-				switch(type) {
-					case 'stop':
-						_this.video.player.stopVideo();
-						_this.video.paused = true;
-						break;
-					case 'pause':
-						_this.video.player.pauseVideo();
-						_this.video.paused = true;
-						break;
-					case 'play':
-						_this.video.player.playVideo();
-						_this.video.paused = false;
-						break;
-					case 'skipToLast10Secs':
-						_this.video.player.seekTo((_this.editing.song.duration - 10) + _this.editing.song.skipDuration);
-						break;
-				}
-			},
-			changeVolume: function () {
-				let local = this;
-				let volume = $("#volumeSlider").val();
-				localStorage.setItem("volume", volume);
-				local.video.player.setVolume(volume);
-				if (volume > 0) local.video.player.unMute();
-			},
-			addTag: function (type) {
-				if (type == 'genres') {
-					let genre = $('#new-genre').val().toLowerCase().trim();
-					if (this.editing.song.genres.indexOf(genre) !== -1) return Toast.methods.addToast('Genre already exists', 3000);
-					if (genre) {
-						this.editing.song.genres.push(genre);
-						$('#new-genre').val('');
-					} else Toast.methods.addToast('Genre cannot be empty', 3000);
-				} else if (type == 'artists') {
-					let artist = $('#new-artist').val();
-					if (this.editing.song.artists.indexOf(artist) !== -1) return Toast.methods.addToast('Artist already exists', 3000);
-					if ($('#new-artist').val() !== '') {
-						this.editing.song.artists.push(artist);
-						$('#new-artist').val('');
-					} else Toast.methods.addToast('Artist cannot be empty', 3000);
+					if (close)
+						_this.closeModal({
+							sector: "admin",
+							modal: "editSong"
+						});
 				}
-			},
-			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);
-				});
+			);
+		},
+		settings(type) {
+			const _this = this;
+			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":
+					_this.video.player.seekTo(
+						_this.editing.song.duration -
+							10 +
+							_this.editing.song.skipDuration
+					);
+					break;
 			}
 		},
-		ready: function () {
-
-			let _this = this;
-
-			io.getSocket(socket => {
-				_this.socket = socket;
-			});
-
-			setInterval(() => {
-				if (_this.video.paused === false && _this.playerReady && _this.video.player.getCurrentTime() - _this.editing.song.skipDuration > _this.editing.song.duration) {
-					_this.video.paused = false;
-					_this.video.player.stopVideo();
+		changeVolume() {
+			const local = this;
+			const volume = document.getElementById("volumeSlider").value;
+			localStorage.setItem("volume", volume);
+			local.video.player.setVolume(volume);
+			if (volume > 0) local.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 Toast.methods.addToast("Genre already exists", 3000);
+				if (genre) {
+					this.editing.song.genres.push(genre);
+					document.getElementById("new-genre").value = "";
+					return false;
 				}
-			}, 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();
-							}
 
-							_this.video.paused = false;
-							let youtubeDuration = _this.video.player.getDuration();
-							youtubeDuration -= _this.editing.song.skipDuration;
-							if (_this.editing.song.duration > youtubeDuration) {
-								this.video.player.stopVideo();
-								_this.video.paused = true;
-								Toast.methods.addToast("Video can't play. Specified duration is bigger than the YouTube song duration.", 4000);
-							} else if (_this.editing.song.duration <= 0) {
-								this.video.player.stopVideo();
-								_this.video.paused = true;
-								Toast.methods.addToast("Video can't play. Specified duration has to be more than 0 seconds.", 4000);
-							}
-
-							if (_this.video.player.getCurrentTime() < _this.editing.song.skipDuration) {
-								_this.video.player.seekTo(10);
-							}
-						} else if (event.data === 2) {
-							this.video.paused = true;
-						}
-					}
+				return Toast.methods.addToast("Genre cannot be empty", 3000);
+			}
+			if (type === "artists") {
+				const artist = document.getElementById("new-artist").value;
+				if (this.editing.song.artists.indexOf(artist) !== -1)
+					return Toast.methods.addToast(
+						"Artist already exists",
+						3000
+					);
+				if (document.getElementById("new-artist").value !== "") {
+					this.editing.song.artists.push(artist);
+					document.getElementById("new-artist").value = "";
+					return false;
 				}
-			});
-
-			let volume = parseInt(localStorage.getItem("volume"));
-			volume = (typeof volume === "number") ? volume : 20;
-			$("#volumeSlider").val(volume);
+				return Toast.methods.addToast("Artist cannot be empty", 3000);
+			}
 
+			return false;
 		},
-		events: {
-			closeModal: function () {
-				this.$parent.modals.editSong = false;
-				this.video.player.stopVideo();
-			},
-			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;
-					});
+		removeTag(type, index) {
+			if (type === "genres") this.editing.song.genres.splice(index, 1);
+			else if (type === "artists")
+				this.editing.song.artists.splice(index, 1);
+		},
+		getSpotifySongs() {
+			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
+						);
 				}
-				this.$parent.toggleModal();
-			},
-			stopVideo: function () {
-				this.video.player.stopVideo();
-			}
-		}
-	}
-</script>
+			);
+		},
+		initCanvas() {
+			const canvasElement = document.getElementById("durationCanvas");
+			const ctx = canvasElement.getContext("2d");
 
-<style type='scss' scoped>
-	input[type=range] {
-		-webkit-appearance: none;
-		width: 100%;
-		margin: 7.3px 0;
-	}
+			const skipDurationColor = "#ef4a1c";
+			const durationColor = "#1dc146";
+			const afterDurationColor = "#ef731a";
 
-	input[type=range]:focus {
-		outline: none;
-	}
+			ctx.font = "16px Arial";
 
-	input[type=range]::-webkit-slider-runnable-track {
-		width: 100%;
-		height: 5.2px;
-		cursor: pointer;
-		box-shadow: 0;
-		background: #c2c0c2;
-		border-radius: 0;
-		border: 0;
-	}
+			ctx.fillStyle = skipDurationColor;
+			ctx.fillRect(0, 25, 20, 15);
 
-	input[type=range]::-webkit-slider-thumb {
-		box-shadow: 0;
-		border: 0;
-		height: 19px;
-		width: 19px;
-		border-radius: 15px;
-		background: #ff4545;
-		cursor: pointer;
-		-webkit-appearance: none;
-		margin-top: -6.5px;
-	}
+			ctx.fillStyle = "#000000";
+			ctx.fillText("Skip duration", 25, 38);
 
-	input[type=range]::-moz-range-track {
-		width: 100%;
-		height: 5.2px;
-		cursor: pointer;
-		box-shadow: 0;
-		background: #c2c0c2;
-		border-radius: 0;
-		border: 0;
-	}
+			ctx.fillStyle = durationColor;
+			ctx.fillRect(130, 25, 20, 15);
 
-	input[type=range]::-moz-range-thumb {
-		box-shadow: 0;
-		border: 0;
-		height: 19px;
-		width: 19px;
-		border-radius: 15px;
-		background: #ff4545;
-		cursor: pointer;
-		-webkit-appearance: none;
-		margin-top: -6.5px;
-	}
+			ctx.fillStyle = "#000000";
+			ctx.fillText("Duration", 155, 38);
 
-	input[type=range]::-ms-track {
-		width: 100%;
-		height: 5.2px;
-		cursor: pointer;
-		box-shadow: 0;
-		background: #c2c0c2;
-		border-radius: 1.3px;
-	}
+			ctx.fillStyle = afterDurationColor;
+			ctx.fillRect(230, 25, 20, 15);
 
-	input[type=range]::-ms-fill-lower {
-		background: #c2c0c2;
-		border: 0;
-		border-radius: 0;
-		box-shadow: 0;
-	}
+			ctx.fillStyle = "#000000";
+			ctx.fillText("After duration", 255, 38);
+		},
+		drawCanvas() {
+			const canvasElement = document.getElementById("durationCanvas");
+			const ctx = canvasElement.getContext("2d");
 
-	input[type=range]::-ms-fill-upper {
-		background: #c2c0c2;
-		border: 0;
-		border-radius: 0;
-		box-shadow: 0;
-	}
+			const videoDuration = Number(this.youtubeVideoDuration);
 
-	input[type=range]::-ms-thumb {
-		box-shadow: 0;
-		border: 0;
-		height: 15px;
-		width: 15px;
-		border-radius: 15px;
-		background: #ff4545;
-		cursor: pointer;
-		-webkit-appearance: none;
-		margin-top: 1.5px;
-	}
+			const skipDuration = Number(this.editing.song.skipDuration);
+			const duration = Number(this.editing.song.duration);
+			const afterDuration = videoDuration - (skipDuration + duration);
 
-	.controls {
-		display: flex;
-		flex-direction: column;
-		align-items: center;
-	}
+			const width = 560;
 
-	.artist-genres {
-		display: flex;
-    	justify-content: space-between;
-	}
+			const currentTime = this.video.player.getCurrentTime();
 
-	#volumeSlider { margin-bottom: 15px; }
+			const widthSkipDuration = (skipDuration / videoDuration) * width;
+			const widthDuration = (duration / videoDuration) * width;
+			const widthAfterDuration = (afterDuration / videoDuration) * width;
 
-	.has-text-centered { padding: 10px; }
+			const widthCurrentTime = (currentTime / videoDuration) * width;
 
-	.thumbnail-preview {
-		display: flex;
-		margin: 0 auto 25px auto;
-		max-width: 200px;
-		width: 100%;
-	}
+			const skipDurationColor = "#ef4a1c";
+			const durationColor = "#1dc146";
+			const afterDurationColor = "#ef731a";
+			const currentDurationColor = "#3b25e8";
 
-	.modal-card-body, .modal-card-foot { border-top: 0; }
+			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
+			);
 
-	.label, .checkbox, h5 {
-		font-weight: normal;
-	}
+			ctx.fillStyle = currentDurationColor;
+			ctx.fillRect(widthCurrentTime, 0, 1, 20);
+		},
+		...mapActions("admin/songs", [
+			"stopVideo",
+			"loadVideoById",
+			"pauseVideo",
+			"getCurrentTime",
+			"editSong"
+		]),
+		...mapActions("modals", ["closeModal"])
+	},
+	mounted() {
+		const _this = this;
+
+		// if (this.modals.editSong = false) this.video.player.stopVideo();
+
+		// this.loadVideoById(
+		//   this.editing.song.songId,
+		//   this.editing.song.skipDuration
+		// );
+
+		this.initCanvas();
+
+		lofig.get("cookie.secure", res => {
+			_this.useHTTPS = res;
+		});
+
+		io.getSocket(socket => {
+			this.socket = socket;
+
+			if (this.editing.type === "songs") {
+				socket.emit(
+					"reports.getReportsForSong",
+					this.editing.song.songId,
+					res => {
+						this.reports = res.data;
+					}
+				);
+			}
+		});
+
+		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();
+			}
+			if (this.playerReady) {
+				_this.getCurrentTime(3).then(time => {
+					this.youtubeVideoCurrentTime = time;
+					return time;
+				});
+			}
 
-	.video-container {
-		display: flex;
-		flex-direction: column;
-		align-items: center;
-		padding: 10px;
+			if (_this.video.paused === false) _this.drawCanvas();
+		}, 200);
+
+		this.video.player = new window.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;
+					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 => {
+					if (event.data === 1) {
+						if (!_this.video.autoPlayed) {
+							_this.video.autoPlayed = true;
+							return _this.video.player.stopVideo();
+						}
 
-		iframe { pointer-events: none; }
-	}
+						_this.video.paused = false;
+						let youtubeDuration = _this.video.player.getDuration();
+						this.youtubeVideoDuration = youtubeDuration;
+						this.youtubeVideoNote = "";
+						youtubeDuration -= _this.editing.song.skipDuration;
+						if (_this.editing.song.duration > youtubeDuration + 1) {
+							this.video.player.stopVideo();
+							_this.video.paused = true;
+							return Toast.methods.addToast(
+								"Video can't play. Specified duration is bigger than the YouTube song duration.",
+								4000
+							);
+						}
+						if (_this.editing.song.duration <= 0) {
+							this.video.player.stopVideo();
+							_this.video.paused = true;
+							return Toast.methods.addToast(
+								"Video can't play. Specified duration has to be more than 0 seconds.",
+								4000
+							);
+						}
 
-	.save-changes { color: #fff; }
+						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;
+					}
 
-	.tag:not(:last-child) { margin-right: 5px; }
+					return false;
+				}
+			}
+		});
 
-	.reports-length {
-		color: #ff4545;
-		font-weight: bold;
-		display: flex;
-		justify-content: center;
+		let volume = parseInt(localStorage.getItem("volume"));
+		document.getElementById("volumeSlider").value = volume =
+			typeof volume === "number" ? volume : 20;
 	}
+};
+</script>
 
-	.report-link {
-		color: #000;
+<style lang="scss" scoped>
+input[type="range"] {
+	-webkit-appearance: none;
+	width: 100%;
+	margin: 7.3px 0;
+}
+
+input[type="range"]:focus {
+	outline: none;
+}
+
+input[type="range"]::-webkit-slider-runnable-track {
+	width: 100%;
+	height: 5.2px;
+	cursor: pointer;
+	box-shadow: 0;
+	background: #c2c0c2;
+	border-radius: 0;
+	border: 0;
+}
+
+input[type="range"]::-webkit-slider-thumb {
+	box-shadow: 0;
+	border: 0;
+	height: 19px;
+	width: 19px;
+	border-radius: 15px;
+	background: #03a9f4;
+	cursor: pointer;
+	-webkit-appearance: none;
+	margin-top: -6.5px;
+}
+
+input[type="range"]::-moz-range-track {
+	width: 100%;
+	height: 5.2px;
+	cursor: pointer;
+	box-shadow: 0;
+	background: #c2c0c2;
+	border-radius: 0;
+	border: 0;
+}
+
+input[type="range"]::-moz-range-thumb {
+	box-shadow: 0;
+	border: 0;
+	height: 19px;
+	width: 19px;
+	border-radius: 15px;
+	background: #03a9f4;
+	cursor: pointer;
+	-webkit-appearance: none;
+	margin-top: -6.5px;
+}
+
+input[type="range"]::-ms-track {
+	width: 100%;
+	height: 5.2px;
+	cursor: pointer;
+	box-shadow: 0;
+	background: #c2c0c2;
+	border-radius: 1.3px;
+}
+
+input[type="range"]::-ms-fill-lower {
+	background: #c2c0c2;
+	border: 0;
+	border-radius: 0;
+	box-shadow: 0;
+}
+
+input[type="range"]::-ms-fill-upper {
+	background: #c2c0c2;
+	border: 0;
+	border-radius: 0;
+	box-shadow: 0;
+}
+
+input[type="range"]::-ms-thumb {
+	box-shadow: 0;
+	border: 0;
+	height: 15px;
+	width: 15px;
+	border-radius: 15px;
+	background: #03a9f4;
+	cursor: pointer;
+	-webkit-appearance: none;
+	margin-top: 1.5px;
+}
+
+.controls {
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+}
+
+.artist-genres {
+	display: flex;
+	justify-content: space-between;
+}
+
+#volumeSlider {
+	margin-bottom: 15px;
+}
+
+.has-text-centered {
+	padding: 10px;
+}
+
+.thumbnail-preview {
+	display: flex;
+	margin: 0 auto 25px auto;
+	max-width: 200px;
+	width: 100%;
+}
+
+.modal-card-body,
+.modal-card-foot {
+	border-top: 0;
+}
+
+.label,
+.checkbox,
+h5 {
+	font-weight: normal;
+}
+
+.video-container {
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+	padding: 10px;
+
+	iframe {
+		pointer-events: none;
 	}
+}
+
+.save-changes {
+	color: #fff;
+}
+
+.tag:not(:last-child) {
+	margin-right: 5px;
+}
+
+.reports-length {
+	color: #ff4545;
+	font-weight: bold;
+	display: flex;
+	justify-content: center;
+}
+
+.report-link {
+	color: #000;
+}
 </style>

+ 310 - 181
frontend/components/Modals/EditStation.vue

@@ -1,217 +1,346 @@
 <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>
-				</div>
+	<modal title="Edit Station">
+		<template v-slot:body>
+			<label class="label">Name</label>
+			<p class="control">
+				<input
+					v-model="editing.name"
+					class="input"
+					type="text"
+					placeholder="Station Name"
+				/>
+			</p>
+			<label class="label">Display name</label>
+			<p class="control">
+				<input
+					v-model="editing.displayName"
+					class="input"
+					type="text"
+					placeholder="Station Display Name"
+				/>
+			</p>
+			<label class="label">Description</label>
+			<p class="control">
+				<input
+					v-model="editing.description"
+					class="input"
+					type="text"
+					placeholder="Station Description"
+				/>
+			</p>
+			<label class="label">Privacy</label>
+			<p class="control">
+				<span class="select">
+					<select v-model="editing.privacy">
+						<option value="public">Public</option>
+						<option value="unlisted">Unlisted</option>
+						<option value="private">Private</option>
+					</select>
+				</span>
+			</p>
+			<br />
+			<p class="control">
+				<label class="checkbox party-mode-inner">
+					<input v-model="editing.partyMode" type="checkbox" />
+					&nbsp;Party mode
+				</label>
+			</p>
+			<small
+				>With party mode enabled, people can add songs to a queue that
+				plays. With party mode disabled you can play a private playlist
+				on loop.</small
+			>
+			<br />
+			<div v-if="station.partyMode">
+				<br />
+				<br />
+				<label class="label">Queue lock</label>
+				<small v-if="station.partyMode"
+					>With the queue locked, only owners (you) can add songs to
+					the queue.</small
+				>
+				<br />
+				<button
+					v-if="!station.locked"
+					class="button is-danger"
+					@click="$parent.toggleLock()"
+				>
+					Lock the queue
+				</button>
+				<button
+					v-if="station.locked"
+					class="button is-success"
+					@click="$parent.toggleLock()"
+				>
+					Unlock the queue
+				</button>
 			</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>
-		</modal>
-	</div>
+		</template>
+		<template v-slot:footer>
+			<button class="button is-success" v-on:click="update()">
+				Update Settings
+			</button>
+			<button
+				v-if="station.type === 'community'"
+				class="button is-danger"
+				@click="deleteStation()"
+			>
+				Delete station
+			</button>
+		</template>
+	</modal>
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
-	import Modal from './Modal.vue';
-	import io from '../../io';
-	import validation from '../../validation';
-
-	export default {
-		data: function() {
-			return {
-				editing: {
-					_id: '',
-					name: '',
-					type: '',
-					displayName: '',
-					description: '',
-					privacy: 'private',
-					partyMode: false
-				}
-			}
+import { mapState } from "vuex";
+
+import { Toast } from "vue-roaster";
+import Modal from "./Modal.vue";
+import io from "../../io";
+import validation from "../../validation";
+
+export default {
+	computed: mapState("station", {
+		station: state => state.station,
+		editing: state => state.editing
+	}),
+	mounted() {
+		const _this = this;
+		io.getSocket(socket => {
+			_this.socket = socket;
+			return socket;
+		});
+	},
+	methods: {
+		update() {
+			if (this.station.name !== this.editing.name) this.updateName();
+			if (this.station.displayName !== this.editing.displayName)
+				this.updateDisplayName();
+			if (this.station.description !== this.editing.description)
+				this.updateDescription();
+			if (this.station.privacy !== this.editing.privacy)
+				this.updatePrivacy();
+			if (this.station.partyMode !== this.editing.partyMode)
+				this.updatePartyMode();
 		},
-		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;
-						else {
-							this.$parent.stations.forEach((station, index) => {
-								if (station._id === this.editing._id) return this.$parent.stations[index].name = name;
-							});
+		updateName() {
+			const { name } = this.editing;
+			if (!validation.isLength(name, 2, 16))
+				return Toast.methods.addToast(
+					"Name must have between 2 and 16 characters.",
+					8000
+				);
+			if (!validation.regex.az09_.test(name))
+				return Toast.methods.addToast(
+					"Invalid name format. Allowed characters: a-z, 0-9 and _.",
+					8000
+				);
+
+			return this.socket.emit(
+				"stations.updateName",
+				this.editing._id,
+				name,
+				res => {
+					if (res.status === "success") {
+						if (this.station) {
+							this.station.name = name;
+							return name;
 						}
+
+						this.$parent.stations.forEach((station, index) => {
+							if (station._id === this.editing._id) {
+								this.$parent.stations[index].name = name;
+								return name;
+							}
+
+							return false;
+						});
 					}
-					Toast.methods.addToast(res.message, 8000);
-				});
-			},
-			updateDisplayName: 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);
 
+					return Toast.methods.addToast(res.message, 8000);
+				}
+			);
+		},
+		updateDisplayName() {
+			const { displayName } = this.editing;
+			if (!validation.isLength(displayName, 2, 32))
+				return Toast.methods.addToast(
+					"Display name must have between 2 and 32 characters.",
+					8000
+				);
+			if (!validation.regex.azAZ09_.test(displayName))
+				return Toast.methods.addToast(
+					"Invalid display name format. Allowed characters: a-z, A-Z, 0-9 and _.",
+					8000
+				);
 
-				this.socket.emit('stations.updateDisplayName', this.editing._id, displayName, res => {
-					if (res.status === 'success') {
-						if (this.$parent.station) _this.$parent.station.displayName = displayName;
+			return this.socket.emit(
+				"stations.updateDisplayName",
+				this.editing._id,
+				displayName,
+				res => {
+					if (res.status === "success") {
+						if (this.station)
+							this.station.displayName = displayName;
 						else {
 							this.$parent.stations.forEach((station, index) => {
-								if (station._id === this.editing._id) return this.$parent.stations[index].displayName = displayName;
+								if (station._id === this.editing._id)
+									this.$parent.stations[
+										index
+									].displayName = displayName;
+								return displayName;
 							});
 						}
 					}
 					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;
-				});
-				if (characters.length !== 0) return Toast.methods.addToast('Invalid description format. Swastika\'s are not allowed.', 8000);
-
-
-				this.socket.emit('stations.updateDescription', this.editing._id, description, res => {
-					if (res.status === 'success') {
-						if (_this.$parent.station) _this.$parent.station.description = description;
-						else {
-							_this.$parent.stations.forEach((station, index) => {
-								if (station._id === station._id) return _this.$parent.stations[index].description = description;
-							});
+				}
+			);
+		},
+		updateDescription() {
+			const _this = this;
+
+			const { description } = this.editing;
+			if (!validation.isLength(description, 2, 200))
+				return Toast.methods.addToast(
+					"Description must have between 2 and 200 characters.",
+					8000
+				);
+
+			let characters = description.split("");
+			characters = characters.filter(character => {
+				return character.charCodeAt(0) === 21328;
+			});
+
+			if (characters.length !== 0)
+				return Toast.methods.addToast(
+					"Invalid description format. Swastika's are not allowed.",
+					8000
+				);
+
+			return this.socket.emit(
+				"stations.updateDescription",
+				this.editing._id,
+				description,
+				res => {
+					if (res.status === "success") {
+						if (_this.station) {
+							_this.station.description = description;
+							return description;
 						}
+
+						_this.$parent.stations.forEach((station, index) => {
+							if (station._id === this.editing._id) {
+								_this.$parent.stations[
+									index
+								].description = description;
+								return description;
+							}
+
+							return false;
+						});
+
 						return Toast.methods.addToast(res.message, 4000);
 					}
-					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;
-						else {
-							_this.$parent.stations.forEach((station, index) => {
-								if (station._id === station._id) return _this.$parent.stations[index].privacy = _this.editing.privacy;
-							});
+
+					return Toast.methods.addToast(res.message, 8000);
+				}
+			);
+		},
+		updatePrivacy() {
+			const _this = this;
+			return 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;
+							return _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);
 					}
-					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, 8000);
+				}
+			);
+		},
+		updatePartyMode() {
+			const _this = this;
+			return 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;
+							return _this.editing.partyMode;
 						}
+
+						_this.$parent.stations.forEach((station, index) => {
+							if (station._id === _this.editing._id) {
+								_this.$parent.stations[index].partyMode =
+									_this.editing.partyMode;
+								return _this.editing.partyMode;
+							}
+
+							return false;
+						});
+
 						return Toast.methods.addToast(res.message, 4000);
 					}
-					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);
-				});
-			}
-		},
-		ready: function () {
-			let _this = this;
-			io.getSocket(socket => {
-				_this.socket = socket;
-			});
-		},
-		events: {
-			closeModal: function() {
-				this.$parent.modals.editStation = false;
-			},
-			editStation: function(station) {
-				for (let prop in station) {
-					this.editing[prop] = station[prop];
+
+					return Toast.methods.addToast(res.message, 8000);
 				}
-				this.$parent.modals.editStation = true;
-			}
+			);
 		},
-		components: { Modal }
-	}
+		deleteStation() {
+			this.socket.emit("stations.remove", this.editing._id, res => {
+				Toast.methods.addToast(res.message, 8000);
+			});
+		}
+	},
+	components: { Modal }
+};
 </script>
 
-<style type='scss' scoped>
-	.controls {
-		display: flex;
+<style lang="scss" scoped>
+.controls {
+	display: flex;
 
-		a {
-			display: flex;
-    		align-items: center;
-		}
+	a {
+		display: flex;
+		align-items: center;
 	}
+}
 
-	.table { margin-bottom: 0; }
+.table {
+	margin-bottom: 0;
+}
 
-	h5 { padding: 20px 0; }
+h5 {
+	padding: 20px 0;
+}
 
-	.party-mode-inner, .party-mode-outer {
-		display: flex;
-		align-items: center;
-	}
+.party-mode-inner,
+.party-mode-outer {
+	display: flex;
+	align-items: center;
+}
 
-	.select:after { border-color: #029ce3; }
+.select:after {
+	border-color: #029ce3;
+}
 </style>

+ 174 - 94
frontend/components/Modals/EditUser.vue

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

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

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

+ 113 - 51
frontend/components/Modals/Login.vue

@@ -1,74 +1,136 @@
 <template>
-	<div class='modal is-active'>
-		<div class='modal-background'></div>
-		<div class='modal-card'>
-			<header class='modal-card-head'>
-				<p class='modal-card-title'>Login</p>
-				<button class='delete' @click='toggleModal()'></button>
+	<div class="modal is-active">
+		<div
+			class="modal-background"
+			@click="
+				closeModal({
+					sector: 'header',
+					modal: 'login'
+				})
+			"
+		/>
+		<div class="modal-card">
+			<header class="modal-card-head">
+				<p class="modal-card-title">
+					Login
+				</p>
+				<button
+					class="delete"
+					@click="
+						closeModal({
+							sector: 'header',
+							modal: 'login'
+						})
+					"
+				/>
 			</header>
-			<section class='modal-card-body'>
+			<section class="modal-card-body">
 				<!-- validation to check if exists http://bulma.io/documentation/elements/form/ -->
-				<label class='label'>Email</label>
-				<p class='control'>
-					<input class='input' type='text' placeholder='Email...' v-model='$parent.login.email'>
+				<label class="label">Email</label>
+				<p class="control">
+					<input
+						v-model="email"
+						class="input"
+						type="text"
+						placeholder="Email..."
+					/>
+				</p>
+				<label class="label">Password</label>
+				<p class="control">
+					<input
+						v-model="password"
+						class="input"
+						type="password"
+						placeholder="Password..."
+						@keypress="$parent.submitOnEnter(submitModal, $event)"
+					/>
 				</p>
-				<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>
+					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>By logging in/registering you agree to our <a href="/terms" v-link="{ path: '/terms' }">Terms of Service</a> and <a href="/privacy" v-link="{ path: '/privacy' }">Privacy Policy</a>.</p>
 			</section>
-			<footer class='modal-card-foot'>
-				<a class='button is-primary' href='#' @click='submitModal("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('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" />
 					</div>
 					&nbsp;&nbsp;Login with GitHub
 				</a>
-				<a href='/reset_password' @click='resetPassword()'>Forgot password?</a>
+				<a href="/reset_password" v-on:click="resetPassword()"
+					>Forgot password?</a
+				>
 			</footer>
 		</div>
 	</div>
 </template>
 
 <script>
-	export default {
-		methods: {
-			toggleModal: function () {
-				if (this.$router._currentRoute.path === '/login') location.href = '/';
-				else this.$dispatch('toggleModal', 'login');
-			},
-			submitModal: function () {
-				this.$dispatch('login');
-				this.toggleModal();
-			},
-			resetPassword: function () {
-				this.toggleModal();
-				this.$router.go('/reset_password');
-			},
-			githubRedirect: function() {
-			    localStorage.setItem('github_redirect', this.$route.path)
-			}
+import { mapActions } from "vuex";
+
+import { Toast } from "vue-roaster";
+
+export default {
+	data() {
+		return {
+			email: "",
+			password: ""
+		};
+	},
+	methods: {
+		submitModal() {
+			this.login({
+				email: this.email,
+				password: this.password
+			})
+				.then(res => {
+					if (res.status === "success") window.location.reload();
+				})
+				.catch(err => Toast.methods.addToast(err.message, 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"])
 	}
+};
 </script>
 
-<style type='scss' scoped>
-	.button.is-github {
-		background-color: #333;
-		color: #fff !important;
-	}
+<style lang="scss" scoped>
+.button.is-github {
+	background-color: #333;
+	color: #fff !important;
+}
 
-	.is-github:focus { background-color: #1a1a1a; }
-	.is-primary:focus { background-color: #029ce3 !important; }
+.is-github:focus {
+	background-color: #1a1a1a;
+}
+.is-primary:focus {
+	background-color: #029ce3 !important;
+}
 
-	.invert { filter: brightness(5); }
+.invert {
+	filter: brightness(5);
+}
 
-	a { color: #029ce3; }
+a {
+	color: #029ce3;
+}
 </style>

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

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

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

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

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

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

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

@@ -1,267 +1,418 @@
 <template>
-	<modal title='Edit Playlist'>
-		<div slot='body'>
+	<modal title="Edit Playlist">
+		<div slot="body">
 			<nav class="level">
 				<div class="level-item has-text-centered">
 					<div>
-						<p class="heading">Total Length</p>
-						<p class="title">{{ totalLength() }}</p>
+						<p class="heading">
+							Total Length
+						</p>
+						<p class="title">
+							{{ totalLength() }}
+						</p>
 					</div>
 				</div>
 			</nav>
 			<hr />
-			<aside class='menu' v-if='playlist.songs && playlist.songs.length > 0'>
-				<ul class='menu-list'>
-					<li v-for='song in playlist.songs' track-by='$index'>
-						<a :href='' target='_blank'>{{ song.title }}</a>
-						<div class='controls'>
-							<a href='#' @click='promoteSong(song.songId)'>
-								<i class='material-icons' v-if='$index > 0'>keyboard_arrow_up</i>
-								<i class='material-icons' style='opacity: 0' v-else>error</i>
+			<aside class="menu">
+				<ul class="menu-list">
+					<li v-for="(song, index) in playlist.songs" :key="index">
+						<a href="#" target="_blank">{{ song.title }}</a>
+						<div class="controls">
+							<a href="#" v-on:click="promoteSong(song.songId)">
+								<i class="material-icons" v-if="index > 0"
+									>keyboard_arrow_up</i
+								>
+								<i
+									v-else
+									class="material-icons"
+									style="opacity: 0"
+									>error</i
+								>
 							</a>
-							<a href='#' @click='demoteSong(song.songId)'>
-								<i class='material-icons' v-if='playlist.songs.length - 1 !== $index'>keyboard_arrow_down</i>
-								<i class='material-icons' style='opacity: 0' v-else>error</i>
+							<a href="#" v-on:click="demoteSong(song.songId)">
+								<i
+									v-if="playlist.songs.length - 1 !== index"
+									class="material-icons"
+									>keyboard_arrow_down</i
+								>
+								<i
+									v-else
+									class="material-icons"
+									style="opacity: 0"
+									>error</i
+								>
+							</a>
+							<a
+								href="#"
+								v-on:click="removeSongFromPlaylist(song.songId)"
+							>
+								<i class="material-icons">delete</i>
 							</a>
-							<a href='#' @click='removeSongFromPlaylist(song.songId)'><i class='material-icons'>delete</i></a>
 						</div>
 					</li>
 				</ul>
 				<br />
 			</aside>
-			<div class='control is-grouped'>
-				<p class='control is-expanded'>
-					<input class='input' type='text' placeholder='Search for Song to add' v-model='songQuery' autofocus @keyup.enter='searchForSongs()'>
+			<div class="control is-grouped">
+				<p class="control is-expanded">
+					<input
+						v-model="songQuery"
+						class="input"
+						type="text"
+						placeholder="Search for Song to add"
+						autofocus
+						@keyup.enter="searchForSongs()"
+					/>
 				</p>
-				<p class='control'>
-					<a class='button is-info' @click='searchForSongs()' href="#">Search</a>
+				<p class="control">
+					<a class="button is-info" @click="searchForSongs()" href="#"
+						>Search</a
+					>
 				</p>
 			</div>
-			<table class='table' v-if='songQueryResults.length > 0'>
+			<table v-if="songQueryResults.length > 0" class="table">
 				<tbody>
-				<tr v-for='result in songQueryResults'>
-					<td>
-						<img :src='result.thumbnail' />
-					</td>
-					<td>{{ result.title }}</td>
-					<td>
-						<a class='button is-success' @click='addSongToPlaylist(result.id)' href='#'>
-							Add
-						</a>
-					</td>
-				</tr>
+					<tr
+						v-for="(result, index) in songQueryResults"
+						:key="index"
+					>
+						<td>
+							<img :src="result.thumbnail" />
+						</td>
+						<td>{{ result.title }}</td>
+						<td>
+							<a
+								class="button is-success"
+								href="#"
+								@click="addSongToPlaylist(result.id)"
+								>Add</a
+							>
+						</td>
+					</tr>
 				</tbody>
 			</table>
-			<div class='control is-grouped'>
-				<p class='control is-expanded'>
-					<input class='input' type='text' placeholder='YouTube Playlist URL' v-model='importQuery' @keyup.enter="importPlaylist()">
+			<div class="control is-grouped">
+				<p class="control is-expanded">
+					<input
+						v-model="importQuery"
+						class="input"
+						type="text"
+						placeholder="YouTube Playlist URL"
+						@keyup.enter="importPlaylist()"
+					/>
 				</p>
-				<p class='control'>
-					<a class='button is-info' @click='importPlaylist()' href="#">Import</a>
+				<p class="control">
+					<a class="button is-info" @click="importPlaylist()" href="#"
+						>Import</a
+					>
 				</p>
 			</div>
 			<h5>Edit playlist details:</h5>
-			<div class='control is-grouped'>
-				<p class='control is-expanded'>
-					<input class='input' type='text' placeholder='Playlist Display Name' v-model='playlist.displayName' @keyup.enter="renamePlaylist()">
+			<div class="control is-grouped">
+				<p class="control is-expanded">
+					<input
+						v-model="playlist.displayName"
+						class="input"
+						type="text"
+						placeholder="Playlist Display Name"
+						@keyup.enter="renamePlaylist()"
+					/>
 				</p>
-				<p class='control'>
-					<a class='button is-info' @click='renamePlaylist()' href="#">Rename</a>
+				<p class="control">
+					<a class="button is-info" @click="renamePlaylist()" href="#"
+						>Rename</a
+					>
 				</p>
 			</div>
 		</div>
-		<div slot='footer'>
-			<a class='button is-danger' @click='removePlaylist()' href="#">Remove Playlist</a>
+		<div slot="footer">
+			<a class="button is-danger" v-on:click="removePlaylist()" href="#"
+				>Remove Playlist</a
+			>
 		</div>
 	</modal>
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
-	import Modal from '../Modal.vue';
-	import io from '../../../io';
-	import validation from '../../../validation';
+import { mapState } 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 "vue-roaster";
+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() {
+		const _this = this;
+		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);
+					});
+				}
+			});
+			_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);
 				}
-				if (query.indexOf('&list=') !== -1) {
-					query = query.split('&list=');
-					query.pop();
-					query = query.join('');
+			});
+			_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);
 				}
-				_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);
+			});
+		});
+	},
+	methods: {
+		formatTime(length) {
+			const duration = moment.duration(length, "seconds");
+			const getHours = () => {
+				return Math.floor(duration.asHours());
+			};
 
+			if (length <= 0) return "0 seconds";
 
-				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 formatHours = () => {
+				if (getHours() > 0) {
+					if (getHours() > 1) {
+						if (getHours() < 10) return `0${getHours()} hours `;
+						return `${getHours()} hours `;
 					}
-				});
-			},
-			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${getHours()} hour `;
+				}
+				return "";
+			};
+
+			const formatMinutes = () => {
+				if (duration.minutes() > 0) {
+					if (duration.minutes() > 1) {
+						if (duration.minutes() < 10)
+							return `0${duration.minutes()} minutes `;
+						return `${duration.minutes()} minutes `;
 					}
-				});
-				_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;
-						});
-						let song = _this.playlist.songs.splice(songIndex, 1)[0];
-						_this.playlist.songs.push(song);
+					return `0${duration.minutes()} minute `;
+				}
+				return "";
+			};
+
+			const formatSeconds = () => {
+				if (duration.seconds() > 0) {
+					if (duration.seconds() > 1) {
+						if (duration.seconds() < 10)
+							return `0${duration.seconds()} seconds `;
+						return `${duration.seconds()} seconds `;
 					}
-				});
-				_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;
+					return `0${duration.seconds()} second `;
+				}
+				return "";
+			};
+
+			return formatHours() + formatMinutes() + formatSeconds();
+		},
+		totalLength() {
+			let length = 0;
+			this.playlist.songs.forEach(song => {
+				length += song.duration;
+			});
+			return this.formatTime(length);
+		},
+		searchForSongs() {
+			const _this = this;
+			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.unshift(song);
 					}
-				});
+				} else if (res.status === "error")
+					Toast.methods.addToast(res.message, 3000);
 			});
 		},
-		events: {
-			closeModal: function() {
-				this.$parent.modals.editPlaylist = !this.$parent.modals.editPlaylist;
-			}
+		addSongToPlaylist(id) {
+			const _this = this;
+			_this.socket.emit(
+				"playlists.addSongToPlaylist",
+				id,
+				_this.playlist._id,
+				res => {
+					Toast.methods.addToast(res.message, 4000);
+				}
+			);
+		},
+		importPlaylist() {
+			const _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(id) {
+			const _this = this;
+			this.socket.emit(
+				"playlists.removeSongFromPlaylist",
+				id,
+				_this.playlist._id,
+				res => {
+					Toast.methods.addToast(res.message, 4000);
+				}
+			);
+		},
+		renamePlaylist() {
+			const { displayName } = this.playlist;
+			if (!validation.isLength(displayName, 2, 32))
+				return Toast.methods.addToast(
+					"Display name must have between 2 and 32 characters.",
+					8000
+				);
+			if (!validation.regex.azAZ09_.test(displayName))
+				return Toast.methods.addToast(
+					"Invalid display name format. Allowed characters: a-z, A-Z, 0-9 and _.",
+					8000
+				);
+
+			return this.socket.emit(
+				"playlists.updateDisplayName",
+				this.playlist._id,
+				this.playlist.displayName,
+				res => {
+					Toast.methods.addToast(res.message, 4000);
+				}
+			);
+		},
+		removePlaylist() {
+			const _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;
+				}
+			});
+		},
+		promoteSong(songId) {
+			const _this = this;
+			_this.socket.emit(
+				"playlists.moveSongToTop",
+				_this.playlist._id,
+				songId,
+				res => {
+					Toast.methods.addToast(res.message, 4000);
+				}
+			);
+		},
+		demoteSong(songId) {
+			const _this = this;
+			_this.socket.emit(
+				"playlists.moveSongToBottom",
+				_this.playlist._id,
+				songId,
+				res => {
+					Toast.methods.addToast(res.message, 4000);
+				}
+			);
 		}
 	}
+};
 </script>
 
-<style type='scss' scoped>
-	.menu { padding: 0 20px; }
+<style lang="scss" scoped>
+.menu {
+	padding: 0 20px;
+}
 
-	.menu-list li {
-		display: flex;
-		justify-content: space-between;
-	}
+.menu-list li {
+	display: flex;
+	justify-content: space-between;
+}
 
-	.menu-list a:hover { color: #000 !important; }
+.menu-list a:hover {
+	color: #000 !important;
+}
 
-	li a {
-		display: flex;
-    	align-items: center;
-	}
+li a {
+	display: flex;
+	align-items: center;
+}
 
-	.controls {
-		display: flex;
+.controls {
+	display: flex;
 
-		a {
-			display: flex;
-    		align-items: center;
-		}
+	a {
+		display: flex;
+		align-items: center;
 	}
+}
 
-	.table {
-		margin-bottom: 0;
-	}
+.table {
+	margin-bottom: 0;
+}
 
-	h5 { padding: 20px 0; }
+h5 {
+	padding: 20px 0;
+}
 </style>

+ 144 - 64
frontend/components/Modals/Register.vue

@@ -1,33 +1,78 @@
 <template>
-	<div class='modal is-active'>
-		<div class='modal-background'></div>
-		<div class='modal-card'>
-			<header class='modal-card-head'>
-				<p class='modal-card-title'>Register</p>
-				<button class='delete' @click='toggleModal()'></button>
+	<div class="modal is-active">
+		<div
+			class="modal-background"
+			@click="
+				closeModal({
+					sector: 'header',
+					modal: 'register'
+				})
+			"
+		/>
+		<div class="modal-card">
+			<header class="modal-card-head">
+				<p class="modal-card-title">
+					Register
+				</p>
+				<button
+					class="delete"
+					@click="
+						closeModal({
+							sector: 'header',
+							modal: 'register'
+						})
+					"
+				/>
 			</header>
-			<section class='modal-card-body'>
+			<section class="modal-card-body">
 				<!-- validation to check if exists http://bulma.io/documentation/elements/form/ -->
-				<label class='label'>Email</label>
-				<p class='control'>
-					<input class='input' type='text' placeholder='Email...' v-model='$parent.register.email' autofocus>
+				<label class="label">Email</label>
+				<p class="control">
+					<input
+						v-model="email"
+						class="input"
+						type="text"
+						placeholder="Email..."
+						autofocus
+					/>
+				</p>
+				<label class="label">Username</label>
+				<p class="control">
+					<input
+						v-model="username"
+						class="input"
+						type="text"
+						placeholder="Username..."
+					/>
 				</p>
-				<label class='label'>Username</label>
-				<p class='control'>
-					<input class='input' type='text' placeholder='Username...' v-model='$parent.register.username'>
+				<label class="label">Password</label>
+				<p class="control">
+					<input
+						v-model="password"
+						class="input"
+						type="password"
+						placeholder="Password..."
+						@keypress="$parent.submitOnEnter(submitModal, $event)"
+					/>
 				</p>
-				<label class='label'>Password</label>
-				<p class='control'>
-					<input class='input' type='password' placeholder='Password...' v-model='$parent.register.password' v-on:keypress='$parent.submitOnEnter(submitModal, $event)'>
+				<p>
+					By logging in/registering you agree to our
+					<router-link to="/terms"> Terms of Service </router-link
+					>&nbsp;and
+					<router-link to="/privacy"> Privacy Policy </router-link>.
 				</p>
-				<div id="recaptcha"></div>
-				<p>By logging in/registering you agree to our <a href="/terms" v-link="{ path: '/terms' }">Terms of Service</a> and <a href="/privacy" v-link="{ path: '/privacy' }">Privacy Policy</a>.</p>
 			</section>
-			<footer class='modal-card-foot'>
-				<a class='button is-primary' href='#' @click='submitModal()'>Submit</a>
-				<a class='button is-github' :href='$parent.serverDomain + "/auth/github/authorize"' @click="githubRedirect()">
-					<div class='icon'>
-						<img class='invert' src='/assets/social/github.svg'/>
+			<footer class="modal-card-foot">
+				<a class="button is-primary" href="#" @click="submitModal()"
+					>Submit</a
+				>
+				<a
+					class="button is-github"
+					:href="$parent.serverDomain + '/auth/github/authorize'"
+					@click="githubRedirect()"
+				>
+					<div class="icon">
+						<img class="invert" src="/assets/social/github.svg" />
 					</div>
 					&nbsp;&nbsp;Register with GitHub
 				</a>
@@ -37,56 +82,91 @@
 </template>
 
 <script>
-	export default {
-		data() {
-			return {
-				recaptcha: {
-					key: ''
-				}
+import { mapActions } from "vuex";
+
+import { Toast } from "vue-roaster";
+
+export default {
+	data() {
+		return {
+			username: "",
+			email: "",
+			password: "",
+			recaptcha: {
+				key: "",
+				token: ""
 			}
-		},
-		ready: function () {
-			let _this = this;
-			lofig.get('recaptcha', obj => {
-				_this.recaptcha.key = obj.key;
-				_this.recaptcha.id = grecaptcha.render('recaptcha', {
-					'sitekey' : _this.recaptcha.key
+		};
+	},
+	mounted() {
+		const _this = this;
+		lofig.get("recaptcha", 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() {
+			console.log(this.recaptcha.token);
+
+			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 => Toast.methods.addToast(err.message, 5000));
 		},
-		methods: {
-			toggleModal: function () {
-				if (this.$router._currentRoute.path === '/register') location.href = '/';
-				else this.$dispatch('toggleModal', 'register');
-			},
-			submitModal: function () {
-				this.$dispatch('register', this.recaptcha.id);
-				this.toggleModal();
-			},
-			githubRedirect: function() {
-				localStorage.setItem('github_redirect', this.$route.path)
-			}
+		githubRedirect() {
+			localStorage.setItem("github_redirect", this.$route.path);
 		},
-		events: {
-			closeModal: function() {
-				this.$dispatch('toggleModal', 'register');
-			}
-		}
+		...mapActions("modals", ["closeModal"]),
+		...mapActions("user/auth", ["register"])
 	}
+};
 </script>
 
-<style type='scss' scoped>
-	.button.is-github {
-		background-color: #333;
-		color: #fff !important;
-	}
+<style lang="scss" scoped>
+.button.is-github {
+	background-color: #333;
+	color: #fff !important;
+}
 
-	.is-github:focus { background-color: #1a1a1a; }
-	.is-primary:focus { background-color: #028bca !important; }
+.is-github:focus {
+	background-color: #1a1a1a;
+}
+.is-primary:focus {
+	background-color: #028bca !important;
+}
 
-	.invert { filter: brightness(5); }
+.invert {
+	filter: brightness(5);
+}
 
-	#recaptcha { padding: 10px 0; }
+#recaptcha {
+	padding: 10px 0;
+}
 
-	a { color: #029ce3; }
+a {
+	color: #029ce3;
+}
 </style>

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

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

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

@@ -1,21 +1,53 @@
 <template>
 	<div>
-		<modal title='View Punishment'>
-			<div slot='body'>
+		<modal title="View Punishment">
+			<div slot="body">
 				<article class="message">
 					<div class="message-body">
-						<strong>Type: </strong>{{ punishment.type }}<br/>
-						<strong>Value: </strong>{{ punishment.value }}<br/>
-						<strong>Reason: </strong>{{ punishment.reason }}<br/>
-						<strong>Active: </strong>{{ punishment.active }}<br/>
-						<strong>Expires at: </strong>{{ moment(punishment.expiresAt).format('MMMM Do YYYY, h:mm:ss a'); }} ({{ moment(punishment.expiresAt).fromNow() }})<br/>
-						<strong>Punished at: </strong>{{ moment(punishment.punishedAt).format('MMMM Do YYYY, h:mm:ss a') }} ({{ moment(punishment.punishedAt).fromNow() }})<br/>
-						<strong>Punished by: </strong>{{ punishment.punishedBy }}<br/>
+						<strong>Type:</strong>
+						{{ punishment.type }}
+						<br />
+						<strong>Value:</strong>
+						{{ punishment.value }}
+						<br />
+						<strong>Reason:</strong>
+						{{ punishment.reason }}
+						<br />
+						<strong>Active:</strong>
+						{{ punishment.active }}
+						<br />
+						<strong>Expires at:</strong>
+						{{
+							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 />
 					</div>
 				</article>
 			</div>
-			<div slot='footer'>
-				<button class='button is-danger' @click='$parent.toggleModal()'>
+			<div slot="footer">
+				<button
+					class="button is-danger"
+					@click="
+						closeModal({
+							sector: 'admin',
+							modal: 'viewPunishment'
+						})
+					"
+				>
 					<span>&nbsp;Close</span>
 				</button>
 			</div>
@@ -24,41 +56,33 @@
 </template>
 
 <script>
-	import io from '../../io';
-	import { Toast } from 'vue-roaster';
-	import Modal from './Modal.vue';
-	import validation from '../../validation';
+import { mapState, mapActions } from "vuex";
 
-	export default {
-		components: { Modal },
-		data() {
-			return {
-				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";
+
+export default {
+	components: { Modal },
+	data() {
+		return {
+			ban: {},
+			moment
+		};
+	},
+	computed: {
+		...mapState("admin/punishments", {
+			punishment: state => state.punishment
+		})
+	},
+	mounted() {
+		const _this = this;
+		io.getSocket(socket => {
+			_this.socket = socket;
+			return socket;
+		});
+	},
+	methods: {
+		...mapActions("modals", ["closeModal"])
 	}
-</script>
+};
+</script>

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

@@ -1,37 +1,69 @@
 <template>
-	<div class='modal' :class='{ "is-active": isModalActive }' v-if='news !== null'>
-		<div class='modal-background'></div>
-		<div class='modal-card'>
-			<header class='modal-card-head'>
-				<p class='modal-card-title'><strong>{{ news.title }}</strong> ({{ formatDate(news.createdAt) }})</p>
-				<button class='delete' @click='toggleModal()'></button>
+	<div
+		v-if="news !== null"
+		class="modal"
+		:class="{ 'is-active': isModalActive }"
+	>
+		<div class="modal-background" />
+		<div class="modal-card">
+			<header class="modal-card-head">
+				<p class="modal-card-title">
+					<strong>{{ news.title }}</strong>
+					({{ formatDate(news.createdAt) }})
+				</p>
+				<button class="delete" v-on:click="toggleModal()" />
 			</header>
-			<section class='modal-card-body'>
-				<div class='content'>
+			<section class="modal-card-body">
+				<div class="content">
 					<p>{{ news.description }}</p>
 				</div>
-				<div class='sect' v-show='news.features.length > 0'>
-					<div class='sect-head-features'>The features are so great</div>
-					<ul class='sect-body'>
-						<li v-for='li in news.features'>{{ li }}</li>
+				<div v-show="news.features.length > 0" class="sect">
+					<div class="sect-head-features">
+						The features are so great
+					</div>
+					<ul class="sect-body">
+						<li
+							v-for="(feature, index) in news.features"
+							:key="index"
+						>
+							{{ feature }}
+						</li>
 					</ul>
 				</div>
-				<div class='sect' v-show='news.improvements.length > 0'>
-					<div class='sect-head-improvements'>Improvements</div>
-					<ul class='sect-body'>
-						<li v-for='li in news.improvements'>{{ li }}</li>
+				<div v-show="news.improvements.length > 0" class="sect">
+					<div class="sect-head-improvements">
+						Improvements
+					</div>
+					<ul class="sect-body">
+						<li
+							v-for="(improvement, index) in news.improvements"
+							:key="index"
+						>
+							{{ improvement }}
+						</li>
 					</ul>
 				</div>
-				<div class='sect' v-show='news.bugs.length > 0'>
-					<div class='sect-head-bugs'>Bugs Smashed</div>
-					<ul class='sect-body'>
-						<li v-for='li in news.bugs'>{{ li }}</li>
+				<div v-show="news.bugs.length > 0" class="sect">
+					<div class="sect-head-bugs">
+						Bugs Smashed
+					</div>
+					<ul class="sect-body">
+						<li v-for="(bug, index) in news.bugs" :key="index">
+							{{ bug }}
+						</li>
 					</ul>
 				</div>
-				<div class='sect' v-show='news.upcoming.length > 0'>
-					<div class='sect-head-upcoming'>Coming Soon to a Musare near you</div>
-					<ul class='sect-body'>
-						<li v-for='li in news.upcoming'>{{ li }}</li>
+				<div v-show="news.upcoming.length > 0" class="sect">
+					<div class="sect-head-upcoming">
+						Coming Soon to a Musare near you
+					</div>
+					<ul class="sect-body">
+						<li
+							v-for="(upcoming, index) in news.upcoming"
+							:key="index"
+						>
+							{{ upcoming }}
+						</li>
 					</ul>
 				</div>
 			</section>
@@ -40,88 +72,114 @@
 </template>
 
 <script>
-	import io from '../../io';
+import io from "../../io";
 
-	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);
+export default {
+	data() {
+		return {
+			isModalActive: false,
+			news: null
+		};
+	},
+	mounted() {
+		const _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 (!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 moment(unix).format("DD-MM-YYYY");
+		}
+	},
+	events: {
+		closeModal() {
+			this.isModalActive = false;
 		}
 	}
+};
 </script>
 
-<style lang='scss' scoped>
-	.modal-card-head {
-		border-bottom: none;
-		background-color: ghostwhite;
-		padding: 15px;
-	}
+<style lang="scss" scoped>
+.modal-card-head {
+	border-bottom: none;
+	background-color: ghostwhite;
+	padding: 15px;
+}
 
-	.modal-card-title { font-size: 14px; }
+.modal-card-title {
+	font-size: 14px;
+}
 
-	.delete {
+.delete {
+	background: transparent;
+	&:hover {
 		background: transparent;
-		&:hover { background: transparent; }
+	}
 
-		&:before, &:after { background-color: #bbb; }
+	&:before,
+	&:after {
+		background-color: #bbb;
 	}
+}
 
-	.sect {
-		div[class^='sect-head'], div[class*=' sect-head']{
-			padding: 12px;
-			text-transform: uppercase;
-			font-weight: bold;
-			color: #fff;
-		}
+.sect {
+	div[class^="sect-head"],
+	div[class*=" sect-head"] {
+		padding: 12px;
+		text-transform: uppercase;
+		font-weight: bold;
+		color: #fff;
+	}
 
-		.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>

+ 176 - 131
frontend/components/Sidebars/Playlist.vue

@@ -1,160 +1,205 @@
 <template>
-	<div class='sidebar' transition='slide' v-if='$parent.sidebars.playlist'>
-		<div class='inner-wrapper'>
-			<div class='title'>Playlists</div>
-
-			<aside class='menu' v-if='playlists.length > 0'>
-				<ul class='menu-list'>
-					<li v-for='playlist in playlists'>
+	<div class="sidebar" transition="slide">
+		<div class="inner-wrapper">
+			<div class="title">
+				Playlists
+			</div>
+
+			<aside v-if="playlists.length > 0" class="menu">
+				<ul class="menu-list">
+					<li v-for="(playlist, index) in playlists" :key="index">
 						<span>{{ playlist.displayName }}</span>
 						<!--Will play playlist in community station Kris-->
-						<div class='icons-group'>
-							<a href='#' @click='selectPlaylist(playlist._id)' v-if="isNotSelected(playlist._id) && !this.$parent.$parent.station.partyMode">
-								<i class='material-icons'>play_arrow</i>
+						<div class="icons-group">
+							<a
+								v-if="
+									isNotSelected(playlist._id) &&
+										!$parent.station.partyMode
+								"
+								href="#"
+								@click="selectPlaylist(playlist._id)"
+							>
+								<i class="material-icons">play_arrow</i>
 							</a>
-							<a href='#' @click='editPlaylist(playlist._id)'>
-								<i class='material-icons'>edit</i>
+							<a href="#" v-on:click="edit(playlist._id)">
+								<i class="material-icons">edit</i>
 							</a>
 						</div>
 					</li>
 				</ul>
 			</aside>
 
-			<div class='none-found' v-else>No Playlists found</div>
-
-			<a class='button create-playlist' href='#' @click='$parent.modals.createPlaylist = !$parent.modals.createPlaylist'>Create Playlist</a>
+			<div v-else class="none-found">
+				No Playlists found
+			</div>
+
+			<a
+				class="button create-playlist"
+				href="#"
+				@click="
+					openModal({ sector: 'station', modal: 'createPlaylist' })
+				"
+				>Create Playlist</a
+			>
 		</div>
 	</div>
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
-	import { Edit } from '../Modals/Playlists/Edit.vue';
-	import io from '../../io';
-
-	export default {
-		data() {
-			return {
-				playlists: []
-			}
+import { mapActions } from "vuex";
+
+import { Toast } from "vue-roaster";
+import io from "../../io";
+
+export default {
+	data() {
+		return {
+			playlists: []
+		};
+	},
+	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.$parent.station._id,
+				id,
+				res => {
+					if (res.status === "failure")
+						return Toast.methods.addToast(res.message, 8000);
+					return Toast.methods.addToast(res.message, 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) {
+			const _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;
+		},
+		...mapActions("modals", ["openModal"]),
+		...mapActions("user/playlists", ["editPlaylist"])
+	},
+	mounted() {
+		// TODO: Update when playlist is removed/created
+		const _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);
+					}
 				});
-				_this.socket.on('event:playlist.addSong', (data) => {
-					_this.playlists.forEach((playlist, index) => {
-						if (playlist._id === data.playlistId) {
-							_this.playlists[index].songs.push(data.song);
-						}
-					});
+			});
+			_this.socket.on("event:playlist.addSong", data => {
+				_this.playlists.forEach((playlist, index) => {
+					if (playlist._id === data.playlistId) {
+						_this.playlists[index].songs.push(data.song);
+					}
 				});
-				_this.socket.on('event:playlist.removeSong', (data) => {
-					_this.playlists.forEach((playlist, index) => {
-						if (playlist._id === data.playlistId) {
-							_this.playlists[index].songs.forEach((song, index2) => {
-								if (song._id === data.songId) {
-									_this.playlists[index].songs.splice(index2, 1);
-								}
-							});
-						}
-					});
+			});
+			_this.socket.on("event:playlist.removeSong", data => {
+				_this.playlists.forEach((playlist, index) => {
+					if (playlist._id === data.playlistId) {
+						_this.playlists[index].songs.forEach((song, index2) => {
+							if (song._id === data.songId) {
+								_this.playlists[index].songs.splice(index2, 1);
+							}
+						});
+					}
 				});
-				_this.socket.on('event:playlist.updateDisplayName', (data) => {
-					_this.playlists.forEach((playlist, index) => {
-						if (playlist._id === data.playlistId) {
-							_this.playlists[index].displayName = data.displayName;
-						}
-					});
+			});
+			_this.socket.on("event:playlist.updateDisplayName", data => {
+				_this.playlists.forEach((playlist, index) => {
+					if (playlist._id === data.playlistId) {
+						_this.playlists[index].displayName = data.displayName;
+					}
 				});
 			});
-		}
+		});
 	}
+};
 </script>
 
-<style type='scss' scoped>
-	.sidebar {
-		position: fixed;
-		z-index: 1;
-		top: 0;
-		right: 0;
-		width: 300px;
-		height: 100vh;
-		background-color: #fff;
-		box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16), 0 2px 10px 0 rgba(0, 0, 0, 0.12);
-	}
-
-	.icons-group a {
-		display: flex;
-    	align-items: center;
-	}
-
-	.menu-list li { align-items: center; }
-
-	.inner-wrapper {	
-		top: 64px;
-		position: relative;
-	}
-
-	.slide-transition {
-		transition: transform 0.6s ease-in-out;
-		transform: translateX(0);
-	}
-
-	.slide-enter, .slide-leave { transform: translateX(100%); }
-
-	.title {
-		background-color: rgb(255, 69, 69);
-		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>
+.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;
+	border: 0;
+
+	&:active,
+	&:focus {
 		border: 0;
-
-		&:active, &:focus { border: 0; }
 	}
+}
 
-	.create-playlist:focus { background: #029ce3; }
+.create-playlist:focus {
+	background: #029ce3;
+}
 
-	.none-found { text-align: center; }
-</style>
+.none-found {
+	text-align: center;
+}
+</style>

+ 230 - 117
frontend/components/Sidebars/SongsList.vue

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

+ 50 - 37
frontend/components/Sidebars/UsersList.vue

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

+ 407 - 296
frontend/components/Station/CommunityHeader.vue

@@ -1,350 +1,461 @@
 <template>
-<div class="winter-is-coming">
-
-<div class="snow snow--near"></div>
-<div class="snow snow--near snow--alt"></div>
-
-<div class="snow snow--mid"></div>
-<div class="snow snow--mid snow--alt"></div>
-
-<div class="snow snow--far"></div>
-<div class="snow snow--far snow--alt"></div>
-</div>
-	<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>
+	<div>
+		<nav class="nav">
+			<div class="nav-left">
+				<router-link
+					class="nav-item is-brand"
+					href="#"
+					:to="{ path: '/' }"
+				>
+					<img
+						:src="`${this.siteSettings.logo}`"
+						:alt="`${this.siteSettings.siteName}` || `Musare`"
+					/>
+				</router-link>
+			</div>
 
-		<span class="nav-toggle" @click="controlBar = !controlBar">
-			<span></span>
-			<span></span>
-			<span></span>
-		</span>
+			<div class="nav-center stationDisplayName">
+				{{ $parent.station.displayName }}
+			</div>
 
-		<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 class="nav-toggle" v-on:click="controlBar = !controlBar">
+				<span />
+				<span />
+				<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>
-		</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>
+			<div class="nav-right nav-menu" :class="{ 'is-active': isMobile }">
+				<router-link
+					v-if="$parent.$parent.role === 'admin'"
+					class="nav-item is-tab admin"
+					href="#"
+					:to="{ path: '/admin' }"
+				>
+					<strong>Admin</strong>
+				</router-link>
+				<span v-if="$parent.$parent.loggedIn" class="grouped">
+					<router-link
+						class="nav-item is-tab"
+						:to="{ path: '/u/' + $parent.$parent.username }"
+						>Profile</router-link
+					>
+					<router-link class="nav-item is-tab" to="/settings"
+						>Settings</router-link
+					>
+					<a
+						class="nav-item is-tab"
+						href="#"
+						@click="$parent.$parent.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
+						v-if="isOwner()"
+						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
+						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
+						v-if="isOwner() && $parent.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="isOwner() && !$parent.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="$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="
+							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 />
+				</div>
+				<a
+					v-if="$parent.station.partyMode === true"
+					class="sidebar-item"
+					href="#"
+					@click="$parent.toggleSidebar('songslist')"
+				>
+					<span class="icon">
+						<i class="material-icons">queue_music</i>
 					</span>
-					<span class="icon-purpose">Pause station</span>
+					<span class="icon-purpose">Show the station queue</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>
+				<a
+					class="sidebar-item"
+					href="#"
+					@click="$parent.toggleSidebar('users')"
+				>
+					<span class="icon">
+						<i class="material-icons">people</i>
 					</span>
-					<span class="skip-votes">{{ $parent.currentSong.skipVotes }}</span>
-					<span class="icon-purpose">Skip current song</span>
+					<span class="icon-purpose"
+						>Display users in the station</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>
+				<a
+					v-if="$parent.$parent.loggedIn"
+					class="sidebar-item"
+					href="#"
+					@click="$parent.toggleSidebar('playlist')"
+				>
+					<span class="icon">
+						<i class="material-icons">library_music</i>
 					</span>
-					<span class="icon-purpose">Add current song to playlist</span>
+					<span class="icon-purpose">Show your playlists</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
+import { mapActions } from "vuex";
+
+export default {
+	data() {
+		return {
+			title: this.$route.params.id,
+			isMobile: false,
+			controlBar: true,
+			frontendDomain: "",
+			siteSettings: {
+				logo: "",
+				siteName: ""
 			}
+		};
+	},
+	mounted() {
+		lofig.get("frontendDomain", res => {
+			this.frontendDomain = res;
+			return res;
+		});
+		lofig.get("siteSettings", res => {
+			this.siteSettings = res;
+			return res;
+		});
+	},
+	methods: {
+		isOwner() {
+			return (
+				this.$parent.$parent.loggedIn &&
+				(this.$parent.$parent.role === "admin" ||
+					this.$parent.$parent.userId === this.$parent.station.owner)
+			);
 		},
-		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);
-			}
-		}
+		settings() {
+			this.editStation({
+				_id: this.$parent.station._id,
+				name: this.$parent.station.name,
+				type: this.$parent.type,
+				partyMode: this.$parent.station.partyMode,
+				description: this.$parent.station.description,
+				privacy: this.$parent.station.privacy,
+				displayName: this.$parent.station.displayName
+			});
+			this.openModal({
+				sector: "station",
+				modal: "editStation"
+			});
+		},
+		...mapActions("modals", ["openModal"]),
+		...mapActions("station", ["editStation"])
 	}
+};
 </script>
 
-<style lang='scss' scoped>
-	.nav {
-		background-color: #ff4545;
-		line-height: 64px;
+<style lang="scss" scoped>
+.nav {
+	background-color: #03a9f4;
+	line-height: 64px;
+	border-radius: 0% 0% 33% 33% / 0% 0% 7% 7%;
+
+	.is-brand {
+		font-size: 2.1rem !important;
+		line-height: 64px !important;
+		padding: 0 20px;
+		color: #ffffff;
+		font-family: Pacifico, cursive;
+		filter: brightness(0) invert(1);
 
-		.is-brand {
-			font-size: 2.1rem !important;
-			line-height: 64px !important;
-			padding: 0 20px;
+		img {
+			max-height: 38px;
 		}
 	}
+}
 
-	a.nav-item {
-		color: hsl(0, 0%, 100%);
-		font-size: 15px;
-
-		&:hover {
-			color: hsl(0, 0%, 100%);
-		}
+a.nav-item {
+	color: #ffffff;
+	font-size: 17px;
 
-		.admin {
-			color: #424242;
-		}
+	&:hover {
+		color: #ffffff;
+	}
 
-		padding: 0 12px;
-		.icon {
+	padding: 0 12px;
+	.icon {
+		height: 64px;
+		i {
+			font-size: 2rem;
+			line-height: 64px;
 			height: 64px;
-			i {
-				font-size: 2rem;
-				line-height: 64px;
-				height: 64px;
-				width: 34px;
-			}
+			width: 34px;
 		}
 	}
+}
 
-	.grouped {
-		margin: 0;
-		display: flex;
-		text-decoration: none;
-	}
+a.nav-item.is-tab:hover {
+	border-bottom: none;
+	border-top: solid 1px #ffffff;
+}
 
-	.skip-votes {
-		position: relative;
-		left: 11px;
-	}
+.admin strong {
+	color: #9d42b1;
+}
 
-	.nav-toggle {
-		height: 64px;
-	}
+.grouped {
+	margin: 0;
+	display: flex;
+	text-decoration: none;
+}
 
-	@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;
-		}
-	}
+.skip-votes {
+	position: relative;
+	left: 11px;
+}
 
-	.logo {
-		font-size: 2.1rem;
-		line-height: 64px;
-		padding-left: 20px !important;
-		padding-right: 20px !important;
-	}
+.nav-toggle {
+	height: 64px;
+}
 
-	.nav-center {
-		display: flex;
-    align-items: center;
-		color: #ff4545;
-		font-size: 22px;
+@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;
-		margin: auto;
-		top: 50%;
-		left: 50%;
-		transform: translate(-50%, -50%);
 	}
-
-	.nav-right.is-active .nav-item {
-		background: #ff4545;
-    	border: 0;
+	.nav-toggle {
+		display: block;
 	}
+}
 
-	.control-sidebar {
-		position: fixed;
-		z-index: 1;
-		top: 0;
-		left: 0;
-		width: 64px;
-		height: 100vh;
-		background-color: #ff4545;
-		box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16), 0 2px 10px 0 rgba(0, 0, 0, 0.12);
+.logo {
+	font-size: 2.1rem;
+	line-height: 64px;
+	padding-left: 20px !important;
+	padding-right: 20px !important;
+}
 
-		@media (max-width: 998px) {
-			display: none;
+.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;
+			}
 		}
-		.inner-wrapper {
-			@media (min-width: 999px) {
-				.mobile-only {
-					display: none;
-				}
-				.desktop-only {
-					display: flex;
-				}
+		@media (max-width: 998px) {
+			.mobile-only {
+				display: flex;
 			}
-			@media (max-width: 998px) {
-				.mobile-only {
-					display: flex;
-				}
-				.desktop-only {
-					display: none;
-					visibility: hidden;
-				}
+			.desktop-only {
+				display: none;
+				visibility: hidden;
 			}
 		}
 	}
+}
 
-	.show-controlBar {
-		display: block;
-	}
+.show-controlBar {
+	display: block;
+}
 
-	.inner-wrapper {
-		top: 64px;
-		position: relative;
-	}
+.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;
-	}
+.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(255, 69, 69, 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 {
+	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(255, 69, 69, 0.8) transparent transparent;
-	}
+.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;
-	}
+.sidebar-item:hover .icon-purpose {
+	visibility: visible;
+	opacity: 1;
+	display: block;
+}
 </style>

+ 431 - 306
frontend/components/Station/OfficialHeader.vue

@@ -1,360 +1,485 @@
 <template>
-<div class="winter-is-coming">
-
-<div class="snow snow--near"></div>
-<div class="snow snow--near snow--alt"></div>
-
-<div class="snow snow--mid"></div>
-<div class="snow snow--mid snow--alt"></div>
-
-<div class="snow snow--far"></div>
-<div class="snow snow--far snow--alt"></div>
-</div>
-	<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>
+	<div>
+		<nav class="nav">
+			<div class="nav-left">
+				<router-link class="nav-item is-brand" to="/">
+					<img
+						:src="`${this.siteSettings.logo}`"
+						:alt="`${this.siteSettings.siteName}` || `Musare`"
+					/>
+				</router-link>
+			</div>
 
-		<span class="nav-toggle" @click="controlBar = !controlBar">
-			<span></span>
-			<span></span>
-			<span></span>
-		</span>
+			<div class="nav-center stationDisplayName">
+				{{ $parent.station.displayName }}
+			</div>
 
-		<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 class="nav-toggle" v-on:click="controlBar = !controlBar">
+				<span />
+				<span />
+				<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>
-		</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 class="nav-right nav-menu" :class="{ 'is-active': isMobile }">
+				<router-link
+					v-if="$parent.$parent.role === 'admin'"
+					class="nav-item is-tab admin"
+					href="#"
+					:to="{ path: '/admin' }"
+				>
+					<strong>Admin</strong>
+				</router-link>
+				<span v-if="$parent.$parent.loggedIn" class="grouped">
+					<router-link
+						class="nav-item is-tab"
+						href="#"
+						:to="{ path: '/u/' + $parent.$parent.username }"
+						>Profile</router-link
+					>
+					<router-link class="nav-item is-tab" to="/settings"
+						>Settings</router-link
+					>
+					<a class="nav-item is-tab" @click="$parent.$parent.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>
-			<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>
+		</nav>
+		<div class="control-sidebar" :class="{ 'show-controlBar': controlBar }">
+			<div class="inner-wrapper">
+				<div v-if="isOwner()">
+					<a
+						v-if="isOwner()"
+						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
+						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
+						v-if="isOwner() && !$parent.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>
+					<a
+						v-if="isOwner() && $parent.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>
+					<hr />
+				</div>
+				<div v-if="$parent.$parent.loggedIn">
+					<a
+						v-if="
+							$parent.type === 'official' &&
+								$parent.$parent.loggedIn
+						"
+						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() &&
+								$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="
+							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="$parent.$parent.loggedIn && !$parent.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 />
+				</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">Report a song</span>
+					<span class="icon-purpose">Show the station queue</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>
+				<a
+					class="sidebar-item"
+					href="#"
+					@click="$parent.toggleSidebar('users')"
+				>
+					<span class="icon">
+						<i class="material-icons">people</i>
 					</span>
-					<span class="icon-purpose">Add current song to playlist</span>
+					<span class="icon-purpose"
+						>Display users in the station</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
+import { mapActions } from "vuex";
+
+export default {
+	data() {
+		return {
+			title: this.$route.params.id,
+			isMobile: false,
+			controlBar: false,
+			frontendDomain: "",
+			siteSettings: {
+				logo: "",
+				siteName: ""
 			}
+		};
+	},
+	mounted() {
+		lofig.get("frontendDomain", res => {
+			this.frontendDomain = res;
+			return res;
+		});
+		lofig.get("siteSettings", res => {
+			this.siteSettings = res;
+			return res;
+		});
+	},
+	methods: {
+		isOwner() {
+			return (
+				this.$parent.$parent.loggedIn &&
+				this.$parent.$parent.role === "admin"
+			);
 		},
-		methods: {
-			isOwner: function () {
-				return this.$parent.$parent.loggedIn && this.$parent.$parent.role === 'admin';
-			},
-			toggleModal: function (type) {
-				this.$dispatch('toggleModal', type);
-			}
-		}
+		settings() {
+			this.editStation({
+				_id: this.$parent.station._id,
+				name: this.$parent.station.name,
+				type: this.$parent.type,
+				partyMode: this.$parent.station.partyMode,
+				description: this.$parent.station.description,
+				privacy: this.$parent.station.privacy,
+				displayName: this.$parent.station.displayName
+			});
+			this.openModal({
+				sector: "station",
+				modal: "editStation"
+			});
+		},
+		...mapActions("modals", ["openModal"]),
+		...mapActions("station", ["editStation"])
 	}
+};
 </script>
 
-<style lang='scss' scoped>
-	.nav {
-		background-color: #ff4545;
-		line-height: 64px;
+<style lang="scss" scoped>
+.nav {
+	background-color: #03a9f4;
+	line-height: 64px;
+	border-radius: 0% 0% 33% 33% / 0% 0% 7% 7%;
+
+	.is-brand {
+		font-size: 2.1rem !important;
+		line-height: 64px !important;
+		padding: 0 20px;
+		color: #ffffff;
+		font-family: Pacifico, cursive;
+		filter: brightness(0) invert(1);
 
-		.is-brand {
-			font-size: 2.1rem !important;
-			line-height: 64px !important;
-			padding: 0 20px;
+		img {
+			max-height: 38px;
 		}
 	}
+}
 
-	a.nav-item {
-		color: hsl(0, 0%, 100%);
-		font-size: 15px;
+a.nav-item {
+	color: #ffffff;
+	font-size: 17px;
 
-		&:hover {
-			color: hsl(0, 0%, 100%);
-		}
-
-		.admin {
-			color: #424242;
-		}
+	&:hover {
+		color: #ffffff;
+	}
 
-		padding: 0 12px;
-		.icon {
+	padding: 0 12px;
+	.icon {
+		height: 64px;
+		i {
+			font-size: 2rem;
+			line-height: 64px;
 			height: 64px;
-			i {
-				font-size: 2rem;
-				line-height: 64px;
-				height: 64px;
-				width: 34px;
-			}
+			width: 34px;
 		}
 	}
+}
 
-	.grouped {
-		margin: 0;
-		display: flex;
-		text-decoration: none;
-	}
+a.nav-item.is-tab:hover {
+	border-bottom: none;
+	border-top: solid 1px #ffffff;
+}
 
-	.skip-votes {
-		position: relative;
-		left: 11px;
-	}
+.admin strong {
+	color: #9d42b1;
+}
 
-	.nav-toggle {
-		height: 64px;
-	}
+.grouped {
+	margin: 0;
+	display: flex;
+	text-decoration: none;
+}
 
-	@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;
-		}
-	}
+.skip-votes {
+	position: relative;
+	left: 11px;
+}
 
-	.logo {
-		font-size: 2.1rem;
-		line-height: 64px;
-		padding-left: 20px !important;
-		padding-right: 20px !important;
-	}
+.nav-toggle {
+	height: 64px;
+}
 
-	.nav-center {
-		display: flex;
-    	align-items: center;
-		color: #ff4545;
-		font-size: 22px;
+@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;
-		margin: auto;
-		top: 50%;
-		left: 50%;
-		transform: translate(-50%, -50%);
 	}
-
-	.nav-right.is-active .nav-item {
-		background: #ff4545;
-    	border: 0;
+	.nav-toggle {
+		display: block;
 	}
+}
 
-	.hidden {
-		display: none;
-	}
+.logo {
+	font-size: 2.1rem;
+	line-height: 64px;
+	padding-left: 20px !important;
+	padding-right: 20px !important;
+}
 
-	.control-sidebar {
-		position: fixed;
-		z-index: 1;
-		top: 0;
-		left: 0;
-		width: 64px;
-		height: 100vh;
-		background-color: #ff4545;
-		box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16), 0 2px 10px 0 rgba(0, 0, 0, 0.12);
+.nav-center {
+	display: flex;
+	align-items: center;
+	color: #03a9f4;
+	font-size: 22px;
+	position: absolute;
+	margin: auto;
+	top: 50%;
+	left: 50%;
+	transform: translate(-50%, -50%);
+}
 
-		@media (max-width: 998px) {
-			display: none;
+.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;
+			}
 		}
-		.inner-wrapper {
-			@media (min-width: 999px) {
-				.mobile-only {
-					display: none;
-				}
-				.desktop-only {
-					display: flex;
-				}
+		@media (max-width: 998px) {
+			.mobile-only {
+				display: flex;
 			}
-			@media (max-width: 998px) {
-				.mobile-only {
-					display: flex;
-				}
-				.desktop-only {
-					display: none;
-					visibility: hidden;
-				}
+			.desktop-only {
+				display: none;
+				visibility: hidden;
 			}
 		}
 	}
+}
 
-	.show-controlBar {
-		display: block;
-	}
+.show-controlBar {
+	display: block;
+}
 
-	.inner-wrapper {
-		top: 64px;
-		position: relative;
-	}
+.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;
-	}
+.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(255, 69, 69, 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 {
+	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(255, 69, 69, 0.8) transparent transparent;
-	}
+.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;
-	}
+.sidebar-item:hover .icon-purpose {
+	visibility: visible;
+	opacity: 1;
+	display: block;
+}
 </style>

+ 1590 - 1000
frontend/components/Station/Station.vue

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

+ 125 - 81
frontend/components/User/ResetPassword.vue

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

+ 338 - 186
frontend/components/User/Settings.vue

@@ -1,211 +1,363 @@
 <template>
-	<main-header></main-header>
-	<div class="container">
-		<!--Implement Validation-->
-		<label class="label">Username</label>
-		<div class="control is-grouped">
-			<p class="control is-expanded has-icon has-icon-right">
-				<input class="input" type="text" placeholder="Change username" v-model="user.username">
-				<!--Remove validation if it's their own without changing-->
-			</p>
-			<p class="control">
-				<button class="button is-success" @click="changeUsername()">Save changes</button>
-			</p>
-		</div>
-		<label class="label">Email</label>
-		<div class="control is-grouped" v-if="user.email">
-			<p class="control is-expanded has-icon has-icon-right">
-				<input class="input" type="text" placeholder="Change email address" v-model="user.email.address">
-				<!--Remove validation if it's their own without changing-->
-			</p>
-			<p class="control is-expanded">
-				<button class="button is-success" @click="changeEmail()">Save changes</button>
-			</p>
-		</div>
-		<label class="label" v-if="password">Change Password</label>
-		<div class="control is-grouped" v-if="password">
-			<p class="control is-expanded has-icon has-icon-right">
-				<input class="input" type="password" placeholder="Change password" v-model="newPassword">
-			</p>
-			<p class="control is-expanded">
-				<button class="button is-success" @click="changePassword()">Change password</button>
-			</p>
-		</div>
+	<div>
+		<main-header />
+		<div class="container">
+			<!--Implement Validation-->
+			<label class="label">Username</label>
+			<div class="control is-grouped">
+				<p class="control is-expanded has-icon has-icon-right">
+					<input
+						v-model="user.username"
+						class="input"
+						type="text"
+						placeholder="Change username"
+					/>
+					<!--Remove validation if it's their own without changing-->
+				</p>
+				<p class="control">
+					<button class="button is-success" @click="changeUsername()">
+						Save changes
+					</button>
+				</p>
+			</div>
+			<label class="label">Email</label>
+			<div v-if="user.email" class="control is-grouped">
+				<p class="control is-expanded has-icon has-icon-right">
+					<input
+						v-model="user.email.address"
+						class="input"
+						type="text"
+						placeholder="Change email address"
+					/>
+					<!--Remove validation if it's their own without changing-->
+				</p>
+				<p class="control is-expanded">
+					<button class="button is-success" @click="changeEmail()">
+						Save changes
+					</button>
+				</p>
+			</div>
+			<label v-if="password" class="label">Change Password</label>
+			<div v-if="password" class="control is-grouped">
+				<p class="control is-expanded has-icon has-icon-right">
+					<input
+						v-model="newPassword"
+						class="input"
+						type="password"
+						placeholder="Change password"
+					/>
+				</p>
+				<p class="control is-expanded">
+					<button class="button is-success" @click="changePassword()">
+						Change password
+					</button>
+				</p>
+			</div>
 
-		<label class="label" v-if="!password">Add password</label>
-		<div class="control is-grouped" v-if="!password">
-			<button class="button is-success" @click="requestPassword()" v-if="passwordStep === 1">Request password email</button><br>
-
-			<p class="control is-expanded has-icon has-icon-right" v-if="passwordStep === 2">
-				<input class="input" type="text" placeholder="Code" v-model="passwordCode">
-			</p>
-			<p class="control is-expanded" v-if="passwordStep === 2">
-				<button class="button is-success" @click="verifyCode()">Verify code</button>
-			</p>
-
-			<p class="control is-expanded has-icon has-icon-right" v-if="passwordStep === 3">
-				<input class="input" type="password" placeholder="New password" v-model="setNewPassword">
-			</p>
-			<p class="control is-expanded" v-if="passwordStep === 3">
-				<button class="button is-success" @click="setPassword()">Set password</button>
-			</p>
-		</div>
-		<a href="#" v-if="passwordStep === 1 && !password" @click="passwordStep = 2">Skip this step</a>
+			<label v-if="!password" class="label">Add password</label>
+			<div v-if="!password" class="control is-grouped">
+				<button
+					v-if="passwordStep === 1"
+					class="button is-success"
+					@click="requestPassword()"
+				>
+					Request password email
+				</button>
+				<br />
+
+				<p
+					v-if="passwordStep === 2"
+					class="control is-expanded has-icon has-icon-right"
+				>
+					<input
+						v-model="passwordCode"
+						class="input"
+						type="text"
+						placeholder="Code"
+					/>
+				</p>
+				<p v-if="passwordStep === 2" class="control is-expanded">
+					<button class="button is-success" v-on:click="verifyCode()">
+						Verify code
+					</button>
+				</p>
 
-		<a class="button is-github" v-if="!github" :href='$parent.serverDomain + "/auth/github/link"'>
-			<div class='icon'>
-				<img class='invert' src='/assets/social/github.svg'/>
+				<p
+					v-if="passwordStep === 3"
+					class="control is-expanded has-icon has-icon-right"
+				>
+					<input
+						v-model="setNewPassword"
+						class="input"
+						type="password"
+						placeholder="New password"
+					/>
+				</p>
+				<p v-if="passwordStep === 3" class="control is-expanded">
+					<button class="button is-success" @click="setPassword()">
+						Set password
+					</button>
+				</p>
 			</div>
-			&nbsp; Link GitHub to account
-		</a>
+			<a
+				v-if="passwordStep === 1 && !password"
+				href="#"
+				@click="passwordStep = 2"
+				>Skip this step</a
+			>
 
-		<button class="button is-danger" @click="unlinkPassword()" v-if="password && github">Remove logging in with password</button>
-		<button class="button is-danger" @click="unlinkGitHub()" v-if="password && github">Remove logging in with GitHub</button>
+			<a
+				v-if="!github"
+				class="button is-github"
+				:href="`${$parent.serverDomain}/auth/github/link`"
+			>
+				<div class="icon">
+					<img class="invert" src="/assets/social/github.svg" />
+				</div>
+				&nbsp; Link GitHub to account
+			</a>
 
-		<br>
-		<button class="button is-warning" @click="removeSessions()" style="margin-top: 30px;">Log out everywhere</button>
+			<button
+				v-if="password && github"
+				class="button is-danger"
+				@click="unlinkPassword()"
+			>
+				Remove logging in with password
+			</button>
+			<button
+				v-if="password && github"
+				class="button is-danger"
+				@click="unlinkGitHub()"
+			>
+				Remove logging in with GitHub
+			</button>
+
+			<br />
+			<button
+				class="button is-warning"
+				style="margin-top: 30px;"
+				@click="removeSessions()"
+			>
+				Log out everywhere
+			</button>
+		</div>
+		<main-footer />
 	</div>
-	<main-footer></main-footer>
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
-
-	import MainHeader from '../MainHeader.vue';
-	import MainFooter from '../MainFooter.vue';
-
-	import LoginModal from '../Modals/Login.vue'
-	import io from '../../io'
-	import validation from '../../validation';
-
-	export default {
-		data() {
-			return {
-				user: {},
-				newPassword: '',
-				password: false,
-				github: false,
-				setNewPassword: '',
-				passwordStep: 1,
-				passwordCode: ''
-			}
+import { Toast } from "vue-roaster";
+
+import MainHeader from "../MainHeader.vue";
+import MainFooter from "../MainFooter.vue";
+
+import io from "../../io";
+import validation from "../../validation";
+
+export default {
+	components: { MainHeader, MainFooter },
+	data() {
+		return {
+			user: {},
+			newPassword: "",
+			password: false,
+			github: false,
+			setNewPassword: "",
+			passwordStep: 1,
+			passwordCode: ""
+		};
+	},
+	mounted() {
+		const _this = this;
+		io.getSocket(socket => {
+			_this.socket = socket;
+			_this.socket.emit("users.findBySession", res => {
+				if (res.status === "success") {
+					_this.user = res.data;
+					_this.password = _this.user.password;
+					_this.github = _this.user.github;
+				} else {
+					_this.$parent.isLoginActive = true;
+					Toast.methods.addToast(
+						"Your are currently not signed in",
+						3000
+					);
+				}
+			});
+			_this.socket.on("event:user.username.changed", username => {
+				_this.$parent.username = username;
+			});
+			_this.socket.on("event:user.linkPassword", () => {
+				_this.password = true;
+			});
+			_this.socket.on("event:user.linkGitHub", () => {
+				_this.github = true;
+			});
+			_this.socket.on("event:user.unlinkPassword", () => {
+				_this.password = false;
+			});
+			_this.socket.on("event:user.unlinkGitHub", () => {
+				_this.github = false;
+			});
+		});
+	},
+	methods: {
+		changeEmail() {
+			const email = this.user.email.address;
+			if (!validation.isLength(email, 3, 254))
+				return Toast.methods.addToast(
+					"Email must have between 3 and 254 characters.",
+					8000
+				);
+			if (
+				email.indexOf("@") !== email.lastIndexOf("@") ||
+				!validation.regex.emailSimple.test(email)
+			)
+				return Toast.methods.addToast("Invalid email format.", 8000);
+
+			return this.socket.emit(
+				"users.updateEmail",
+				this.$parent.userId,
+				email,
+				res => {
+					if (res.status !== "success")
+						Toast.methods.addToast(res.message, 8000);
+					else
+						Toast.methods.addToast(
+							"Successfully changed email address",
+							4000
+						);
+				}
+			);
 		},
-		ready: function() {
-			let _this = this;
-			io.getSocket(socket => {
-				_this.socket = socket;
-				_this.socket.emit('users.findBySession', res => {
-					if (res.status == 'success') {
-						_this.user = res.data;
-						_this.password = _this.user.password;
-						_this.github = _this.user.github;
-					} else {
-						_this.$parent.isLoginActive = true;
-						Toast.methods.addToast('Your are currently not signed in', 3000);
-					}
-				});
-				_this.socket.on('event:user.username.changed', username => {
-					_this.$parent.username = username;
-				});
-				_this.socket.on('event:user.linkPassword', () => {
-					_this.password = true;
-				});
-				_this.socket.on('event:user.linkGitHub', () => {
-					_this.github = true;
-				});
-				_this.socket.on('event:user.unlinkPassword', () => {
-					_this.password = false;
-				});
-				_this.socket.on('event:user.unlinkGitHub', () => {
-					_this.github = false;
-				});
+		changeUsername() {
+			const { username } = this.user;
+			if (!validation.isLength(username, 2, 32))
+				return Toast.methods.addToast(
+					"Username must have between 2 and 32 characters.",
+					8000
+				);
+			if (!validation.regex.azAZ09_.test(username))
+				return Toast.methods.addToast(
+					"Invalid username format. Allowed characters: a-z, A-Z, 0-9 and _.",
+					8000
+				);
+
+			return this.socket.emit(
+				"users.updateUsername",
+				this.$parent.userId,
+				username,
+				res => {
+					if (res.status !== "success")
+						Toast.methods.addToast(res.message, 8000);
+					else
+						Toast.methods.addToast(
+							"Successfully changed username",
+							4000
+						);
+				}
+			);
+		},
+		changePassword() {
+			const { newPassword } = this;
+			if (!validation.isLength(newPassword, 6, 200))
+				return Toast.methods.addToast(
+					"Password must have between 6 and 200 characters.",
+					8000
+				);
+			if (!validation.regex.password.test(newPassword))
+				return Toast.methods.addToast(
+					"Invalid password format. Must have one lowercase letter, one uppercase letter, one number and one special character.",
+					8000
+				);
+
+			return this.socket.emit(
+				"users.updatePassword",
+				newPassword,
+				res => {
+					if (res.status !== "success")
+						Toast.methods.addToast(res.message, 8000);
+					else
+						Toast.methods.addToast(
+							"Successfully changed password",
+							4000
+						);
+				}
+			);
+		},
+		requestPassword() {
+			return this.socket.emit("users.requestPassword", res => {
+				Toast.methods.addToast(res.message, 8000);
+				if (res.status === "success") {
+					this.passwordStep = 2;
+				}
 			});
 		},
-		methods: {
-			changeEmail: function () {
-				const email = this.user.email.address;
-				if (!validation.isLength(email, 3, 254)) return Toast.methods.addToast('Email must have between 3 and 254 characters.', 8000);
-				if (email.indexOf('@') !== email.lastIndexOf('@') || !validation.regex.emailSimple.test(email)) return Toast.methods.addToast('Invalid email format.', 8000);
-
-
-				this.socket.emit('users.updateEmail', this.$parent.userId, email, res => {
-					if (res.status !== 'success') Toast.methods.addToast(res.message, 8000);
-					else Toast.methods.addToast('Successfully changed email address', 4000);
-				});
-			},
-			changeUsername: function () {
-				const username = this.user.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);
-
-
-				this.socket.emit('users.updateUsername', this.$parent.userId, username, res => {
-					if (res.status !== 'success') Toast.methods.addToast(res.message, 8000);
-					else Toast.methods.addToast('Successfully changed username', 4000);
-				});
-			},
-			changePassword: function () {
-				const newPassword = this.newPassword;
-				if (!validation.isLength(newPassword, 6, 200)) return Toast.methods.addToast('Password must have between 6 and 200 characters.', 8000);
-				if (!validation.regex.password.test(newPassword)) return Toast.methods.addToast('Invalid password format. Must have one lowercase letter, one uppercase letter, one number and one special character.', 8000);
-
-
-				this.socket.emit('users.updatePassword', newPassword, res => {
-					if (res.status !== 'success') Toast.methods.addToast(res.message, 8000);
-					else Toast.methods.addToast('Successfully changed password', 4000);
-				});
-			},
-			requestPassword: function() {
-				this.socket.emit('users.requestPassword', res => {
-					Toast.methods.addToast(res.message, 8000);
-					if (res.status === 'success') {
-						this.passwordStep = 2;
-					}
-				});
-			},
-			verifyCode: function () {
-				if (!this.passwordCode) return Toast.methods.addToast('Code cannot be empty', 8000);
-				this.socket.emit('users.verifyPasswordCode', this.passwordCode, res => {
+		verifyCode() {
+			if (!this.passwordCode)
+				return Toast.methods.addToast("Code cannot be empty", 8000);
+			return this.socket.emit(
+				"users.verifyPasswordCode",
+				this.passwordCode,
+				res => {
 					Toast.methods.addToast(res.message, 8000);
-					if (res.status === 'success') {
+					if (res.status === "success") {
 						this.passwordStep = 3;
 					}
-				});
-			},
-			setPassword: function () {
-				const newPassword = this.setNewPassword;
-				if (!validation.isLength(newPassword, 6, 200)) return Toast.methods.addToast('Password must have between 6 and 200 characters.', 8000);
-				if (!validation.regex.password.test(newPassword)) return Toast.methods.addToast('Invalid password format. Must have one lowercase letter, one uppercase letter, one number and one special character.', 8000);
-
+				}
+			);
+		},
+		setPassword() {
+			const newPassword = this.setNewPassword;
+			if (!validation.isLength(newPassword, 6, 200))
+				return Toast.methods.addToast(
+					"Password must have between 6 and 200 characters.",
+					8000
+				);
+			if (!validation.regex.password.test(newPassword))
+				return Toast.methods.addToast(
+					"Invalid password format. Must have one lowercase letter, one uppercase letter, one number and one special character.",
+					8000
+				);
 
-				this.socket.emit('users.changePasswordWithCode', this.passwordCode, newPassword, res => {
+			return this.socket.emit(
+				"users.changePasswordWithCode",
+				this.passwordCode,
+				newPassword,
+				res => {
 					Toast.methods.addToast(res.message, 8000);
-				});
-			},
-			unlinkPassword: function () {
-				this.socket.emit('users.unlinkPassword', res => {
-					Toast.methods.addToast(res.message, 8000);
-				});
-			},
-			unlinkGitHub: function () {
-				this.socket.emit('users.unlinkGitHub', res => {
-					Toast.methods.addToast(res.message, 8000);
-				});
-			},
-			removeSessions: function () {
-				this.socket.emit(`users.removeSessions`, this.$parent.userId, res => {
-					Toast.methods.addToast(res.message, 4000);
-				});
-			}
+				}
+			);
+		},
+		unlinkPassword() {
+			this.socket.emit("users.unlinkPassword", res => {
+				Toast.methods.addToast(res.message, 8000);
+			});
 		},
-		components: { MainHeader, MainFooter, LoginModal }
+		unlinkGitHub() {
+			this.socket.emit("users.unlinkGitHub", res => {
+				Toast.methods.addToast(res.message, 8000);
+			});
+		},
+		removeSessions() {
+			this.socket.emit(
+				`users.removeSessions`,
+				this.$parent.userId,
+				res => {
+					Toast.methods.addToast(res.message, 4000);
+				}
+			);
+		}
 	}
+};
 </script>
 
 <style lang="scss" scoped>
-	.container {
-		padding: 25px;
-	}
+.container {
+	padding: 25px;
+}
 
-	a { color: #029ce3 !important; }
+a {
+	color: #029ce3 !important;
+}
 </style>

+ 122 - 71
frontend/components/User/Show.vue

@@ -1,108 +1,159 @@
 <template>
 	<div v-if="isUser">
-		<main-header></main-header>
+		<main-header />
 		<div class="container">
-			<img class="avatar" src="/assets/notes.png"/>
-			<h2 class="has-text-centered username">@{{user.username}}</h2>
-			<h5>A member since {{user.createdAt}}</h5>
-			<div class="admin-functionality" v-if="$parent.role === 'admin' && !($parent.userId === user._id)">
-				<a class="button is-small is-info is-outlined" @click="changeRank('admin')" v-if="user.role == 'default'">Promote to Admin</a>
-				<a class="button is-small is-danger is-outlined" @click="changeRank('default')" v-if="user.role == 'admin'">Demote to User</a>
+			<img class="avatar" src="/assets/notes.png" />
+			<h2 class="has-text-centered username">@{{ user.username }}</h2>
+			<h5>A member since {{ user.createdAt }}</h5>
+			<div
+				v-if="
+					$parent.role === 'admin' && !($parent.userId === user._id)
+				"
+				class="admin-functionality"
+			>
+				<a
+					v-if="user.role == 'default'"
+					class="button is-small is-info is-outlined"
+					@click="changeRank('admin')"
+					>Promote to Admin</a
+				>
+				<a
+					v-if="user.role == 'admin'"
+					class="button is-small is-danger is-outlined"
+					@click="changeRank('default')"
+					>Demote to User</a
+				>
 			</div>
 			<nav class="level">
 				<div class="level-item has-text-centered">
-					<p class="heading">Rank</p>
-					<p class="title role">{{user.role}}</p>
+					<p class="heading">
+						Rank
+					</p>
+					<p class="title role">
+						{{ user.role }}
+					</p>
 				</div>
 				<div class="level-item has-text-centered">
-					<p class="heading">Songs Requested</p>
-					<p class="title">{{ user.statistics.songsRequested }}</p>
+					<p class="heading">
+						Songs Requested
+					</p>
+					<p class="title">
+						{{ user.statistics.songsRequested }}
+					</p>
 				</div>
 				<div class="level-item has-text-centered">
-					<p class="heading">Likes</p>
-					<p class="title">{{ user.liked.length }}</p>
+					<p class="heading">
+						Likes
+					</p>
+					<p class="title">
+						{{ user.liked.length }}
+					</p>
 				</div>
 				<div class="level-item has-text-centered">
-					<p class="heading">Dislikes</p>
-					<p class="title">{{ user.disliked.length }}</p>
+					<p class="heading">
+						Dislikes
+					</p>
+					<p class="title">
+						{{ user.disliked.length }}
+					</p>
 				</div>
 			</nav>
 		</div>
-		<main-footer></main-footer>
+		<main-footer />
 	</div>
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
+import { Toast } from "vue-roaster";
 
-	import MainHeader from '../MainHeader.vue';
-	import MainFooter from '../MainFooter.vue';
-	import io from '../../io';
+import MainHeader from "../MainHeader.vue";
+import MainFooter from "../MainFooter.vue";
+import io from "../../io";
 
-	export default {
-		data() {
-			return {
-				user: {},
-				isUser: false
-			}
-		},
-		methods: {
-			changeRank(newRank) {
-				this.socket.emit('users.updateRole', this.user._id, ((newRank == 'admin') ? 'admin' : 'default'), res => {
-					if (res.status == 'error') Toast.methods.addToast(res.message, 2000);
-					else this.user.role = newRank; Toast.methods.addToast(`User ${this.$route.params.username}'s rank has been changed to: ${newRank}`, 2000);
-				});
-			}
-		},
-		ready: function() {
-			let _this = this;
-			io.getSocket((socket) => {
-				_this.socket = socket;
-				_this.socket.emit('users.findByUsername', _this.$route.params.username, res => {
-					if (res.status == 'error') this.$router.go('/404');
+export default {
+	components: { MainHeader, MainFooter },
+	data() {
+		return {
+			user: {},
+			isUser: false
+		};
+	},
+	mounted() {
+		const _this = this;
+		io.getSocket(socket => {
+			_this.socket = socket;
+			_this.socket.emit(
+				"users.findByUsername",
+				_this.$route.params.username,
+				res => {
+					if (res.status === "error") this.$router.go("/404");
 					else {
 						_this.user = res.data;
-						this.user.createdAt = moment(this.user.createdAt).format('LL');
+						this.user.createdAt = moment(
+							this.user.createdAt
+						).format("LL");
 						_this.isUser = true;
 					}
-				});
-			});
-		},
-		components: { MainHeader, MainFooter }
+				}
+			);
+		});
+	},
+	methods: {
+		changeRank(newRank) {
+			this.socket.emit(
+				"users.updateRole",
+				this.user._id,
+				newRank === "admin" ? "admin" : "default",
+				res => {
+					if (res.status === "error")
+						Toast.methods.addToast(res.message, 2000);
+					else this.user.role = newRank;
+					Toast.methods.addToast(
+						`User ${this.$route.params.username}'s rank has been changed to: ${newRank}`,
+						2000
+					);
+				}
+			);
+		}
 	}
+};
 </script>
 
 <style lang="scss" scoped>
-	.container {
-		padding: 25px;
-	}
+.container {
+	padding: 25px;
+}
 
-	.avatar {
-		border-radius: 50%;
-		width: 250px;
-		display: block;
-		margin: auto;
-	}
+.avatar {
+	border-radius: 50%;
+	width: 250px;
+	display: block;
+	margin: auto;
+}
 
-	h5 {
-		text-align: center;
-		margin-bottom: 25px;
-		font-size: 17px;
-	}
+h5 {
+	text-align: center;
+	margin-bottom: 25px;
+	font-size: 17px;
+}
 
-	.role { text-transform: capitalize; }
+.role {
+	text-transform: capitalize;
+}
 
-	.level { margin-top: 40px; }
+.level {
+	margin-top: 40px;
+}
 
-	.admin-functionality {
-		text-align: center;
-		margin: 0 auto;
-	}
+.admin-functionality {
+	text-align: center;
+	margin: 0 auto;
+}
 
-	@media (max-width: 350px) {
-		.username {
-			font-size: 2.9rem;
-			word-wrap: break-all;
-		}
+@media (max-width: 350px) {
+	.username {
+		font-size: 2.9rem;
+		word-wrap: break-all;
 	}
+}
 </style>

+ 37 - 0
frontend/components/UserIdToUsername.vue

@@ -0,0 +1,37 @@
+<template>
+	<router-link
+		v-if="$props.link && username"
+		:to="{ path: `/u/${userIdMap['Z' + $props.userId]}` }"
+	>
+		{{ username ? username : "unknown" }}
+	</router-link>
+	<span v-else>
+		{{ username ? username : "unknown" }}
+	</span>
+</template>
+
+<script>
+import { mapState, mapActions } from "vuex";
+
+export default {
+	props: ["userId", "link"],
+	data() {
+		return {
+			username: ""
+		};
+	},
+	computed: {
+		...mapState("user/auth", {
+			userIdMap: state => state.userIdMap
+		})
+	},
+	methods: {
+		...mapActions("user/auth", ["getUsernameFromId"])
+	},
+	mounted() {
+		this.getUsernameFromId(this.$props.userId).then(res => {
+			this.username = res;
+		});
+	}
+};
+</script>

+ 50 - 42
frontend/components/pages/About.vue

@@ -1,75 +1,83 @@
 <template>
-	<div class='app'>
-		<main-header></main-header>
-		<div class='container'>
-			<div class='card is-fullwidth'>
-				<header class='card-header'>
-					<p class='card-header-title'>
+	<div class="app">
+		<main-header />
+		<div class="content-wrapper">
+			<div class="card is-fullwidth">
+				<header class="card-header">
+					<p class="card-header-title">
 						The project
 					</p>
 				</header>
-				<div class='card-content'>
-					<div class='content'>
+				<div class="card-content">
+					<div class="content">
 						<p>
-							Musare is an open-source music website where you can listen to real-time genre specific music stations, or join community stations created by users.
+							Musare is an open-source music website where you can
+							listen to real-time genre specific music stations,
+							or join community stations created by users.
 						</p>
 					</div>
 				</div>
 			</div>
-			<div class='card is-fullwidth'>
-				<header class='card-header'>
-					<p class='card-header-title'>
+			<div class="card is-fullwidth">
+				<header class="card-header">
+					<p class="card-header-title">
 						How you can help
 					</p>
 				</header>
-				<div class='card-content'>
-					<div class='content'>
-						<p>
+				<div class="card-content">
+					<div class="content">
+						<span>
 							There are multiple ways you can help us:
 							<ol>
 								<li>
-									Reporting bugs. No website is perfect, but we try to eliminate as many bugs as possible.
-									If you find a bug, we would highly appreciate it if you could create an issue on the GitHub project with steps to reproduce the issue, so we can fix it as soon as possible.
+									Reporting bugs. No website is perfect, but
+									we try to eliminate as many bugs as
+									possible. If you find a bug, we would highly
+									appreciate it if you could create an issue
+									on the GitHub project with steps to
+									reproduce the issue, so we can fix it as
+									soon as possible.
 								</li>
 								<li>
-									Sending us feedback. Your comments and/or suggestions are extremely valuable to us. In order to improve
-									we need to know what you like, don't like and what you might want on the website.
+									Sending us feedback. Your comments and/or
+									suggestions are extremely valuable to us. In
+									order to improve we need to know what you
+									like, don't like and what you might want on
+									the website.
 								</li>
 								<li>
-									Sharing the joy. The more people enjoying Musare, the better.
-									Telling your friends or relatives about Musare would increase the amount of users we have, which would motivate us and cause Musare to grow faster.
+									Sharing the joy. The more people enjoying
+									Musare, the better. Telling your friends or
+									relatives about Musare would increase the
+									amount of users we have, which would
+									motivate us and cause Musare to grow faster.
 								</li>
 							</ol>
-						</p>
+						</span>
 					</div>
 				</div>
 			</div>
 		</div>
-		<main-footer></main-footer>
+		<main-footer />
 	</div>
 </template>
 
 <script>
-	import MainHeader from '../MainHeader.vue';
-	import MainFooter from '../MainFooter.vue';
-	import io from '../../io';
-
-	export default {
-		components: { MainHeader, MainFooter },
-		methods: {
-
-		},
-		data() {
-			return {
-
-			}
-		},
-		ready: function () {
+import MainHeader from "../MainHeader.vue";
+import MainFooter from "../MainFooter.vue";
 
-		}
-	}
+export default {
+	components: { MainHeader, MainFooter },
+	data() {
+		return {};
+	},
+	mounted() {},
+	methods: {}
+};
 </script>
 
-<style lang='scss' scoped>
-	.card { margin-top: 50px; }
+<style lang="scss" scoped>
+.card {
+	margin-top: 50px;
+}
 </style>

+ 172 - 94
frontend/components/pages/Admin.vue

@@ -1,142 +1,220 @@
 <template>
-	<div class='app'>
-		<main-header></main-header>
-		<div class='tabs is-centered'>
+	<div class="app">
+		<main-header />
+		<div class="tabs is-centered">
 			<ul>
-				<li :class='{ "is-active": currentTab == "queueSongs" }' @click='showTab("queueSongs")'>
-					<a v-link="{ path: '/admin/queuesongs' }">
-						<i class='material-icons'>queue_music</i>
+				<li
+					:class="{ 'is-active': currentTab == 'queueSongs' }"
+					@click="showTab('queueSongs')"
+				>
+					<router-link class="tab queueSongs" to="/admin/queuesongs">
+						<i class="material-icons">queue_music</i>
 						<span>&nbsp;Queue Songs</span>
-					</a>
+					</router-link>
 				</li>
-				<li :class='{ "is-active": currentTab == "songs" }' @click='showTab("songs")'>
-					<a v-link="{ path: '/admin/songs' }">
-						<i class='material-icons'>music_note</i>
+				<li
+					:class="{ 'is-active': currentTab == 'songs' }"
+					@click="showTab('songs')"
+				>
+					<router-link class="tab songs" to="/admin/songs">
+						<i class="material-icons">music_note</i>
 						<span>&nbsp;Songs</span>
-					</a>
+					</router-link>
 				</li>
-				<li :class='{ "is-active": currentTab == "stations" }' @click='showTab("stations")'>
-					<a v-link="{ path: '/admin/stations' }">
-						<i class='material-icons'>hearing</i>
+				<li
+					:class="{ 'is-active': currentTab == 'stations' }"
+					@click="showTab('stations')"
+				>
+					<router-link class="tab stations" to="/admin/stations">
+						<i class="material-icons">radio</i>
 						<span>&nbsp;Stations</span>
-					</a>
+					</router-link>
 				</li>
-				<li :class='{ "is-active": currentTab == "reports" }' @click='showTab("reports")'>
-					<a v-link="{ path: '/admin/reports' }">
+				<li
+					:class="{ 'is-active': currentTab == 'reports' }"
+					@click="showTab('reports')"
+				>
+					<router-link class="tab reports" to="/admin/reports">
 						<i class="material-icons">report_problem</i>
 						<span>&nbsp;Reports</span>
-					</a>
+					</router-link>
 				</li>
-				<li :class='{ "is-active": currentTab == "news" }' @click='showTab("news")'>
-					<a v-link="{ path: '/admin/news' }">
+				<li
+					:class="{ 'is-active': currentTab == 'news' }"
+					@click="showTab('news')"
+				>
+					<router-link class="tab news" to="/admin/news">
 						<i class="material-icons">chrome_reader_mode</i>
 						<span>&nbsp;News</span>
-					</a>
+					</router-link>
 				</li>
-				<li :class='{ "is-active": currentTab == "users" }' @click='showTab("users")'>
-					<a v-link="{ path: '/admin/users' }">
-						<i class="material-icons">person</i>
+				<li
+					:class="{ 'is-active': currentTab == 'users' }"
+					@click="showTab('users')"
+				>
+					<router-link class="tab users" to="/admin/users">
+						<i class="material-icons">people</i>
 						<span>&nbsp;Users</span>
-					</a>
+					</router-link>
 				</li>
-				<li :class='{ "is-active": currentTab == "statistics" }' @click='showTab("statistics")'>
-					<a v-link="{ path: '/admin/statistics' }">
+				<li
+					:class="{ 'is-active': currentTab == 'statistics' }"
+					@click="showTab('statistics')"
+				>
+					<router-link class="tab statistics" to="/admin/statistics">
 						<i class="material-icons">show_chart</i>
 						<span>&nbsp;Statistics</span>
-					</a>
+					</router-link>
 				</li>
-				<li :class='{ "is-active": currentTab == "punishments" }' @click='showTab("punishments")'>
-					<a v-link="{ path: '/admin/punishments' }">
+				<li
+					:class="{ 'is-active': currentTab == 'punishments' }"
+					@click="showTab('punishments')"
+				>
+					<router-link
+						class="tab punishments"
+						to="/admin/punishments"
+					>
 						<i class="material-icons">gavel</i>
 						<span>&nbsp;Punishments</span>
-					</a>
+					</router-link>
 				</li>
 			</ul>
 		</div>
 
-		<queue-songs v-if='currentTab == "queueSongs"'></queue-songs>
-		<songs v-if='currentTab == "songs"'></songs>
-		<stations v-if='currentTab == "stations"'></stations>
-		<reports v-if='currentTab == "reports"'></reports>
-		<news v-if='currentTab == "news"'></news>
-		<users v-if='currentTab == "users"'></users>
-		<statistics v-if='currentTab == "statistics"'></statistics>
-		<punishments v-if='currentTab == "punishments"'></punishments>
+		<queue-songs v-if="currentTab == 'queueSongs'" />
+		<songs v-if="currentTab == 'songs'" />
+		<stations v-if="currentTab == 'stations'" />
+		<reports v-if="currentTab == 'reports'" />
+		<news v-if="currentTab == 'news'" />
+		<users v-if="currentTab == 'users'" />
+		<statistics v-if="currentTab == 'statistics'" />
+		<punishments v-if="currentTab == 'punishments'" />
 	</div>
 </template>
 
 <script>
-	import MainHeader from '../MainHeader.vue';
-	import MainFooter from '../MainFooter.vue';
+import MainHeader from "../MainHeader.vue";
 
-	import QueueSongs from '../Admin/QueueSongs.vue';
-	import Songs from '../Admin/Songs.vue';
-	import Stations from '../Admin/Stations.vue';
-	import Reports from '../Admin/Reports.vue';
-	import News from '../Admin/News.vue';
-	import Users from '../Admin/Users.vue';
-	import Statistics from '../Admin/Statistics.vue';
-	import Punishments from '../Admin/Punishments.vue';
+import QueueSongs from "../Admin/QueueSongs.vue";
+import Songs from "../Admin/Songs.vue";
+import Stations from "../Admin/Stations.vue";
+import Reports from "../Admin/Reports.vue";
+import News from "../Admin/News.vue";
+import Users from "../Admin/Users.vue";
+import Statistics from "../Admin/Statistics.vue";
+import Punishments from "../Admin/Punishments.vue";
 
-	export default {
-		components: {
-			MainHeader,
-			MainFooter,
-			QueueSongs,
-			Songs,
-			Stations,
-			Reports,
-			News,
-			Users,
-			Statistics,
-			Punishments
-		},
-		ready() {
-			switch(window.location.pathname) {
-				case '/admin/queuesongs':
-					this.currentTab = 'queueSongs';
+export default {
+	components: {
+		MainHeader,
+		QueueSongs,
+		Songs,
+		Stations,
+		Reports,
+		News,
+		Users,
+		Statistics,
+		Punishments
+	},
+	data() {
+		return {
+			currentTab: "queueSongs"
+		};
+	},
+	mounted() {
+		this.changeTab(this.$route.path);
+	},
+	watch: {
+		$route(route) {
+			this.changeTab(route.path);
+		}
+	},
+	methods: {
+		changeTab(path) {
+			switch (path) {
+				case "/admin/queuesongs":
+					this.currentTab = "queueSongs";
 					break;
-				case '/admin/songs':
-					this.currentTab = 'songs';
+				case "/admin/songs":
+					this.currentTab = "songs";
 					break;
-				case '/admin/stations':
-					this.currentTab = 'stations';
+				case "/admin/stations":
+					this.currentTab = "stations";
 					break;
-				case '/admin/reports':
-					this.currentTab = 'reports';
+				case "/admin/reports":
+					this.currentTab = "reports";
 					break;
-				case '/admin/news':
-					this.currentTab = 'news';
+				case "/admin/news":
+					this.currentTab = "news";
 					break;
-				case '/admin/users':
-					this.currentTab = 'users';
+				case "/admin/users":
+					this.currentTab = "users";
 					break;
-				case '/admin/statistics':
-					this.currentTab = 'statistics';
+				case "/admin/statistics":
+					this.currentTab = "statistics";
 					break;
-				case '/admin/punishments':
-					this.currentTab = 'punishments';
+				case "/admin/punishments":
+					this.currentTab = "punishments";
 					break;
 				default:
-					this.currentTab = 'queueSongs';
-			}
-		},
-		data() {
-			return {
-				currentTab: 'queueSongs'
+					this.currentTab = "queueSongs";
 			}
 		},
-		methods: {
-			showTab: function (tab) {
-				this.currentTab = tab;
-			}
+		showTab(tab) {
+			this.currentTab = tab;
 		}
 	}
+};
 </script>
 
-<style lang='scss' scoped>
-	.is-active a {
-		color: #ff4545 !important;
-		border-color: #ff4545 !important;
+<style lang="scss" scoped>
+.tabs {
+	background-color: #ffffff;
+	.queueSongs {
+		color: #00d1b2;
+		border-color: #00d1b2;
+	}
+	.songs {
+		color: #03a9f4;
+		border-color: #03a9f4;
+	}
+	.stations {
+		color: #90298c;
+		border-color: #90298c;
+	}
+	.reports {
+		color: #f7c218;
+		border-color: #f7c218;
+	}
+	.news {
+		color: #e49ba6;
+		border-color: #e49ba6;
+	}
+	.users {
+		color: #ea4962;
+		border-color: #ea4962;
+	}
+	.statistics {
+		color: #ff5e00;
+		border-color: #ff5e00;
+	}
+	.punishments {
+		color: #fc3200;
+		border-color: #fc3200;
+	}
+	.tab {
+		transition: all 0.2s ease-in-out;
+		font-weight: 500;
+		border-bottom: solid 0px;
+	}
+	.tab:hover {
+		border-width: 3px;
+		transition: all 0.2s ease-in-out;
+		font-weight: 600;
+	}
+	.is-active .tab {
+		font-weight: 600;
+		border-width: 3px;
 	}
+}
 </style>

+ 25 - 26
frontend/components/pages/Banned.vue

@@ -2,8 +2,7 @@
 	<div class="container">
 		<i class="material-icons">not_interested</i>
 		<h4>
-			You are banned
-			for
+			You are banned for
 			<strong>{{ moment($parent.ban.expiresAt).fromNow(true) }}</strong>
 		</h4>
 		<h5 class="reason">
@@ -13,33 +12,33 @@
 	</div>
 </template>
 <script>
-	export default {
-		data() {
-	        return {
-				moment
-			}
-	    }
+export default {
+	data() {
+		return {
+			moment
+		};
 	}
+};
 </script>
 
-<style lang='scss' scoped>
-	.container {
-		display: flex;
-		justify-content: center;
-		align-items: center;
-		flex-direction: column;
-		height: 100vh;
-		max-width: 1000px;
-		padding: 0 20px;
-	}
+<style lang="scss" scoped>
+.container {
+	display: flex;
+	justify-content: center;
+	align-items: center;
+	flex-direction: column;
+	height: 100vh;
+	max-width: 1000px;
+	padding: 0 20px;
+}
 
-	.reason {
-		text-align: justify;
-	}
+.reason {
+	text-align: justify;
+}
 
-	i.material-icons {
-		cursor: default;
-		font-size: 65px;
-		color: tomato;
-	}
+i.material-icons {
+	cursor: default;
+	font-size: 65px;
+	color: tomato;
+}
 </style>

+ 432 - 310
frontend/components/pages/Home.vue

@@ -1,267 +1,431 @@
 <template>
-	<div class="app" :class="{'nightMode': nightMode}">
-		<main-header></main-header>
-		<div class="group">
-			<div class="group-title">Official Stations</div>
-			<div class="card station-card" v-for="station in stations.official" v-link="{ path: '/' + station.name }" @click="this.$dispatch('joinStation', station._id)" :class="{'isPrivate': station.privacy === 'private'}">
-				<div class="card-image">
-					<figure class="image is-square">
-						<img :src="station.currentSong.thumbnail" onerror="this.src='/assets/notes-transparent.png'" />
-					</figure>
-				</div>
-				<div class="card-content">
-					<div class="media">
-						<div class="media-left displayName">
-							<h5>{{ station.displayName }}</h5>
-						</div>
-					</div>
-
-					<div class="content">
-						{{ station.description }}
-					</div>
-
-					<div class="under-content">
-						<i class='material-icons' title="How many users there are in the station.">people</i>
-						<span class="users-count" title="How many users there are in the station.">&nbsp;{{station.userCount}}</span>
-
-						<i class="material-icons right-icon" v-if="station.privacy !== 'public'" title="This station is not visible to other users.">lock</i>
+	<div>
+		<div class="app">
+			<main-header />
+			<div class="content-wrapper">
+				<div class="group">
+					<div class="group-title">
+						Official Stations
 					</div>
-				</div>
-				<a @click="this.$dispatch('joinStation', station._id)" href='#' class='absolute-a' v-link="{ path: '/' + station.name }"></a>
-			</div>
-		</div>
-		<div class="group">
-			<div class="group-title">
-				Community Stations&nbsp;
-				<a @click='modals.createCommunityStation = !modals.createCommunityStation' v-if="$parent.loggedIn" href='#'>
-				<i class="material-icons community-button">add_circle_outline</i></a>
-			</div>
-			<div class="card station-card" v-for="station in stations.community" v-link="{ path: '/community/' + station.name }" @click="this.$dispatch('joinStation', station._id)" :class="{'isPrivate': station.privacy === 'private','isMine': isOwner(station)}">
-				<div class="card-image">
-					<figure class="image is-square">
-						<img :src="station.currentSong.thumbnail" onerror="this.src='/assets/notes-transparent.png'" />
-					</figure>
-				</div>
-				<div class="card-content">
-					<div class="media">
-						<div class="media-left displayName">
-							<h5>{{ station.displayName }}</h5>
+					<router-link
+						v-for="(station, index) in stations.official"
+						:key="index"
+						class="card station-card"
+						:to="{ name: 'official', params: { id: station.name } }"
+						:class="{ isPrivate: station.privacy === 'private' }"
+					>
+						<div class="card-image">
+							<figure class="image is-square">
+								<img
+									:src="station.currentSong.thumbnail"
+									onerror="this.src='/assets/notes-transparent.png'"
+								/>
+							</figure>
 						</div>
+						<div class="card-content">
+							<div class="media">
+								<div class="media-left displayName">
+									<h5>{{ station.displayName }}</h5>
+								</div>
+							</div>
+
+							<div class="content">
+								{{ station.description }}
+							</div>
+
+							<div class="under-content">
+								<span class="official"
+									><i class="badge material-icons"
+										>verified_user</i
+									>Official</span
+								>
+								<i
+									v-if="station.privacy !== 'public'"
+									class="material-icons right-icon"
+									title="This station is not visible to other users."
+									>lock</i
+								>
+							</div>
+						</div>
+						<router-link
+							href="#"
+							class="absolute-a"
+							:to="{
+								name: 'official',
+								params: { id: station.name }
+							}"
+						/>
+					</router-link>
+				</div>
+				<div class="group">
+					<div class="group-title">
+						Community Stations&nbsp;
+						<a
+							v-if="$parent.loggedIn"
+							href="#"
+							@click="
+								openModal({
+									sector: 'home',
+									modal: 'createCommunityStation'
+								})
+							"
+						>
+							<i class="material-icons community-button"
+								>add_circle_outline</i
+							>
+						</a>
 					</div>
-
-					<div class="content">
-						{{ station.description }}
-					</div>
-					<div class="under-content">
-						<i class='material-icons' title="How many users there are in the station.">people</i>
-						<span class="users-count" title="How many users there are in the station.">&nbsp;{{station.userCount}}</span>
-
-						<i class="material-icons right-icon" v-if="station.privacy !== 'public'" title="This station is not visible to other users.">lock</i>
-						<i class="material-icons right-icon" v-if="isOwner(station)" title="This is your station.">home</i>
-					</div>
+					<router-link
+						v-for="(station, index) in stations.community"
+						:key="index"
+						:to="{
+							name: 'community',
+							params: { id: station.name }
+						}"
+						class="card station-card"
+						:class="{
+							isPrivate: station.privacy === 'private',
+							isMine: isOwner(station)
+						}"
+					>
+						<div class="card-image">
+							<figure class="image is-square">
+								<img
+									:src="station.currentSong.thumbnail"
+									onerror="this.src='/assets/notes-transparent.png'"
+								/>
+							</figure>
+						</div>
+						<div class="card-content">
+							<div class="media">
+								<div class="media-left displayName">
+									<h5>{{ station.displayName }}</h5>
+								</div>
+							</div>
+
+							<div class="content">
+								{{ station.description }}
+							</div>
+							<div class="under-content">
+								<span class="hostedby"
+									>Hosted by
+									<span class="host">
+										<user-id-to-username
+											:userId="station.owner"
+											:link="true"
+										/>
+									</span>
+								</span>
+								<i
+									v-if="station.privacy !== 'public'"
+									class="material-icons right-icon"
+									title="This station is not visible to other users."
+									>lock</i
+								>
+								<i
+									v-if="isOwner(station)"
+									class="material-icons right-icon"
+									title="This is your station."
+									>home</i
+								>
+							</div>
+						</div>
+						<router-link
+							href="#"
+							class="absolute-a"
+							:to="{
+								name: 'community',
+								params: { id: station.name }
+							}"
+						/>
+					</router-link>
 				</div>
-				<a @click="this.$dispatch('joinStation', station._id)" href='#' class='absolute-a' v-link="{ path: '/community/' + station.name }"></a>
 			</div>
+			<main-footer />
 		</div>
-		<main-footer></main-footer>
+		<create-community-station v-if="modals.createCommunityStation" />
 	</div>
-	<create-community-station v-if='modals.createCommunityStation'></create-community-station>
 </template>
 
 <script>
-	import MainHeader from '../MainHeader.vue';
-	import MainFooter from '../MainFooter.vue';
-	import CreateCommunityStation from '../Modals/CreateCommunityStation.vue';
-	import auth from '../../auth';
-	import io from '../../io';
-
-	export default {
-		data() {
-			return {
-				recaptcha: {
-					key: ''
-				},
-				stations: {
-					official: [],
-					community: []
-				},
-				modals: {
-					createCommunityStation: false
-				},
-				nightMode: false
+import { mapState, mapActions } from "vuex";
+
+import MainHeader from "../MainHeader.vue";
+import MainFooter from "../MainFooter.vue";
+import CreateCommunityStation from "../Modals/CreateCommunityStation.vue";
+import UserIdToUsername from "../UserIdToUsername.vue";
+
+import auth from "../../auth";
+import io from "../../io";
+
+export default {
+	data() {
+		return {
+			recaptcha: {
+				key: ""
+			},
+			stations: {
+				official: [],
+				community: []
 			}
-		},
-		ready() {
-			let _this = this;
-			auth.getStatus((authenticated, role, username, userId) => {
-				io.getSocket((socket) => {
-					_this.socket = socket;
-					if (_this.socket.connected) _this.init();
-					io.onConnect(() => {
-						_this.init();
-					});
-					_this.socket.on('event:stations.created', station => {
-						if (!station.currentSong) station.currentSong = { thumbnail: '/assets/notes-transparent.png' };
-						if (station.currentSong && !station.currentSong.thumbnail) station.currentSong.thumbnail = "/assets/notes-transparent.png";
-						_this.stations[station.type].push(station);
-					});
-					_this.socket.on('event:userCount.updated', (stationId, userCount) => {
-						_this.stations.official.forEach((station) => {
+		};
+	},
+	computed: mapState("modals", {
+		modals: state => state.modals.home
+	}),
+	mounted() {
+		const _this = this;
+		auth.getStatus(() => {
+			io.getSocket(socket => {
+				_this.socket = socket;
+				if (_this.socket.connected) _this.init();
+				io.onConnect(() => {
+					_this.init();
+				});
+				_this.socket.on("event:stations.created", res => {
+					const station = res;
+
+					if (!station.currentSong)
+						station.currentSong = {
+							thumbnail: "/assets/notes-transparent.png"
+						};
+					if (station.currentSong && !station.currentSong.thumbnail)
+						station.currentSong.thumbnail =
+							"/assets/notes-transparent.png";
+					_this.stations[station.type].push(station);
+				});
+				_this.socket.on(
+					"event:userCount.updated",
+					(stationId, userCount) => {
+						_this.stations.official.forEach(s => {
+							const station = s;
 							if (station._id === stationId) {
 								station.userCount = userCount;
 							}
 						});
 
-						_this.stations.community.forEach((station) => {
+						_this.stations.community.forEach(s => {
+							const station = s;
 							if (station._id === stationId) {
 								station.userCount = userCount;
 							}
 						});
+					}
+				);
+				_this.socket.on("event:station.nextSong", (stationId, song) => {
+					let newSong = song;
+					_this.stations.official.forEach(s => {
+						const station = s;
+						if (station._id === stationId) {
+							if (!newSong)
+								newSong = {
+									thumbnail: "/assets/notes-transparent.png"
+								};
+							if (newSong && !newSong.thumbnail)
+								newSong.thumbnail =
+									"/assets/notes-transparent.png";
+							station.currentSong = newSong;
+						}
 					});
-					_this.socket.on('event:station.nextSong', (stationId, newSong) => {
-						_this.stations.official.forEach((station) => {
-							if (station._id === stationId) {
-								if (!newSong) newSong = { thumbnail: '/assets/notes-transparent.png' };
-								if (newSong && !newSong.thumbnail) newSong.thumbnail = "/assets/notes-transparent.png";
-								station.currentSong = newSong;
-							}
-						});
 
-						_this.stations.community.forEach((station) => {
-							if (station._id === stationId) {
-								if (!newSong) newSong = { thumbnail: '/assets/notes-transparent.png' };
-								if (newSong && !newSong.thumbnail) newSong.thumbnail = "/assets/notes-transparent.png";
-								station.currentSong = newSong;
-							}
-						});
+					_this.stations.community.forEach(s => {
+						const station = s;
+						if (station._id === stationId) {
+							if (!newSong)
+								newSong = {
+									thumbnail: "/assets/notes-transparent.png"
+								};
+							if (newSong && !newSong.thumbnail)
+								newSong.thumbnail =
+									"/assets/notes-transparent.png";
+							station.currentSong = newSong;
+						}
 					});
 				});
 			});
-		},
-		methods: {
-			toggleModal: function (type) {
-				this.$dispatch('toggleModal', type);
-			},
-			init: function() {
-				let _this = this;
-				auth.getStatus((authenticated, role, username, userId) => {
-					_this.socket.emit('stations.index', data => {
-						_this.stations.community = [];
-						_this.stations.official = [];
-						if (data.status === "success") data.stations.forEach(station => {
-							if (!station.currentSong) station.currentSong = { thumbnail: '/assets/notes-transparent.png' };
-							if (station.currentSong && !station.currentSong.thumbnail) station.currentSong.thumbnail = "/assets/notes-transparent.png";
-							if (station.privacy !== 'public') station.class = { 'station-red': true }
-							else if (station.type === 'community' && station.owner === userId) station.class = { 'station-blue': true }
-							if (station.type == 'official') _this.stations.official.push(station);
+		});
+	},
+	methods: {
+		init() {
+			const _this = this;
+			auth.getStatus((authenticated, role, username, userId) => {
+				_this.socket.emit("stations.index", data => {
+					_this.stations.community = [];
+					_this.stations.official = [];
+					if (data.status === "success")
+						data.stations.forEach(s => {
+							const station = s;
+							if (!station.currentSong)
+								station.currentSong = {
+									thumbnail: "/assets/notes-transparent.png"
+								};
+							if (
+								station.currentSong &&
+								!station.currentSong.thumbnail
+							)
+								station.currentSong.thumbnail =
+									"/assets/notes-transparent.png";
+							if (station.privacy !== "public")
+								station.class = { "station-red": true };
+							else if (
+								station.type === "community" &&
+								station.owner === userId
+							)
+								station.class = { "station-blue": true };
+							if (station.type === "official")
+								_this.stations.official.push(station);
 							else _this.stations.community.push(station);
 						});
-					});
-					_this.socket.emit("apis.joinRoom", 'home', () => {
-					});
 				});
-			},
-			isOwner: function(station) {
-				let _this = this;
-				return station.owner === _this.$parent.userId && station.privacy === 'public';
-			}
+				_this.socket.emit("apis.joinRoom", "home", () => {});
+			});
+		},
+		isOwner(station) {
+			const _this = this;
+			return (
+				station.owner === _this.$parent.userId &&
+				station.privacy === "public"
+			);
 		},
-		components: { MainHeader, MainFooter, CreateCommunityStation }
+		...mapActions("modals", ["openModal"])
+	},
+	components: {
+		MainHeader,
+		MainFooter,
+		CreateCommunityStation,
+		UserIdToUsername
 	}
+};
 </script>
 
-<style lang='scss'>
-	* { box-sizing: border-box; }
+<style lang="scss">
+* {
+	box-sizing: border-box;
+}
 
-	html {
+html {
+	width: 100%;
+	height: 100%;
+	color: rgba(0, 0, 0, 0.87);
+
+	body {
 		width: 100%;
 		height: 100%;
-		color: rgba(0, 0, 0, 0.87);
-
-		body {
-			width: 100%;
-			height: 100%;
-			margin: 0;
-			padding: 0;
-		}
+		margin: 0;
+		padding: 0;
 	}
+}
 
-	@media only screen and (min-width: 1200px) {
-		html { font-size: 15px; }
+@media only screen and (min-width: 1200px) {
+	html {
+		font-size: 15px;
 	}
+}
 
-	@media only screen and (min-width: 992px) {
-		html { font-size: 14.5px; }
+@media only screen and (min-width: 992px) {
+	html {
+		font-size: 14.5px;
 	}
+}
 
-	@media only screen and (min-width: 0) {
-		html { font-size: 14px; }
+@media only screen and (min-width: 0) {
+	html {
+		font-size: 14px;
+	}
+}
+
+.under-content {
+	width: calc(100% - 40px);
+	left: 20px;
+	right: 20px;
+	bottom: 10px;
+	text-align: left;
+	height: 25px;
+	position: absolute;
+	margin-bottom: 10px;
+	line-height: 1;
+	font-size: 24px;
+	vertical-align: middle;
+
+	* {
+		z-index: 10;
+		position: relative;
 	}
 
-	.under-content {
-		width: calc(100% - 40px);
-		left: 20px;
-		right: 20px;
-		bottom: 10px;
-		text-align: left;
-		height: 25px;
-		position: absolute;
-		margin-bottom: 10px;
-		line-height: 1;
-	    font-size: 24px;
-	    vertical-align: middle;
-
-		* {
-			z-index: 10;
-			position: relative;
-		}
+	.official {
+		font-size: 18px;
+		color: #03a9f4;
+		position: relative;
+		top: -5px;
 
-		.right-icon {
-			float: right;
+		.badge {
+			position: relative;
+			padding-right: 2px;
+			color: #38d227;
+			top: +5px;
 		}
 	}
 
-	.users-count {
-		font-size: 20px;
-		position: relative;
-		top: -4px;
+	.hostedby {
+		font-size: 15px;
+
+		.host {
+			color: #03a9f4;
+
+			a {
+				color: #03a9f4;
+			}
+		}
 	}
 
-	.right {
+	.right-icon {
 		float: right;
 	}
+}
 
-	.group { min-height: 64px; }
+.users-count {
+	font-size: 20px;
+	position: relative;
+	top: -4px;
+}
 
-	.station-card {
-		margin: 10px;
-		cursor: pointer;
-		height: 475px;
+.right {
+	float: right;
+}
 
-		transition: all ease-in-out 0.2s;
+.group {
+	min-height: 64px;
+}
 
-		.card-content {
-			max-height: 159px;
+.station-card {
+	margin: 10px;
+	cursor: pointer;
+	height: 475px;
 
-			.content {
-				word-wrap: break-word;
-				overflow: hidden;
-				text-overflow: ellipsis;
-				display: -webkit-box;
-				-webkit-box-orient: vertical;
-				-webkit-line-clamp: 3;
-				line-height: 20px;
-				max-height: 60px;
-			}
+	transition: all ease-in-out 0.2s;
+
+	.card-content {
+		max-height: 159px;
+
+		.content {
+			word-wrap: break-word;
+			overflow: hidden;
+			text-overflow: ellipsis;
+			display: -webkit-box;
+			-webkit-box-orient: vertical;
+			-webkit-line-clamp: 3;
+			line-height: 20px;
+			max-height: 60px;
 		}
 	}
+}
 
-	.station-card:hover {
-		box-shadow: 0 2px 3px rgba(10, 10, 10, 0.3), 0 0 10px rgba(10, 10, 10, 0.3);
-		transition: all ease-in-out 0.2s;
-	}
+.station-card:hover {
+	box-shadow: 0 2px 3px rgba(10, 10, 10, 0.3), 0 0 10px rgba(10, 10, 10, 0.3);
+	transition: all ease-in-out 0.2s;
+}
 
-	/*.isPrivate {
+/*.isPrivate {
 		background-color: #F8BBD0;
 	}
 
@@ -269,123 +433,81 @@
 		background-color: #29B6F6;
 	}*/
 
-	.community-button {
-		cursor: pointer;
-		transition: .25s ease color;
-		font-size: 30px;
-		color: #4a4a4a;
-	}
-
-	.community-button:hover { color: #ff4545; }
-
-	.station-privacy { text-transform: capitalize; }
-
-	.label { display: flex; }
-
-	.g-recaptcha {
-		display: flex;
-		justify-content: center;
-		margin-top: 20px;
-	}
-
-	.group {
-		text-align: center;
+.community-button {
+	cursor: pointer;
+	transition: 0.25s ease color;
+	font-size: 30px;
+	color: #4a4a4a;
+}
+
+.community-button:hover {
+	color: #03a9f4;
+}
+
+.station-privacy {
+	text-transform: capitalize;
+}
+
+.label {
+	display: flex;
+}
+
+.g-recaptcha {
+	display: flex;
+	justify-content: center;
+	margin-top: 20px;
+}
+
+.group {
+	text-align: center;
+	width: 100%;
+
+	.group-title {
+		float: left;
+		clear: none;
 		width: 100%;
-		margin: 64px 0 0 0;
-
-		.group-title {
-			float: left;
-			clear: none;
-			width: 100%;
-			height: 64px;
-			line-height: 48px;
-			text-align: center;
-			font-size: 48px;
-			margin-bottom: 25px;
-		}
+		height: 64px;
+		line-height: 48px;
+		text-align: center;
+		font-size: 48px;
+		margin-bottom: 25px;
 	}
+}
 
-	.group .card {
-		display: inline-flex;
-    	flex-direction: column;
-		overflow: hidden;
+.group .card {
+	display: inline-flex;
+	flex-direction: column;
+	overflow: hidden;
 
-		.content {
-			text-align: left;
-			word-wrap: break-word;
-		}
-
-		.media {
-			display: flex;
-    		align-items: center;
-
-			.station-status { line-height: 13px; }
-
-			h5 { margin: 0; }
-		}
-	}
-
-	.displayName {
-		word-wrap: break-word;
-    	width: 80%;
+	.content {
+		text-align: left;
 		word-wrap: break-word;
-	    overflow: hidden;
-	    text-overflow: ellipsis;
-	    display: -webkit-box;
-	    -webkit-box-orient: vertical;
-	    -webkit-line-clamp: 1;
-	    line-height: 30px;
-	    max-height: 30px;
 	}
 
-	.nightMode {
-		background-color: rgb(51, 51, 51);
-		color: #e6e6e6;
-
-		.community-button {
-			cursor: pointer;
-			transition: .25s ease color;
-			font-size: 30px;
-			color: #e6e6e6;
-		}
-
-		.community-button:hover { color: #ff4545; }
-
-		.station-card {
-			margin: 10px;
-			cursor: pointer;
-			height: 475px;
-			background-color: rgb(51, 51, 51);
-			color: #e6e6e6;
-
-			.card-content {
-				max-height: 159px;
-				color: #e6e6e6;
-
-				.content {
-					word-wrap: break-word;
-					overflow: hidden;
-					text-overflow: ellipsis;
-					display: -webkit-box;
-					-webkit-box-orient: vertical;
-					-webkit-line-clamp: 3;
-					line-height: 20px;
-					max-height: 60px;
-					color: #e6e6e6;
-				}
-			}
-		}
-
-		.station-card:hover {
-			box-shadow: 0 2px 3px rgba(10, 10, 10, 0.3), 0 0 10px rgba(10, 10, 10, 0.3);
-		}
+	.media {
+		display: flex;
+		align-items: center;
 
-		.isPrivate {
-			background-color: #d01657;
+		.station-status {
+			line-height: 13px;
 		}
 
-		.isMine {
-			background-color: #0777ab;
+		h5 {
+			margin: 0;
 		}
 	}
+}
+
+.displayName {
+	word-wrap: break-word;
+	width: 80%;
+	word-wrap: break-word;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	display: -webkit-box;
+	-webkit-box-orient: vertical;
+	-webkit-line-clamp: 1;
+	line-height: 30px;
+	max-height: 30px;
+}
 </style>

+ 125 - 80
frontend/components/pages/News.vue

@@ -1,115 +1,160 @@
 <template>
-	<div class='app'>
-		<main-header></main-header>
-		<div class='container'>
-			<div class='card is-fullwidth' v-for='item in news'>
-				<header class='card-header'>
-					<p class='card-header-title'>
+	<div class="app">
+		<main-header />
+		<div class="container">
+			<div
+				v-for="(item, index) in news"
+				:key="index"
+				class="card is-fullwidth"
+			>
+				<header class="card-header">
+					<p class="card-header-title">
 						{{ item.title }} - {{ formatDate(item.createdAt) }}
 					</p>
 				</header>
-				<div class='card-content'>
-					<div class='content'>
+				<div class="card-content">
+					<div class="content">
 						<p>{{ item.description }}</p>
 					</div>
-					<div class='sect' v-show='item.features.length > 0'>
-						<div class='sect-head-features'>The features are so great</div>
-						<ul class='sect-body'>
-							<li v-for='li in item.features'>{{ li }}</li>
+					<div v-show="item.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 item.features"
+								:key="index"
+							>
+								{{ feature }}
+							</li>
 						</ul>
 					</div>
-					<div class='sect' v-show='item.improvements.length > 0'>
-						<div class='sect-head-improvements'>Improvements</div>
-						<ul class='sect-body'>
-							<li v-for='li in item.improvements'>{{ li }}</li>
+					<div v-show="item.improvements.length > 0" class="sect">
+						<div class="sect-head-improvements">
+							Improvements
+						</div>
+						<ul class="sect-body">
+							<li
+								v-for="(improvement,
+								index) in item.improvements"
+								:key="index"
+							>
+								{{ improvement }}
+							</li>
 						</ul>
 					</div>
-					<div class='sect' v-show='item.bugs.length > 0'>
-						<div class='sect-head-bugs'>Bugs Smashed</div>
-						<ul class='sect-body'>
-							<li v-for='li in item.bugs'>{{ li }}</li>
+					<div v-show="item.bugs.length > 0" class="sect">
+						<div class="sect-head-bugs">
+							Bugs Smashed
+						</div>
+						<ul class="sect-body">
+							<li v-for="(bug, index) in item.bugs" :key="index">
+								{{ bug }}
+							</li>
 						</ul>
 					</div>
-					<div class='sect' v-show='item.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 item.upcoming'>{{ li }}</li>
+					<div v-show="item.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 item.upcoming"
+								:key="index"
+							>
+								{{ upcoming }}
+							</li>
 						</ul>
 					</div>
 				</div>
 			</div>
-			<h3 v-if="noFound" class="center">No news items were found.</h3>
+			<h3 v-if="noFound" class="center">
+				No news items were found.
+			</h3>
 		</div>
-		<main-footer></main-footer>
+		<main-footer />
 	</div>
 </template>
 
 <script>
-	import MainHeader from '../MainHeader.vue';
-	import MainFooter from '../MainFooter.vue';
-	import io from '../../io';
+import MainHeader from "../MainHeader.vue";
+import MainFooter from "../MainFooter.vue";
+import io from "../../io";
 
-	export default {
-		components: { MainHeader, MainFooter },
-		methods: {
-			formatDate: unix => {
-				return moment(unix).format('DD-MM-YYYY');
-			}
-		},
-		data() {
-			return {
-				news: [],
-				noFound: false
-			}
-		},
-		ready: function () {
-			let _this = this;
-			io.getSocket((socket) => {
-				_this.socket = socket;
-				_this.socket.emit('news.index', res => {
-					_this.news = res.data;
-					if (_this.news.length === 0) _this.noFound = true;
-				});
-				_this.socket.on('event:admin.news.created', news => {
-					_this.news.unshift(news);
-					_this.noFound = false;
-				});
-				_this.socket.on('event:admin.news.updated', news => {
-					for (let n = 0; n < _this.news.length; n++) {
-						if (_this.news[n]._id === news._id) {
-							_this.news.$set(n, news);
-						}
+export default {
+	components: { MainHeader, MainFooter },
+	data() {
+		return {
+			news: [],
+			noFound: false
+		};
+	},
+	mounted() {
+		const _this = this;
+		io.getSocket(socket => {
+			_this.socket = socket;
+			_this.socket.emit("news.index", res => {
+				_this.news = res.data;
+				if (_this.news.length === 0) _this.noFound = true;
+			});
+			_this.socket.on("event:admin.news.created", news => {
+				_this.news.unshift(news);
+				_this.noFound = false;
+			});
+			_this.socket.on("event:admin.news.updated", news => {
+				for (let n = 0; n < _this.news.length; n += 1) {
+					if (_this.news[n]._id === news._id) {
+						_this.news.$set(n, news);
 					}
-				});
-				_this.socket.on('event:admin.news.removed', news => {
-					_this.news = _this.news.filter(item => item._id !== news._id);
-					if (_this.news.length === 0) _this.noFound = true;
-				});
+				}
+			});
+			_this.socket.on("event:admin.news.removed", news => {
+				_this.news = _this.news.filter(item => item._id !== news._id);
+				if (_this.news.length === 0) _this.noFound = true;
 			});
+		});
+	},
+	methods: {
+		formatDate: unix => {
+			return moment(unix).format("DD-MM-YYYY");
 		}
 	}
+};
 </script>
 
-<style lang='scss' scoped>
-	.card { margin-top: 50px; }
+<style lang="scss" scoped>
+.card {
+	margin-top: 50px;
+}
 
-	.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: #fff;
+	}
 
-		.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>

+ 179 - 36
frontend/components/pages/Privacy.vue

@@ -1,69 +1,212 @@
 <template>
-	<div class='app'>
-		<main-header></main-header>
-		<div class='container'>
+	<div class="app">
+		<main-header />
+		<div class="container">
 			<h1>MUSARE PRIVACY POLICY</h1>
 			<h4>Last Updated: January 25, 2016</h4>
 
 			<h4>1. Introduction</h4>
-			Musare.com respects your privacy and the security of your personal information, and we want to do as much as we can to protect it. Because of this, we have created this Privacy Policy to govern how we deal with your personal information. Since our Site is built off of Content that you provide, including shared information from third party sites, it is important that you read and understand their information sharing policies as well. Please check back often, as we will update this Privacy Policy as we grow.
+			Musare.com respects your privacy and the security of your personal
+			information, and we want to do as much as we can to protect it.
+			Because of this, we have created this Privacy Policy to govern how
+			we deal with your personal information. Since our Site is built off
+			of Content that you provide, including shared information from third
+			party sites, it is important that you read and understand their
+			information sharing policies as well. Please check back often, as we
+			will update this Privacy Policy as we grow.
 
 			<h4>2. Personal Information We Collect</h4>
-			<p>In order for you to sign up for our service, we may ask for personal information from you including your name, e-mail address, mailing address, phone number, photo, username from other social media sites, gender, date of birth, or other relevant information. In addition, we utilize third party API’s like GitHub Authentication, and other API’s that allow you to transfer your profile information from those Sites to ours depending on your settings on those Sites. We are not responsible for any information that does not transfer or if any information is inaccurate.</p>
-
-			<p>Your use of any of the video or chat features may be recorded or logged by our servers. We may use this data to improve our Site or Platform, or to determine how best to provide marketing opportunities to you.</p>
-
-			<p>We use the above referenced information to contact you regarding your account, assist in customer service and support, and to improve our Site and the musare.com platform. We also use the information we collect to send periodic communications to you regarding updates to our Site, new features, and marketing opportunities that we think you may find interesting.</p>
-
-			<p>We may send you periodic emails that concern updates or features. We make sure to comply with CAN-SPAM Act of 2003, 15 U.S.C. 7701 whenever we send you these goodies. If you feel that you are receiving unwanted messages from us (which we hope isn’t the case!) then please use the unsubscribe button or email us at musaremusic@gmail.com to remove yourself from our list. Please allow for up to ten (10) business days to process the removal.</p>
+			<p>
+				In order for you to sign up for our service, we may ask for
+				personal information from you including your name, e-mail
+				address, mailing address, phone number, photo, username from
+				other social media sites, gender, date of birth, or other
+				relevant information. In addition, we utilize third party API’s
+				like GitHub Authentication, and other API’s that allow you to
+				transfer your profile information from those Sites to ours
+				depending on your settings on those Sites. We are not
+				responsible for any information that does not transfer or if any
+				information is inaccurate.
+			</p>
+
+			<p>
+				Your use of any of the video or chat features may be recorded or
+				logged by our servers. We may use this data to improve our Site
+				or Platform, or to determine how best to provide marketing
+				opportunities to you.
+			</p>
+
+			<p>
+				We use the above referenced information to contact you regarding
+				your account, assist in customer service and support, and to
+				improve our Site and the musare.com platform. We also use the
+				information we collect to send periodic communications to you
+				regarding updates to our Site, new features, and marketing
+				opportunities that we think you may find interesting.
+			</p>
+
+			<p>
+				We may send you periodic emails that concern updates or
+				features. We make sure to comply with CAN-SPAM Act of 2003, 15
+				U.S.C. 7701 whenever we send you these goodies. If you feel that
+				you are receiving unwanted messages from us (which we hope isn’t
+				the case!) then please use the unsubscribe button or email us at
+				musaremusic@gmail.com to remove yourself from our list. Please
+				allow for up to ten (10) business days to process the removal.
+			</p>
 
 			<h4>3. Non-Personal Information</h4>
-			<p>We may collect information about you that we consider to be less sensitive. When you access our website, we may collect such things as your IP address, browser, operating system, and other information that helps us know about the general nature of our visitors. We use this information to improve our Site and the musare.com platform.</p>
+			<p>
+				We may collect information about you that we consider to be less
+				sensitive. When you access our website, we may collect such
+				things as your IP address, browser, operating system, and other
+				information that helps us know about the general nature of our
+				visitors. We use this information to improve our Site and the
+				musare.com platform.
+			</p>
 
 			<h4>4. Cookies</h4>
-			<p>We use tracking cookies to distinguish you from other users to help prevent one user from unwittingly logging into another user’s account on the same computer or network. In conjunction with third party API’s, we also allow you to login using your credentials on those third party sites. These Sites may use cookies to track your web browsing, and have separate privacy policies that you must read. In addition, any time you share Content with others those third party Sites may collect information about people who view or share that Content. You must also read their privacy policies.</p>
-
-			<p>We also may use tracking cookies to help ourselves or third party advertisers increase the effectiveness and quality of, and interest in, our marketing programs, or for other advertising or marketing purposes.</p>
-
-			<p>Any advertisements served by Google, Inc., and affiliated companies may be controlled using cookies. These cookies allow Google to display ads based on your visits to this site and other sites that use Google advertising services. Learn how to opt out of Google’s cookie usage. As mentioned above, any tracking done by Google through cookies and other mechanisms is subject to Google’s own privacy policies.</p>
-
-			<p>Your use of the Site may require that you have cookies turned on, depending on your login preferences.</p>
+			<p>
+				We use tracking cookies to distinguish you from other users to
+				help prevent one user from unwittingly logging into another
+				user’s account on the same computer or network. In conjunction
+				with third party API’s, we also allow you to login using your
+				credentials on those third party sites. These Sites may use
+				cookies to track your web browsing, and have separate privacy
+				policies that you must read. In addition, any time you share
+				Content with others those third party Sites may collect
+				information about people who view or share that Content. You
+				must also read their privacy policies.
+			</p>
+
+			<p>
+				We also may use tracking cookies to help ourselves or third
+				party advertisers increase the effectiveness and quality of, and
+				interest in, our marketing programs, or for other advertising or
+				marketing purposes.
+			</p>
+
+			<p>
+				Any advertisements served by Google, Inc., and affiliated
+				companies may be controlled using cookies. These cookies allow
+				Google to display ads based on your visits to this site and
+				other sites that use Google advertising services. Learn how to
+				opt out of Google’s cookie usage. As mentioned above, any
+				tracking done by Google through cookies and other mechanisms is
+				subject to Google’s own privacy policies.
+			</p>
+
+			<p>
+				Your use of the Site may require that you have cookies turned
+				on, depending on your login preferences.
+			</p>
 
 			<h4>5. User Content</h4>
-			<p>We may allow you to post Content to our website, including videos and music. This content, once posted, is available for anyone to see and you are granting us the limited license for our use in accordance with our Terms of Service. As such, you must make sure you do not post anything that you do not have the rights to distribute. Please engage your brain when posting content.</p>
+			<p>
+				We may allow you to post Content to our website, including
+				videos and music. This content, once posted, is available for
+				anyone to see and you are granting us the limited license for
+				our use in accordance with our Terms of Service. As such, you
+				must make sure you do not post anything that you do not have the
+				rights to distribute. Please engage your brain when posting
+				content.
+			</p>
 
 			<h4>6. Third Party Sites</h4>
-			<p>Since our Site is built off of Content and sharing, you can be sure that you will encounter links to third party sites or Content that is being displayed from a third party site. Anytime you encounter a link to a website outside of musare.com, you should know that we have no control over that Site. We recommend that you consult those websites privacy policies, terms of service, and other similar documents when using them.</p>
-
-			<p>You may also have the ability to interface, through the use of APIs, with third party websites such as social websites like Facebook, GitHub and Twitter. Be advised that we cannot be responsible for any breaches of privacy that may arise from the use of these third party websites.</p>
+			<p>
+				Since our Site is built off of Content and sharing, you can be
+				sure that you will encounter links to third party sites or
+				Content that is being displayed from a third party site. Anytime
+				you encounter a link to a website outside of musare.com, you
+				should know that we have no control over that Site. We recommend
+				that you consult those websites privacy policies, terms of
+				service, and other similar documents when using them.
+			</p>
+
+			<p>
+				You may also have the ability to interface, through the use of
+				APIs, with third party websites such as social websites like
+				Facebook, GitHub and Twitter. Be advised that we cannot be
+				responsible for any breaches of privacy that may arise from the
+				use of these third party websites.
+			</p>
 
 			<h4>7. Access to Information and Data Storage</h4>
-			<p>We may host data with third parties and allow third parties to access, maintain, or otherwise use your information for purposes that we deem conducive to improving our business and service. We will strive to always deal with reputable providers, but we cannot make any guarantees. As such, you hereby agree that we are not liable for any privacy breaches that may occur as a result of the actions of third parties. In addition, how you interact with our Site may be shared with the third party service that you used to login, which means you are also storing information on their servers, which is governed by their own agreements.</p>
+			<p>
+				We may host data with third parties and allow third parties to
+				access, maintain, or otherwise use your information for purposes
+				that we deem conducive to improving our business and service. We
+				will strive to always deal with reputable providers, but we
+				cannot make any guarantees. As such, you hereby agree that we
+				are not liable for any privacy breaches that may occur as a
+				result of the actions of third parties. In addition, how you
+				interact with our Site may be shared with the third party
+				service that you used to login, which means you are also storing
+				information on their servers, which is governed by their own
+				agreements.
+			</p>
 
 			<h4>8. Law Enforcement</h4>
-			<p>We may disclose your information to a third party where we believe, in good faith that we are required to for legal purposes. The disclosure may be due to a criminal investigation, or a civil subpoena. If we receive such a request we may, but are not required to, notify you of such request and give you an opportunity to respond.</p>
+			<p>
+				We may disclose your information to a third party where we
+				believe, in good faith that we are required to for legal
+				purposes. The disclosure may be due to a criminal investigation,
+				or a civil subpoena. If we receive such a request we may, but
+				are not required to, notify you of such request and give you an
+				opportunity to respond.
+			</p>
 
 			<h4>9. Children's Online Privacy Protection Act</h4>
-			<p>We do not allow users on our website who are under the age of thirteen years old. If you become aware of such a user, please notify us immediately. If you are reported as being in violation of our age policy, we may freeze your account and require that you submit satisfactory proof of age before you may continue using our service.</p>
+			<p>
+				We do not allow users on our website who are under the age of
+				thirteen years old. If you become aware of such a user, please
+				notify us immediately. If you are reported as being in violation
+				of our age policy, we may freeze your account and require that
+				you submit satisfactory proof of age before you may continue
+				using our service.
+			</p>
 
 			<h4>10. Amendments</h4>
-			<p>We may amend this Privacy Policy under the same conditions as our Terms of Service. Your responsibility to keep yourself updated as to changes to this Privacy Policy is the same as in our “Amendments” section in our Terms of Service.</p>
+			<p>
+				We may amend this Privacy Policy under the same conditions as
+				our Terms of Service. Your responsibility to keep yourself
+				updated as to changes to this Privacy Policy is the same as in
+				our “Amendments” section in our Terms of Service.
+			</p>
 
 			<h4>11. Users from outside the United States</h4>
-			<p>We may have users who are from outside the United States. If you are, you are acknowledging that your information is being transferred from your country to ours. To the extent we are required, we maintain our Site and information collection practices in a way that conforms with most laws. If you are from a jurisdiction who's information collection practices differ from ours, please notify us so that we may take necessary action. This may include terminating your account and deleting your information. We are committed to resolving those issues, so if you have any questions about how we collect or use your information you may email us at musaremusic@gmail.com.</p>
+			<p>
+				We may have users who are from outside the United States. If you
+				are, you are acknowledging that your information is being
+				transferred from your country to ours. To the extent we are
+				required, we maintain our Site and information collection
+				practices in a way that conforms with most laws. If you are from
+				a jurisdiction who's information collection practices differ
+				from ours, please notify us so that we may take necessary
+				action. This may include terminating your account and deleting
+				your information. We are committed to resolving those issues, so
+				if you have any questions about how we collect or use your
+				information you may email us at musaremusic@gmail.com.
+			</p>
 
 			<h4>12. Deactivating your account</h4>
-			<p>You may deactivate your account at any time by accessing your account settings, or send us a mail at musaremusic@gmail.com. When submitting your request, please let us know what led you to deactivate your account. Your feedback is greatly appreciated, and will help us to better accommodate members of the community.</p>
+			<p>
+				You may deactivate your account at any time by accessing your
+				account settings, or send us a mail at musaremusic@gmail.com.
+				When submitting your request, please let us know what led you to
+				deactivate your account. Your feedback is greatly appreciated,
+				and will help us to better accommodate members of the community.
+			</p>
 		</div>
-		<main-footer></main-footer>
+		<main-footer />
 	</div>
 </template>
 
 <script>
-	import MainHeader from '../MainHeader.vue';
-	import MainFooter from '../MainFooter.vue';
+import MainHeader from "../MainHeader.vue";
+import MainFooter from "../MainFooter.vue";
 
-	export default {
-		components: { MainHeader, MainFooter }
-	}
-</script>
+export default {
+	components: { MainHeader, MainFooter }
+};
+</script>

+ 153 - 212
frontend/components/pages/Team.vue

@@ -1,20 +1,31 @@
 <template>
-	<div class='app'>
-		<main-header></main-header>
-		<div class='container'>
-			<h3 class="center">Current members</h3>
-			<br>
+	<div class="app">
+		<main-header />
+		<div class="content-wrapper">
+			<h3 class="center">
+				Our Team
+			</h3>
+			<br />
 			<div class="columns">
-				<div class='card column is-6-desktop is-offset-3-desktop is-12-mobile'>
-					<header class='card-header'>
-						<p class='card-header-title'>
+				<div
+					class="card column is-6-desktop is-offset-3-desktop is-12-mobile"
+				>
+					<header class="card-header">
+						<p class="card-header-title">
 							Kris
 						</p>
 					</header>
-					<div class='card-content'>
-						<div class='content'>
-							<span class="custom-tag purple">senior-project-manager</span>
-							<span class="custom-tag blue">co-founder</span>
+					<div class="card-content">
+						<div class="content">
+							<span class="role"
+								><span class="custom-tag purple"
+									>Senior Project Manager</span
+								>
+								and
+								<span class="custom-tag blue"
+									>Co-Founder</span
+								></span
+							>
 							<ul>
 								<li>
 									<b>Joined: </b>
@@ -22,25 +33,36 @@
 								</li>
 								<li>
 									<b>Email: </b>
-									<a href="&#109;&#097;&#105;&#108;&#116;&#111;:&#107;&#114;&#105;&#115;&#064;&#109;&#117;&#115;&#097;&#114;&#101;&#046;&#099;&#111;&#109;">&#107;&#114;&#105;&#115;&#064;&#109;&#117;&#115;&#097;&#114;&#101;&#046;&#099;&#111;&#109;</a>
+									<a href="mailto:kris@musare.com"
+										>&#107;&#114;&#105;&#115;&#064;&#109;&#117;&#115;&#097;&#114;&#101;&#046;&#099;&#111;&#109;</a
+									>
 								</li>
 							</ul>
 						</div>
 					</div>
 				</div>
 			</div>
-			<br>
+			<br />
 			<div class="columns">
-				<div class='card column is-6-desktop is-offset-3-desktop is-12-mobile'>
-					<header class='card-header'>
-						<p class='card-header-title'>
+				<div
+					class="card column is-6-desktop is-offset-3-desktop is-12-mobile"
+				>
+					<header class="card-header">
+						<p class="card-header-title">
 							Owen Diffey
 						</p>
 					</header>
-					<div class='card-content'>
-						<div class='content'>
-							<span class="custom-tag purple">project-manager</span>
-							<span class="custom-tag light-blue">developer</span>
+					<div class="card-content">
+						<div class="content">
+							<span class="role"
+								><span class="custom-tag purple"
+									>Project Manager</span
+								>
+								and
+								<span class="custom-tag light-blue"
+									>Developer</span
+								></span
+							>
 							<ul>
 								<li>
 									<b>Joined: </b>
@@ -48,24 +70,32 @@
 								</li>
 								<li>
 									<b>Email: </b>
-									<a href="&#109;&#097;&#105;&#108;&#116;&#111;:&#111;&#119;&#101;&#110;&#064;&#109;&#117;&#115;&#097;&#114;&#101;&#046;&#099;&#111;&#109;">&#111;&#119;&#101;&#110;&#064;&#109;&#117;&#115;&#097;&#114;&#101;&#046;&#099;&#111;&#109;</a>
+									<a href="mailto:owen@musare.com"
+										>&#111;&#119;&#101;&#110;&#064;&#109;&#117;&#115;&#097;&#114;&#101;&#046;&#099;&#111;&#109;</a
+									>
 								</li>
 							</ul>
 						</div>
 					</div>
 				</div>
 			</div>
-			<br>
+			<br />
 			<div class="columns">
-				<div class='card column is-6-desktop is-offset-3-desktop is-12-mobile'>
-					<header class='card-header'>
-						<p class='card-header-title'>
+				<div
+					class="card column is-6-desktop is-offset-3-desktop is-12-mobile"
+				>
+					<header class="card-header">
+						<p class="card-header-title">
 							Jonathan
 						</p>
 					</header>
-					<div class='card-content'>
-						<div class='content'>
-							<span class="custom-tag light-blue">lead-developer</span>
+					<div class="card-content">
+						<div class="content">
+							<span class="role"
+								><span class="custom-tag light-blue"
+									>Lead Developer</span
+								></span
+							>
 							<ul>
 								<li>
 									<b>Joined: </b>
@@ -73,24 +103,32 @@
 								</li>
 								<li>
 									<b>Email: </b>
-									<a href="&#109;&#097;&#105;&#108;&#116;&#111;:&#106;&#111;&#110;&#097;&#116;&#104;&#097;&#110;&#064;&#109;&#117;&#115;&#097;&#114;&#101;&#046;&#099;&#111;&#109;">&#106;&#111;&#110;&#097;&#116;&#104;&#097;&#110;&#064;&#109;&#117;&#115;&#097;&#114;&#101;&#046;&#099;&#111;&#109;</a>
+									<a href="mailto:jonathan@musare.com"
+										>&#106;&#111;&#110;&#097;&#116;&#104;&#097;&#110;&#064;&#109;&#117;&#115;&#097;&#114;&#101;&#046;&#099;&#111;&#109;</a
+									>
 								</li>
 							</ul>
 						</div>
 					</div>
 				</div>
 			</div>
-			<br>
+			<br />
 			<div class="columns">
-				<div class='card column is-6-desktop is-offset-3-desktop is-12-mobile'>
-					<header class='card-header'>
-						<p class='card-header-title'>
+				<div
+					class="card column is-6-desktop is-offset-3-desktop is-12-mobile"
+				>
+					<header class="card-header">
+						<p class="card-header-title">
 							Antonio
 						</p>
 					</header>
-					<div class='card-content'>
-						<div class='content'>
-							<span class="custom-tag light-green">moderator</span>
+					<div class="card-content">
+						<div class="content">
+							<span class="role"
+								><span class="custom-tag light-green"
+									>Moderator</span
+								></span
+							>
 							<ul>
 								<li>
 									<b>Joined: </b>
@@ -98,71 +136,9 @@
 								</li>
 								<li>
 									<b>Email: </b>
-									<a href="&#109;&#097;&#105;&#108;&#116;&#111;:&#108;&#108;&#100;&#106;&#115;&#104;&#097;&#100;&#111;&#119;&#108;&#108;&#064;&#103;&#109;&#097;&#105;&#108;&#046;&#099;&#111;&#109;">&#108;&#108;&#100;&#106;&#115;&#104;&#097;&#100;&#111;&#119;&#108;&#108;&#064;&#103;&#109;&#097;&#105;&#108;&#046;&#099;&#111;&#109;</a>
-								</li>
-							</ul>
-						</div>
-					</div>
-				</div>
-			</div>
-			<h3 class="center">Old/inactive members</h3>
-			<br />
-			<div class="columns">
-				<div class='card column is-6-desktop is-offset-3-desktop is-12-mobile'>
-					<header class='card-header'>
-						<p class='card-header-title'>
-							Adryd
-						</p>
-					</header>
-					<div class='card-content'>
-						<div class='content'>
-							<span class="custom-tag pink">designer</span>
-							<ul>
-								<li>
-									<b>Joined: </b>
-									April 21, 2017
-								</li>
-							</ul>
-						</div>
-					</div>
-				</div>
-			</div>
-			<br />
-			<div class="columns">
-				<div class='card column is-6-desktop is-offset-3-desktop is-12-mobile'>
-					<header class='card-header'>
-						<p class='card-header-title'>
-							Cameron Kline
-						</p>
-					</header>
-					<div class='card-content'>
-						<div class='content'>
-							<span class="custom-tag light-blue">developer</span>
-							<ul>
-								<li>
-									<b>Joined: </b>
-									August 26, 2016
-								</li>
-							</ul>
-						</div>
-					</div>
-				</div>
-			</div>
-			<br />
-			<div class="columns">
-				<div class='card column is-6-desktop is-offset-3-desktop is-12-mobile'>
-					<header class='card-header'>
-						<p class='card-header-title'>
-							Wesley McCann
-						</p>
-					</header>
-					<div class='card-content'>
-						<div class='content'>
-							<span class="custom-tag light-blue">developer</span>
-							<ul>
-								<li>
-									<b>Joined: </b>
-									November 8, 2015
+									<a href="mailto:antonio@musare.com"
+										>&#97;&#110;&#116;&#111;&#110;&#105;&#111;&#64;&#109;&#117;&#115;&#97;&#114;&#101;&#46;&#99;&#111;&#109;</a
+									>
 								</li>
 							</ul>
 						</div>
@@ -171,143 +147,108 @@
 			</div>
 			<br />
 			<div class="columns">
-				<div class='card column is-6-desktop is-offset-3-desktop is-12-mobile'>
-					<header class='card-header'>
-						<p class='card-header-title'>
-							Nex
+				<div
+					class="card column is-6-desktop is-offset-3-desktop is-12-mobile"
+				>
+					<header class="card-header">
+						<p class="card-header-title">
+							Zachary
 						</p>
 					</header>
-					<div class='card-content'>
-						<div class='content'>
-							<span class="custom-tag light-blue">developer</span>
+					<div class="card-content">
+						<div class="content">
+							<span class="role"
+								><span class="custom-tag light-blue"
+									>Developer</span
+								></span
+							>
 							<ul>
 								<li>
 									<b>Joined: </b>
-									February 29, 2016
+									July 12, 2019
 								</li>
-							</ul>
-						</div>
-					</div>
-				</div>
-			</div>
-			<br />
-			<div class="columns">
-				<div class='card column is-6-desktop is-offset-3-desktop is-12-mobile'>
-					<header class='card-header'>
-						<p class='card-header-title'>
-							Akira Laine
-						</p>
-					</header>
-					<div class='card-content'>
-						<div class='content'>
-							<span class="custom-tag blue">co-founder</span>
-							<span class="custom-tag light-blue">lead-developer</span>
-							<ul>
 								<li>
-									<b>Joined: </b>
-									September 23, 2015
-								</li>
-							</ul>
-						</div>
-					</div>
-				</div>
-			</div>
-			<br />
-			<div class="columns">
-				<div class='card column is-6-desktop is-offset-3-desktop is-12-mobile'>
-					<header class='card-header'>
-						<p class='card-header-title'>
-							Johannes Andersen
-						</p>
-					</header>
-					<div class='card-content'>
-						<div class='content'>
-							<span class="custom-tag light-blue">developer</span>
-							<ul>
-								<li>
-									<b>Joined: </b>
-									September 23, 2015
+									<b>Email: </b>
+									<a href="mailto:zachary@musare.com"
+										>&#122;&#97;&#99;&#104;&#97;&#114;&#121;&#64;&#109;&#117;&#115;&#97;&#114;&#101;&#46;&#99;&#111;&#109;</a
+									>
 								</li>
 							</ul>
 						</div>
 					</div>
 				</div>
 			</div>
+			<h4 class="center">
+				Special Thanks
+			</h4>
 			<br />
-			<div class="columns">
-				<div class='card column is-6-desktop is-offset-3-desktop is-12-mobile'>
-					<header class='card-header'>
-						<p class='card-header-title'>
-							Aaron Gildea
-						</p>
-					</header>
-					<div class='card-content'>
-						<div class='content'>
-							<span class="custom-tag light-green">moderator</span>
-							<ul>
-								<li>
-									<b>Joined: </b>
-									November 7, 2015
-								</li>
-							</ul>
-						</div>
-					</div>
-				</div>
-			</div>
+			<p class="center thanks">
+				Special thanks to Adryd, Cameron Kline, Wesley McCann,
+				<strong>Akira Laine (Co-Founder)</strong>, Johannes Andersen and
+				Aaron Gildea for their contributions to Musare.
+			</p>
 		</div>
-		<main-footer></main-footer>
+		<main-footer />
 	</div>
 </template>
 
 <script>
-	import MainHeader from '../MainHeader.vue';
-	import MainFooter from '../MainFooter.vue';
+import MainHeader from "../MainHeader.vue";
+import MainFooter from "../MainFooter.vue";
 
-	export default {
-		components: { MainHeader, MainFooter }
-	}
+export default {
+	components: { MainHeader, MainFooter }
+};
 </script>
 
-<style lang='scss' scoped>
-	li a {
-		color: dodgerblue;
-    	border-bottom: 0 !important;
-	}
+<style lang="scss" scoped>
+li a {
+	color: dodgerblue;
+	border-bottom: 0 !important;
+}
+
+ul {
+	margin-left: 0;
+	margin-right: 0;
+	list-style: none;
+}
+
+.card-content .content {
+	font-size: 15px;
+}
+
+.card-header-title {
+	font-size: 17px;
+	font-weight: 700;
+}
 
-	ul {
-		margin-left: 0;
-		margin-right: 0;
-		list-style: none;
-	}
+.role {
+	font-size: 16px;
+	font-weight: 500;
+}
 
-	.custom-tag {
-		padding: 2px 7px;
-		border-radius: 10px;
-		display: inline-block;
-	}
+.custom-tag.blue {
+	border-bottom: 2px #0066f4 solid;
+}
 
-	.custom-tag.blue {
-		background-color: #0066f4;
-		color: white;
-	}
+.custom-tag.pink {
+	border-bottom: 2px #ff99dd solid;
+}
 
-	.custom-tag.pink {
-		background-color: #ff99dd;
-		color: white;
-	}
+.custom-tag.light-blue {
+	border-bottom: 2px #00baf4 solid;
+	background-color: transparent !important;
+}
 
-	.custom-tag.light-blue {
-		background-color: #00baf4;
-		color: white;
-	}
+.custom-tag.light-green {
+	border-bottom: 2px #019875 solid;
+}
 
-	.custom-tag.light-green {
-		background-color: #019875;
-		color: white;
-	}
+.custom-tag.purple {
+	border-bottom: 2px #90298c solid;
+}
 
-	.custom-tag.purple {
-		background-color: #90298C;
-    	color: white;
-	}
+.thanks {
+	font-size: 15px;
+}
 </style>

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 204 - 21
frontend/components/pages/Terms.vue


+ 33 - 0
frontend/dev.nginx.conf

@@ -0,0 +1,33 @@
+worker_processes  1;
+
+events {
+    worker_connections  1024;
+}
+
+http {
+    include       /etc/nginx/mime.types;
+    default_type  application/octet-stream;
+
+    sendfile        off;
+
+    keepalive_timeout  65;
+
+    server {
+        listen       80;
+        server_name  localhost;
+
+        location / {
+            proxy_set_header X-Real-IP  $remote_addr;
+			proxy_set_header X-Forwarded-For $remote_addr;
+			proxy_set_header Host $host;
+
+			proxy_pass http://localhost:81; 
+
+			proxy_redirect off;
+
+			proxy_http_version 1.1;
+			proxy_set_header Upgrade $http_upgrade;
+			proxy_set_header Connection "upgrade";
+        }
+    }
+}

+ 0 - 0
frontend/build/android-chrome-144x144.png → frontend/dist/android-chrome-144x144.png


+ 0 - 0
frontend/build/android-chrome-192x192.png → frontend/dist/android-chrome-192x192.png


+ 0 - 0
frontend/build/android-chrome-36x36.png → frontend/dist/android-chrome-36x36.png


+ 0 - 0
frontend/build/android-chrome-48x48.png → frontend/dist/android-chrome-48x48.png


+ 0 - 0
frontend/build/android-chrome-72x72.png → frontend/dist/android-chrome-72x72.png


+ 0 - 0
frontend/build/android-chrome-96x96.png → frontend/dist/android-chrome-96x96.png


+ 0 - 0
frontend/build/apple-touch-icon-114x114.png → frontend/dist/apple-touch-icon-114x114.png


+ 0 - 0
frontend/build/apple-touch-icon-120x120.png → frontend/dist/apple-touch-icon-120x120.png


+ 0 - 0
frontend/build/apple-touch-icon-144x144.png → frontend/dist/apple-touch-icon-144x144.png


+ 0 - 0
frontend/build/apple-touch-icon-152x152.png → frontend/dist/apple-touch-icon-152x152.png


Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio