Browse Source

Merge remote-tracking branch 'origin/staging' into staging

Owen Diffey 3 years ago
parent
commit
70c37011ae
38 changed files with 1809 additions and 2005 deletions
  1. 0 44
      .travis.yml
  2. 1 0
      .wiki/Configuration.md
  3. 2 1
      backend/config/template.json
  4. 1 1
      backend/index.js
  5. 79 241
      backend/logic/actions/stations.js
  6. 1 1
      frontend/dist/config/template.json
  7. 0 249
      frontend/dist/index.css
  8. 4 3
      frontend/dist/index.tpl.html
  9. 0 0
      frontend/dist/vendor/bulma.0.2.3.min.css
  10. 5 0
      frontend/package-lock.json
  11. 1 0
      frontend/package.json
  12. 426 19
      frontend/src/App.vue
  13. 1 1
      frontend/src/components/ActivityItem.vue
  14. 16 4
      frontend/src/components/FloatingBox.vue
  15. 142 33
      frontend/src/components/Modal.vue
  16. 1 1
      frontend/src/components/ProfilePicture.vue
  17. 7 1
      frontend/src/components/layout/MainFooter.vue
  18. 101 37
      frontend/src/components/layout/MainHeader.vue
  19. 16 10
      frontend/src/components/modals/CreateStation.vue
  20. 29 44
      frontend/src/components/modals/EditNews.vue
  21. 182 255
      frontend/src/components/modals/EditPlaylist/index.vue
  22. 5 68
      frontend/src/components/modals/EditSong/index.vue
  23. 92 117
      frontend/src/components/modals/Login.vue
  24. 127 168
      frontend/src/components/modals/ManageStation/index.vue
  25. 129 151
      frontend/src/components/modals/Register.vue
  26. 1 1
      frontend/src/components/modals/ViewReport.vue
  27. 6 0
      frontend/src/components/modals/WhatIsNew.vue
  28. 10 14
      frontend/src/main.js
  29. 52 44
      frontend/src/pages/About.vue
  30. 45 0
      frontend/src/pages/Admin/index.vue
  31. 52 36
      frontend/src/pages/Admin/tabs/Playlists.vue
  32. 55 40
      frontend/src/pages/Admin/tabs/Punishments.vue
  33. 12 210
      frontend/src/pages/Admin/tabs/Stations.vue
  34. 151 180
      frontend/src/pages/Admin/tabs/Statistics.vue
  35. 46 29
      frontend/src/pages/Home.vue
  36. 3 1
      frontend/src/pages/Station/Sidebar/Playlists.vue
  37. 7 0
      frontend/src/pages/Team.vue
  38. 1 1
      frontend/src/store/modules/modalVisibility.js

+ 0 - 44
.travis.yml

@@ -1,44 +0,0 @@
-# .travis.yml
-
-
-language: minimal
-sudo: required
-services:
-  - docker
-
-env:
-  global:
-    - COMPOSE_PROJECT_NAME=musare
-    - BACKEND_HOST=127.0.0.1
-    - BACKEND_PORT=8080
-    - FRONTEND_HOST=127.0.0.1
-    - FRONTEND_PORT=80
-    - FRONTEND_MODE=dev
-    - MONGO_HOST=127.0.0.1
-    - MONGO_PORT=27017
-    - MONGO_ROOT_PASSWORD=PASSWORD_HERE
-    - MONGO_USER_USERNAME=musare
-    - MONGO_USER_PASSWORD=OTHER_PASSWORD_HERE
-    - REDIS_HOST=127.0.0.1
-    - REDIS_PORT=6379
-    - REDIS_PASSWORD=PASSWORD
-
-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 && npm run lint" # using eslint to check for formatting/linting issues
-    - stage: backend # This will eventually be used for proper unit tests etc.
-      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 "npx eslint app/logic" # using eslint to check for formatting/linting issues

+ 1 - 0
.wiki/Configuration.md

@@ -40,6 +40,7 @@ Location: `backend/config/default.json`
 | `cookie.domain` | The ip or address you use to access the site, without protocols (http/https), so for example `localhost`. |
 | `cookie.domain` | The ip or address you use to access the site, without protocols (http/https), so for example `localhost`. |
 | `cookie.secure` | Should be `true` for SSL connections, and `false` for normal http connections. |
 | `cookie.secure` | Should be `true` for SSL connections, and `false` for normal http connections. |
 | `cookie.SIDname` | Name of the cookie stored for sessions. |
 | `cookie.SIDname` | Name of the cookie stored for sessions. |
+| `blacklistedCommunityStationNames ` | Array of blacklisted community station names. |
 | `skipConfigVersionCheck` | Skips checking if the config version is outdated or not. Should almost always be set to false. |
 | `skipConfigVersionCheck` | Skips checking if the config version is outdated or not. Should almost always be set to false. |
 | `skipDbDocumentsVersionCheck` | Skips checking if there are any DB documents outdated or not. Should almost always be set to false. |
 | `skipDbDocumentsVersionCheck` | Skips checking if there are any DB documents outdated or not. Should almost always be set to false. |
 | `debug.stationIssue` | If set to `true` it will enable the `/debug_station` API endpoint on the backend, which provides information useful to debugging stations not skipping, as well as capure all jobs specified in `debug.captureJobs`. 
 | `debug.stationIssue` | If set to `true` it will enable the `/debug_station` API endpoint on the backend, which provides information useful to debugging stations not skipping, as well as capure all jobs specified in `debug.captureJobs`. 

+ 2 - 1
backend/config/template.json

@@ -59,6 +59,7 @@
 		"secure": false,
 		"secure": false,
 		"SIDname": "SID"
 		"SIDname": "SID"
 	},
 	},
+	"blacklistedStationNames": ["musare"],
 	"skipConfigVersionCheck": false,
 	"skipConfigVersionCheck": false,
 	"skipDbDocumentsVersionCheck": false,
 	"skipDbDocumentsVersionCheck": false,
 	"debug": {
 	"debug": {
@@ -91,5 +92,5 @@
 			]
 			]
 		}
 		}
 	},
 	},
-	"configVersion": 6
+	"configVersion": 7
 }
 }

+ 1 - 1
backend/index.js

@@ -3,7 +3,7 @@ import "./loadEnvVariables.js";
 import util from "util";
 import util from "util";
 import config from "config";
 import config from "config";
 
 
-const REQUIRED_CONFIG_VERSION = 6;
+const REQUIRED_CONFIG_VERSION = 7;
 
 
 // eslint-disable-next-line
 // eslint-disable-next-line
 Array.prototype.remove = function (item) {
 Array.prototype.remove = function (item) {

+ 79 - 241
backend/logic/actions/stations.js

@@ -1,5 +1,6 @@
 import async from "async";
 import async from "async";
 import mongoose from "mongoose";
 import mongoose from "mongoose";
+import config from "config";
 
 
 import { isLoginRequired, isOwnerRequired, isAdminRequired } from "./hooks";
 import { isLoginRequired, isOwnerRequired, isAdminRequired } from "./hooks";
 
 
@@ -456,6 +457,11 @@ CacheModule.runJob("SUB", {
 			args: ["event:station.deleted"]
 			args: ["event:station.deleted"]
 		});
 		});
 
 
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `manage-station.${stationId}`,
+			args: ["event:station.deleted"]
+		});
+
 		WSModule.runJob("EMIT_TO_ROOM", {
 		WSModule.runJob("EMIT_TO_ROOM", {
 			room: `home`,
 			room: `home`,
 			args: ["event:station.deleted", { data: { stationId } }]
 			args: ["event:station.deleted", { data: { stationId } }]
@@ -2534,16 +2540,7 @@ export default {
 
 
 		data.name = data.name.toLowerCase();
 		data.name = data.name.toLowerCase();
 
 
-		const blacklist = [
-			"country",
-			"edm",
-			"musare",
-			"hip-hop",
-			"rap",
-			"top-hits",
-			"todays-hits",
-			"old-school",
-			"christmas",
+		let blacklist = [
 			"about",
 			"about",
 			"support",
 			"support",
 			"staff",
 			"staff",
@@ -2560,7 +2557,6 @@ export default {
 			"p",
 			"p",
 			"official",
 			"official",
 			"o",
 			"o",
-			"trap",
 			"faq",
 			"faq",
 			"team",
 			"team",
 			"donate",
 			"donate",
@@ -2576,9 +2572,16 @@ export default {
 			"api",
 			"api",
 			"songs",
 			"songs",
 			"playlists",
 			"playlists",
-			"playlist"
+			"playlist",
+			"albums",
+			"artists",
+			"artist",
+			"station"
 		];
 		];
 
 
+		if (data.type === "community" && config.get("blacklistedCommunityStationNames "))
+			blacklist = [...blacklist, ...config.get("blacklistedCommunityStationNames")];
+
 		async.waterfall(
 		async.waterfall(
 			[
 			[
 				next => {
 				next => {
@@ -2595,242 +2598,84 @@ export default {
 					);
 					);
 				},
 				},
 
 
-				// eslint-disable-next-line consistent-return
 				(station, next) => {
 				(station, next) => {
 					this.log(station);
 					this.log(station);
 
 
 					if (station) return next("A station with that name or display name already exists.");
 					if (station) return next("A station with that name or display name already exists.");
-					const { name, displayName, description, /* playlist, */ type, genres, blacklistedGenres } = data;
-					const stationId = mongoose.Types.ObjectId();
 
 
-					if (type === "official") {
+					if (blacklist.indexOf(data.name) !== -1)
+						return next("That name is blacklisted. Please use a different name.");
+
+					if (data.type === "official") {
 						return userModel.findOne({ _id: session.userId }, (err, user) => {
 						return userModel.findOne({ _id: session.userId }, (err, user) => {
 							if (err) return next(err);
 							if (err) return next(err);
 							if (!user) return next("User not found.");
 							if (!user) return next("User not found.");
 							if (user.role !== "admin") return next("Admin required.");
 							if (user.role !== "admin") return next("Admin required.");
-
-							return async.waterfall(
-								[
-									next => {
-										const playlists = [];
-										async.eachLimit(
-											genres,
-											1,
-											(genre, next) => {
-												PlaylistsModule.runJob(
-													"GET_GENRE_PLAYLIST",
-													{ genre, includeSongs: false },
-													this
-												)
-													.then(response => {
-														playlists.push(response.playlist);
-														next();
-													})
-													.catch(err => {
-														next(
-															`An error occurred when trying to get genre playlist for genre ${genre}. Error: ${err}.`
-														);
-													});
-											},
-											err => {
-												next(
-													err,
-													playlists.map(playlist => playlist._id.toString())
-												);
-											}
-										);
-									},
-
-									(genrePlaylistIds, next) => {
-										const playlists = [];
-										async.eachLimit(
-											blacklistedGenres,
-											1,
-											(genre, next) => {
-												PlaylistsModule.runJob(
-													"GET_GENRE_PLAYLIST",
-													{ genre, includeSongs: false },
-													this
-												)
-													.then(response => {
-														playlists.push(response.playlist);
-														next();
-													})
-													.catch(err => {
-														next(
-															`An error occurred when trying to get genre playlist for genre ${genre}. Error: ${err}.`
-														);
-													});
-											},
-											err => {
-												next(
-													err,
-													genrePlaylistIds,
-													playlists.map(playlist => playlist._id.toString())
-												);
-											}
-										);
-									},
-
-									(genrePlaylistIds, blacklistedGenrePlaylistIds, next) => {
-										const duplicateGenre =
-											genrePlaylistIds.length !== new Set(genrePlaylistIds).size;
-										const duplicateBlacklistedGenre =
-											genrePlaylistIds.length !== new Set(genrePlaylistIds).size;
-										const duplicateCross =
-											genrePlaylistIds.length + blacklistedGenrePlaylistIds.length !==
-											new Set([...genrePlaylistIds, ...blacklistedGenrePlaylistIds]).size;
-										if (duplicateGenre)
-											return next("You cannot have the same genre included twice.");
-										if (duplicateBlacklistedGenre)
-											return next("You cannot have the same blacklisted genre included twice.");
-										if (duplicateCross)
-											return next(
-												"You cannot have the same genre included and blacklisted at the same time."
-											);
-										return next(null, genrePlaylistIds, blacklistedGenrePlaylistIds);
-									}
-								],
-								(err, genrePlaylistIds, blacklistedGenrePlaylistIds) => {
-									if (err) return next(err);
-									return playlistModel.create(
-										{
-											isUserModifiable: false,
-											displayName: `Station - ${displayName}`,
-											songs: [],
-											createdBy: "Musare",
-											createdFor: `${stationId}`,
-											createdAt: Date.now(),
-											type: "station"
-										},
-
-										(err, playlist) => {
-											if (err) next(err);
-											else {
-												stationModel.create(
-													{
-														_id: stationId,
-														name,
-														displayName,
-														description,
-														type,
-														privacy: "private",
-														playlist: playlist._id,
-														currentSong: null,
-														partyMode: false,
-														playMode: "random"
-													},
-													(err, station) => {
-														next(
-															err,
-															station,
-															genrePlaylistIds,
-															blacklistedGenrePlaylistIds
-														);
-													}
-												);
-											}
-										}
-									);
-								}
-							);
+							return next();
 						});
 						});
 					}
 					}
-					if (type === "community") {
-						if (blacklist.indexOf(name) !== -1)
-							return next("That name is blacklisted. Please use a different name.");
-						return playlistModel.create(
-							{
-								isUserModifiable: false,
-								displayName: `Station - ${name}`,
-								songs: [],
-								createdBy: session.userId,
-								createdFor: `${stationId}`,
-								createdAt: Date.now(),
-								type: "station"
-							},
-
-							(err, playlist) => {
-								if (err) next(err);
-								else {
-									stationModel.create(
-										{
-											_id: stationId,
-											name,
-											displayName,
-											description,
-											playlist: playlist._id,
-											type,
-											privacy: "private",
-											owner: session.userId,
-											queue: [],
-											currentSong: null,
-											partyMode: true,
-											playMode: "random"
-										},
-										(err, station) => {
-											next(err, station, null, null);
-										}
-									);
-								}
-							}
-						);
-					}
+					return next();
 				},
 				},
 
 
-				(station, genrePlaylistIds, blacklistedGenrePlaylistIds, next) => {
-					if (station.type !== "official") return next(null, station);
-
-					const stationId = station._id;
+				next => {
+					const stationId = mongoose.Types.ObjectId();
+					playlistModel.create(
+						{
+							isUserModifiable: false,
+							displayName: `Station - ${data.name}`,
+							songs: [],
+							createdBy: data.type === "official" ? "Musare" : session.userId,
+							createdFor: `${stationId}`,
+							createdAt: Date.now(),
+							type: "station"
+						},
+						(err, playlist) => {
+							next(err, playlist, stationId);
+						}
+					);
+				},
 
 
-					return async.waterfall(
-						[
-							next => {
-								async.eachLimit(
-									genrePlaylistIds,
-									1,
-									(playlistId, next) => {
-										StationsModule.runJob("INCLUDE_PLAYLIST", { stationId, playlistId }, this)
-											.then(() => next())
-											.catch(next);
-									},
-									next
-								);
+				(playlist, stationId, next) => {
+					const { name, displayName, description, type } = data;
+					if (type === "official") {
+						stationModel.create(
+							{
+								_id: stationId,
+								name,
+								displayName,
+								description,
+								playlist: playlist._id,
+								type,
+								privacy: "private",
+								queue: [],
+								currentSong: null,
+								partyMode: false,
+								playMode: "random"
 							},
 							},
-
-							next => {
-								async.eachLimit(
-									blacklistedGenrePlaylistIds,
-									1,
-									(playlistId, next) => {
-										StationsModule.runJob("EXCLUDE_PLAYLIST", { stationId, playlistId }, this)
-											.then(() => next())
-											.catch(next);
-									},
-									next
-								);
+							next
+						);
+					} else {
+						stationModel.create(
+							{
+								_id: stationId,
+								name,
+								displayName,
+								description,
+								playlist: playlist._id,
+								type,
+								privacy: "private",
+								owner: session.userId,
+								queue: [],
+								currentSong: null,
+								partyMode: true,
+								playMode: "random"
 							},
 							},
-
-							next => {
-								PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
-								next();
-							}
-						],
-						async err => {
-							if (err) {
-								err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-								this.log(
-									"ERROR",
-									"STATIONS_CREATE",
-									`Created station ${stationId} successfully, but an error occurred during playing including/excluding. Error: ${err}`
-								);
-							}
-							next(null, station, err);
-						}
-					);
+							next
+						);
+					}
 				}
 				}
 			],
 			],
-			async (err, station, extraError) => {
+			async (err, station) => {
 				if (err) {
 				if (err) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 					this.log("ERROR", "STATIONS_CREATE", `Creating station failed. "${err}"`);
 					this.log("ERROR", "STATIONS_CREATE", `Creating station failed. "${err}"`);
@@ -2852,17 +2697,10 @@ export default {
 						}
 						}
 					});
 					});
 
 
-					if (!extraError) {
-						cb({
-							status: "success",
-							message: "Successfully created station."
-						});
-					} else {
-						cb({
-							status: "success",
-							message: `Successfully created station, but with one error at the end: ${extraError}`
-						});
-					}
+					cb({
+						status: "success",
+						message: "Successfully created station."
+					});
 				}
 				}
 			}
 			}
 		);
 		);

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

@@ -22,7 +22,7 @@
 		"logo_white": "/assets/white_wordmark.png",
 		"logo_white": "/assets/white_wordmark.png",
 		"logo_blue": "/assets/blue_wordmark.png",
 		"logo_blue": "/assets/blue_wordmark.png",
 		"sitename": "Musare",
 		"sitename": "Musare",
-		"github": "https://github.com/Musare/MusareNode"
+		"github": "https://github.com/Musare/Musare"
 	},
 	},
 	"messages": {
 	"messages": {
 		"accountRemoval": "Your account will be deactivated instantly and your data will shortly be deleted by an admin."
 		"accountRemoval": "Your account will be deactivated instantly and your data will shortly be deleted by an admin."

+ 0 - 249
frontend/dist/index.css

@@ -1,249 +0,0 @@
-/* nunito-200 - latin */
-@font-face {
-    font-family: "Nunito";
-    font-style: normal;
-    font-weight: 200;
-    src: url("/fonts/nunito-v16-latin-200.eot"); /* IE9 Compat Modes */
-    src: local(""),
-        url("/fonts/nunito-v16-latin-200.eot?#iefix") format("embedded-opentype"), /* IE6-IE8 */
-        url("/fonts/nunito-v16-latin-200.woff2") format("woff2"), /* Super Modern Browsers */
-        url("/fonts/nunito-v16-latin-200.woff") format("woff"), /* Modern Browsers */
-        url("/fonts/nunito-v16-latin-200.ttf") format("truetype"), /* Safari, Android, iOS */
-        url("/fonts/nunito-v16-latin-200.svg#Nunito") format("svg"); /* Legacy iOS */
-}
-
-/* nunito-regular - latin */
-@font-face {
-    font-family: "Nunito";
-    font-style: normal;
-    font-weight: 400;
-    src: url("/fonts/nunito-v16-latin-regular.eot"); /* IE9 Compat Modes */
-    src: local(""),
-        url("/fonts/nunito-v16-latin-regular.eot?#iefix") format("embedded-opentype"), /* IE6-IE8 */
-        url("/fonts/nunito-v16-latin-regular.woff2") format("woff2"), /* Super Modern Browsers */
-        url("/fonts/nunito-v16-latin-regular.woff") format("woff"), /* Modern Browsers */
-        url("/fonts/nunito-v16-latin-regular.ttf") format("truetype"), /* Safari, Android, iOS */
-        url("/fonts/nunito-v16-latin-regular.svg#Nunito") format("svg"); /* Legacy iOS */
-}
-
-/* nunito-600 - latin */
-@font-face {
-    font-family: "Nunito";
-    font-style: normal;
-    font-weight: 600;
-    src: url("/fonts/nunito-v16-latin-600.eot"); /* IE9 Compat Modes */
-    src: local(""),
-        url("/fonts/nunito-v16-latin-600.eot?#iefix") format("embedded-opentype"), /* IE6-IE8 */
-        url("/fonts/nunito-v16-latin-600.woff2") format("woff2"), /* Super Modern Browsers */
-        url("/fonts/nunito-v16-latin-600.woff") format("woff"), /* Modern Browsers */
-        url("/fonts/nunito-v16-latin-600.ttf") format("truetype"), /* Safari, Android, iOS */
-        url("/fonts/nunito-v16-latin-600.svg#Nunito") format("svg"); /* Legacy iOS */
-}
-
-/* nunito-700 - latin */
-@font-face {
-    font-family: "Nunito";
-    font-style: normal;
-    font-weight: 700;
-    src: url("/fonts/nunito-v16-latin-700.eot"); /* IE9 Compat Modes */
-    src: local(""),
-        url("/fonts/nunito-v16-latin-700.eot?#iefix") format("embedded-opentype"), /* IE6-IE8 */
-        url("/fonts/nunito-v16-latin-700.woff2") format("woff2"), /* Super Modern Browsers */
-        url("/fonts/nunito-v16-latin-700.woff") format("woff"), /* Modern Browsers */
-        url("/fonts/nunito-v16-latin-700.ttf") format("truetype"), /* Safari, Android, iOS */
-        url("/fonts/nunito-v16-latin-700.svg#Nunito") format("svg"); /* Legacy iOS */
-}
-
-/* nunito-800 - latin */
-@font-face {
-    font-family: "Nunito";
-    font-style: normal;
-    font-weight: 800;
-    src: url("/fonts/nunito-v16-latin-800.eot"); /* IE9 Compat Modes */
-    src: local(""),
-        url("/fonts/nunito-v16-latin-800.eot?#iefix") format("embedded-opentype"), /* IE6-IE8 */
-        url("/fonts/nunito-v16-latin-800.woff2") format("woff2"), /* Super Modern Browsers */
-        url("/fonts/nunito-v16-latin-800.woff") format("woff"), /* Modern Browsers */
-        url("/fonts/nunito-v16-latin-800.ttf") format("truetype"), /* Safari, Android, iOS */
-        url("/fonts/nunito-v16-latin-800.svg#Nunito") format("svg"); /* Legacy iOS */
-}
-
-/* pacifico-regular - latin */
-@font-face {
-    font-family: "Pacifico";
-    font-style: normal;
-    font-weight: 400;
-    src: url("/fonts/pacifico-v17-latin-regular.eot"); /* IE9 Compat Modes */
-    src: local(""),
-        url("/fonts/pacifico-v17-latin-regular.eot?#iefix")
-           format("embedded-opentype"),
-        /* IE6-IE8 */ url("/fonts/pacifico-v17-latin-regular.woff2")
-           format("woff2"),
-        /* Super Modern Browsers */
-           url("/fonts/pacifico-v17-latin-regular.woff") format("woff"),
-        /* Modern Browsers */ url("/fonts/pacifico-v17-latin-regular.ttf")
-           format("truetype"),
-        /* Safari, Android, iOS */
-           url("/fonts/pacifico-v17-latin-regular.svg#Pacifico") format("svg"); /* Legacy iOS */
-}
-
-@font-face {
-    font-family: "Material Icons";
-    font-style: normal;
-    font-weight: 400;
-    src: url(/fonts/MaterialIcons-Regular.ttf); /* For IE6-8 */
-    src: local("Material Icons"), local("MaterialIcons-Regular"),
-        url(/fonts/MaterialIcons-Regular.ttf) format("truetype");
-}
-
-.material-icons {
-    font-family: "Material Icons";
-    font-weight: normal;
-    font-style: normal;
-    font-size: 24px; /* Preferred icon size */
-    display: inline-block;
-    line-height: 1;
-    text-transform: none;
-    letter-spacing: normal;
-    word-wrap: normal;
-    white-space: nowrap;
-    direction: ltr;
-
-    /* Support for all WebKit browsers. */
-    -webkit-font-smoothing: antialiased;
-    /* Support for Safari and Chrome. */
-    text-rendering: optimizeLegibility;
-
-    /* Support for Firefox. */
-    -moz-osx-font-smoothing: grayscale;
-
-    /* Support for IE. */
-    font-feature-settings: "liga";
-}
-
-html {
-    background-color: inherit;
-}
-
-body {
-    /* background-color: rgb(245, 245, 245); */
-    background-color: rgb(249 249 249);
-}
-
-.app {
-    min-height: 100vh;
-    position: relative;
-}
-
-#root {
-    height: 100%;
-}
-
-.content-wrapper {
-    /* padding: 60px 0 calc(230px + 60px) 0; */
-    padding-top: 60px;
-}
-
-.card {
-    background-color: white;
-    /*padding: 20px;*/
-}
-
-.toast {
-    z-index: 10000 !important;
-}
-
-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;
-}
-
-.white {
-    background-color: #ffffff !important;
-}
-
-.btn-search {
-    font-size: 14px;
-}
-
-a.nav-item.is-tab {
-    border-bottom: 1px solid transparent;
-    border-top: 1px solid transparent;
-}
-
-.button.is-info {
-    border-width: 0;
-    color: #fff;
-}
-
-strong {
-    color: inherit;
-}

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

@@ -1,5 +1,6 @@
 <!DOCTYPE html>
 <!DOCTYPE html>
 <html lang='en'>
 <html lang='en'>
+
 <head>
 <head>
 	<title><%= htmlWebpackPlugin.options.title %></title>
 	<title><%= htmlWebpackPlugin.options.title %></title>
 
 
@@ -32,16 +33,16 @@
 	<meta name='theme-color' content='#03a9f4'>
 	<meta name='theme-color' content='#03a9f4'>
 	<meta name='google' content='nositelinkssearchbox' />
 	<meta name='google' content='nositelinkssearchbox' />
 
 
-	<link rel="stylesheet" href="/vendor/bulma.0.2.3.min.css">
-	<link rel='stylesheet' href='/index.css'>
 	<script src='https://www.youtube.com/iframe_api'></script>
 	<script src='https://www.youtube.com/iframe_api'></script>
 	<script type='text/javascript' src='/vendor/can-autoplay.min.js'></script>
 	<script type='text/javascript' src='/vendor/can-autoplay.min.js'></script>
 	<script type='text/javascript' src='/vendor/lofig.1.3.4.min.js'></script>
 	<script type='text/javascript' src='/vendor/lofig.1.3.4.min.js'></script>
 </head>
 </head>
+
 <body>
 <body>
 	<div id="root"></div>
 	<div id="root"></div>
 	<div id="toasts-container" class="position-right position-bottom">
 	<div id="toasts-container" class="position-right position-bottom">
 		<div id="toasts-content"></div>
 		<div id="toasts-content"></div>
 	</div>
 	</div>
 </body>
 </body>
-</html>
+
+</html>

File diff suppressed because it is too large
+ 0 - 0
frontend/dist/vendor/bulma.0.2.3.min.css


+ 5 - 0
frontend/package-lock.json

@@ -5315,6 +5315,11 @@
       "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
       "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
       "dev": true
       "dev": true
     },
     },
+    "normalize.css": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/normalize.css/-/normalize.css-8.0.1.tgz",
+      "integrity": "sha512-qizSNPO93t1YUuUhP22btGOo3chcvDFqFaj2TRybP0DMxkHOCTYwp3n34fel4a31ORXy4m1Xq0Gyqpb5m33qIg=="
+    },
     "npm-run-path": {
     "npm-run-path": {
       "version": "4.0.1",
       "version": "4.0.1",
       "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
       "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",

+ 1 - 0
frontend/package.json

@@ -48,6 +48,7 @@
     "eslint-config-airbnb-base": "^14.2.1",
     "eslint-config-airbnb-base": "^14.2.1",
     "html-webpack-plugin": "^5.3.2",
     "html-webpack-plugin": "^5.3.2",
     "marked": "^3.0.7",
     "marked": "^3.0.7",
+    "normalize.css": "^8.0.1",
     "toasters": "^2.3.1",
     "toasters": "^2.3.1",
     "vue": "^3.2.20",
     "vue": "^3.2.20",
     "vue-content-loader": "^2.0.0",
     "vue-content-loader": "^2.0.0",

+ 426 - 19
frontend/src/App.vue

@@ -252,6 +252,7 @@ export default {
 </script>
 </script>
 
 
 <style lang="scss">
 <style lang="scss">
+@import "normalize.css/normalize.css";
 @import "tippy.js/dist/tippy.css";
 @import "tippy.js/dist/tippy.css";
 @import "tippy.js/animations/scale.css";
 @import "tippy.js/animations/scale.css";
 
 
@@ -344,6 +345,150 @@ export default {
 	}
 	}
 }
 }
 
 
+/* nunito-200 - latin */
+@font-face {
+	font-family: "Nunito";
+	font-style: normal;
+	font-weight: 200;
+	src: url("/fonts/nunito-v16-latin-200.eot"); /* IE9 Compat Modes */
+	src: local(""),
+		url("/fonts/nunito-v16-latin-200.eot?#iefix")
+			format("embedded-opentype"),
+		/* IE6-IE8 */ url("/fonts/nunito-v16-latin-200.woff2") format("woff2"),
+		/* Super Modern Browsers */ url("/fonts/nunito-v16-latin-200.woff")
+			format("woff"),
+		/* Modern Browsers */ url("/fonts/nunito-v16-latin-200.ttf")
+			format("truetype"),
+		/* Safari, Android, iOS */ url("/fonts/nunito-v16-latin-200.svg#Nunito")
+			format("svg"); /* Legacy iOS */
+}
+
+/* nunito-regular - latin */
+@font-face {
+	font-family: "Nunito";
+	font-style: normal;
+	font-weight: 400;
+	src: url("/fonts/nunito-v16-latin-regular.eot"); /* IE9 Compat Modes */
+	src: local(""),
+		url("/fonts/nunito-v16-latin-regular.eot?#iefix")
+			format("embedded-opentype"),
+		/* IE6-IE8 */ url("/fonts/nunito-v16-latin-regular.woff2")
+			format("woff2"),
+		/* Super Modern Browsers */ url("/fonts/nunito-v16-latin-regular.woff")
+			format("woff"),
+		/* Modern Browsers */ url("/fonts/nunito-v16-latin-regular.ttf")
+			format("truetype"),
+		/* Safari, Android, iOS */
+			url("/fonts/nunito-v16-latin-regular.svg#Nunito") format("svg"); /* Legacy iOS */
+}
+
+/* nunito-600 - latin */
+@font-face {
+	font-family: "Nunito";
+	font-style: normal;
+	font-weight: 600;
+	src: url("/fonts/nunito-v16-latin-600.eot"); /* IE9 Compat Modes */
+	src: local(""),
+		url("/fonts/nunito-v16-latin-600.eot?#iefix")
+			format("embedded-opentype"),
+		/* IE6-IE8 */ url("/fonts/nunito-v16-latin-600.woff2") format("woff2"),
+		/* Super Modern Browsers */ url("/fonts/nunito-v16-latin-600.woff")
+			format("woff"),
+		/* Modern Browsers */ url("/fonts/nunito-v16-latin-600.ttf")
+			format("truetype"),
+		/* Safari, Android, iOS */ url("/fonts/nunito-v16-latin-600.svg#Nunito")
+			format("svg"); /* Legacy iOS */
+}
+
+/* nunito-700 - latin */
+@font-face {
+	font-family: "Nunito";
+	font-style: normal;
+	font-weight: 700;
+	src: url("/fonts/nunito-v16-latin-700.eot"); /* IE9 Compat Modes */
+	src: local(""),
+		url("/fonts/nunito-v16-latin-700.eot?#iefix")
+			format("embedded-opentype"),
+		/* IE6-IE8 */ url("/fonts/nunito-v16-latin-700.woff2") format("woff2"),
+		/* Super Modern Browsers */ url("/fonts/nunito-v16-latin-700.woff")
+			format("woff"),
+		/* Modern Browsers */ url("/fonts/nunito-v16-latin-700.ttf")
+			format("truetype"),
+		/* Safari, Android, iOS */ url("/fonts/nunito-v16-latin-700.svg#Nunito")
+			format("svg"); /* Legacy iOS */
+}
+
+/* nunito-800 - latin */
+@font-face {
+	font-family: "Nunito";
+	font-style: normal;
+	font-weight: 800;
+	src: url("/fonts/nunito-v16-latin-800.eot"); /* IE9 Compat Modes */
+	src: local(""),
+		url("/fonts/nunito-v16-latin-800.eot?#iefix")
+			format("embedded-opentype"),
+		/* IE6-IE8 */ url("/fonts/nunito-v16-latin-800.woff2") format("woff2"),
+		/* Super Modern Browsers */ url("/fonts/nunito-v16-latin-800.woff")
+			format("woff"),
+		/* Modern Browsers */ url("/fonts/nunito-v16-latin-800.ttf")
+			format("truetype"),
+		/* Safari, Android, iOS */ url("/fonts/nunito-v16-latin-800.svg#Nunito")
+			format("svg"); /* Legacy iOS */
+}
+
+/* pacifico-regular - latin */
+@font-face {
+	font-family: "Pacifico";
+	font-style: normal;
+	font-weight: 400;
+	src: url("/fonts/pacifico-v17-latin-regular.eot"); /* IE9 Compat Modes */
+	src: local(""),
+		url("/fonts/pacifico-v17-latin-regular.eot?#iefix")
+			format("embedded-opentype"),
+		/* IE6-IE8 */ url("/fonts/pacifico-v17-latin-regular.woff2")
+			format("woff2"),
+		/* Super Modern Browsers */
+			url("/fonts/pacifico-v17-latin-regular.woff") format("woff"),
+		/* Modern Browsers */ url("/fonts/pacifico-v17-latin-regular.ttf")
+			format("truetype"),
+		/* Safari, Android, iOS */
+			url("/fonts/pacifico-v17-latin-regular.svg#Pacifico") format("svg"); /* Legacy iOS */
+}
+
+@font-face {
+	font-family: "Material Icons";
+	font-style: normal;
+	font-weight: 400;
+	src: url(/fonts/MaterialIcons-Regular.ttf); /* For IE6-8 */
+	src: local("Material Icons"), local("MaterialIcons-Regular"),
+		url(/fonts/MaterialIcons-Regular.ttf) format("truetype");
+}
+
+.material-icons {
+	font-family: "Material Icons";
+	font-weight: normal;
+	font-style: normal;
+	font-size: 24px; /* Preferred icon size */
+	display: inline-block;
+	line-height: 1;
+	text-transform: none;
+	letter-spacing: normal;
+	word-wrap: normal;
+	white-space: nowrap;
+	direction: ltr;
+
+	/* Support for all WebKit browsers. */
+	-webkit-font-smoothing: antialiased;
+	/* Support for Safari and Chrome. */
+	text-rendering: optimizeLegibility;
+
+	/* Support for Firefox. */
+	-moz-osx-font-smoothing: grayscale;
+
+	/* Support for IE. */
+	font-feature-settings: "liga";
+}
+
 code {
 code {
 	background-color: var(--light-grey) !important;
 	background-color: var(--light-grey) !important;
 	color: var(--red) !important;
 	color: var(--red) !important;
@@ -358,21 +503,57 @@ body.night-mode {
 
 
 	.toast {
 	.toast {
 		font-weight: 600;
 		font-weight: 600;
+		z-index: 10000 !important;
 	}
 	}
 }
 }
 
 
 html {
 html {
 	overflow: auto !important;
 	overflow: auto !important;
 	height: 100%;
 	height: 100%;
+	background-color: inherit;
+	font-size: 14px;
 }
 }
 
 
 body {
 body {
 	background-color: var(--light-grey);
 	background-color: var(--light-grey);
 	color: var(--dark-grey);
 	color: var(--dark-grey);
 	height: 100%;
 	height: 100%;
+	line-height: 1.428;
+	font-size: 1rem;
 	font-family: Nunito, Arial, sans-serif;
 	font-family: Nunito, Arial, sans-serif;
 }
 }
 
 
+.app {
+	min-height: 100vh;
+	position: relative;
+}
+
+#root {
+	height: 100%;
+}
+
+.content-wrapper {
+	/* padding: 60px 0 calc(230px + 60px) 0; */
+	padding-top: 60px;
+}
+
+.column {
+	display: flex;
+	flex: 1 1 0;
+	padding: 10px;
+}
+
+ul {
+	list-style: none;
+	margin: 0;
+	display: block;
+}
+
+ol,
+ul {
+	margin-left: 2em;
+}
+
 h1,
 h1,
 h2,
 h2,
 h3,
 h3,
@@ -381,11 +562,88 @@ h5,
 h6,
 h6,
 .sidebar-title {
 .sidebar-title {
 	font-family: Nunito, Arial, sans-serif;
 	font-family: Nunito, Arial, sans-serif;
+	font-weight: 400;
+	line-height: 1.1;
+
+	a {
+		font-weight: inherit;
+	}
 }
 }
 
 
-.modal-card-title {
-	font-weight: 600;
-	font-family: Nunito, Arial, sans-serif;
+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;
+}
+
+.white {
+	background-color: var(--white) !important;
+}
+
+.btn-search {
+	font-size: 14px;
+}
+
+a.nav-item.is-tab {
+	border-bottom: 1px solid transparent;
+	border-top: 1px solid transparent;
+}
+
+.button.is-info {
+	border-width: 0;
+	color: var(--white);
+}
+
+strong {
+	color: inherit;
+}
+
+hr {
+	background-color: var(--light-grey-2);
+	border: none;
+	height: 1px;
 }
 }
 
 
 p,
 p,
@@ -396,6 +654,21 @@ textarea {
 	font-family: Nunito, Arial, sans-serif;
 	font-family: Nunito, Arial, sans-serif;
 }
 }
 
 
+input,
+select,
+textarea {
+	outline: none;
+}
+
+.label {
+	display: flex;
+	font-weight: 700;
+
+	&:not(:last-child) {
+		margin-bottom: 5px;
+	}
+}
+
 #page-title {
 #page-title {
 	margin-top: 0;
 	margin-top: 0;
 	font-size: 35px;
 	font-size: 35px;
@@ -410,6 +683,20 @@ textarea {
 	}
 	}
 }
 }
 
 
+@media screen and (min-width: 980px) {
+	.container {
+		max-width: 960px;
+		margin-left: auto;
+		margin-right: auto;
+	}
+}
+
+@media screen and (min-width: 1180px) {
+	.container {
+		max-width: 1200px;
+	}
+}
+
 .upper-container {
 .upper-container {
 	height: 100%;
 	height: 100%;
 }
 }
@@ -420,23 +707,51 @@ textarea {
 	display: flex;
 	display: flex;
 	flex-direction: column;
 	flex-direction: column;
 
 
+	&.main-container-modal-active {
+		height: 100% !important;
+		overflow: hidden !important;
+	}
+
 	> .container {
 	> .container {
+		position: relative;
 		flex: 1 0 auto;
 		flex: 1 0 auto;
+		margin: 0 auto;
+		max-width: 1200px;
 	}
 	}
 }
 }
 
 
-.main-container.main-container-modal-active {
-	height: 100% !important;
-	overflow: hidden !important;
-}
-
 a {
 a {
 	color: var(--primary-color);
 	color: var(--primary-color);
 	text-decoration: none;
 	text-decoration: none;
 }
 }
 
 
-.modal-card {
-	margin: 0 !important;
+table {
+	border-collapse: collapse;
+	width: 100%;
+
+	thead {
+		td {
+			border-width: 0 0 2px;
+		}
+	}
+
+	td {
+		border: 1px solid var(--light-grey-2);
+		border-width: 0 0 1px;
+		padding: 8px 10px;
+	}
+
+	tbody {
+		tr:last-child {
+			td {
+				border-bottom-width: 0;
+			}
+		}
+	}
+}
+
+img {
+	max-width: 100%;
 }
 }
 
 
 .absolute-a {
 .absolute-a {
@@ -752,7 +1067,7 @@ a {
 					left: 0;
 					left: 0;
 					right: 0;
 					right: 0;
 					bottom: 0;
 					bottom: 0;
-					background-color: #ccc;
+					background-color: var(--light-grey-3);
 					transition: 0.2s;
 					transition: 0.2s;
 					border-radius: 34px;
 					border-radius: 34px;
 				}
 				}
@@ -764,7 +1079,7 @@ a {
 					width: 16px;
 					width: 16px;
 					left: 4px;
 					left: 4px;
 					bottom: 4px;
 					bottom: 4px;
-					background-color: white;
+					background-color: var(--white);
 					transition: 0.2s;
 					transition: 0.2s;
 					border-radius: 50%;
 					border-radius: 50%;
 				}
 				}
@@ -808,15 +1123,41 @@ a {
 	}
 	}
 }
 }
 
 
+.has-text-centered {
+	text-align: center;
+}
+
 .select {
 .select {
+	position: relative;
+
 	&:after {
 	&:after {
-		border-color: var(--primary-color);
-		border-width: 1.5px;
-		margin-top: -3px;
+		content: " ";
+		border: 1.5px solid var(--primary-color);
+		border-right: 0;
+		border-top: 0;
+		height: 7px;
+		pointer-events: none;
+		position: absolute;
+		transform: rotate(-45deg);
+		width: 7px;
+		margin-top: -6px;
+		right: 16px;
+		top: 50%;
 	}
 	}
 
 
 	select {
 	select {
 		height: 36px;
 		height: 36px;
+		background-color: var(--white);
+		border: 1px solid var(--light-grey-2);
+		color: var(--dark-grey-2);
+		appearance: none;
+		border-radius: 3px;
+		font-size: 14px;
+		line-height: 24px;
+		padding-left: 8px;
+		position: relative;
+		padding-right: 36px;
+		cursor: pointer;
 	}
 	}
 }
 }
 
 
@@ -843,6 +1184,20 @@ button.delete:focus {
 }
 }
 
 
 .button {
 .button {
+	border: 1px solid var(--light-grey-2);
+	background-color: var(--white);
+	color: var(--dark-grey-2);
+	border-radius: 3px;
+	line-height: 24px;
+	align-items: center;
+	display: inline-flex;
+	font-size: 14px;
+	padding-left: 10px;
+	padding-right: 10px;
+	justify-content: center;
+	cursor: pointer;
+	user-select: none;
+
 	&:hover,
 	&:hover,
 	&:focus {
 	&:focus {
 		filter: brightness(95%);
 		filter: brightness(95%);
@@ -850,30 +1205,70 @@ button.delete:focus {
 
 
 	&.is-success {
 	&.is-success {
 		background-color: var(--green) !important;
 		background-color: var(--green) !important;
+		border-width: 0;
+		color: var(--white);
 	}
 	}
 
 
 	&.is-primary {
 	&.is-primary {
 		background-color: var(--primary-color) !important;
 		background-color: var(--primary-color) !important;
+		border-width: 0;
+		color: var(--white);
 	}
 	}
 
 
 	&.is-danger {
 	&.is-danger {
 		background-color: var(--red) !important;
 		background-color: var(--red) !important;
+		border-width: 0;
+		color: var(--white);
 	}
 	}
 
 
 	&.is-info {
 	&.is-info {
 		background-color: var(--primary-color) !important;
 		background-color: var(--primary-color) !important;
+		border-width: 0;
+		color: var(--white);
 	}
 	}
 
 
 	&.is-warning {
 	&.is-warning {
 		background-color: var(--yellow) !important;
 		background-color: var(--yellow) !important;
+		border-width: 0;
+		color: rgba(0, 0, 0, 0.7);
 	}
 	}
 }
 }
 
 
+.input,
+.textarea {
+	width: 100%;
+	padding-left: 8px;
+	padding-right: 8px;
+	line-height: 24px;
+	font-size: 14px;
+	border-radius: 3px;
+	box-shadow: inset 0 1px 2px rgba(10, 10, 10, 0.1);
+	border: 1px solid var(--light-grey-2);
+}
+
 .input,
 .input,
 .button {
 .button {
 	height: 36px;
 	height: 36px;
 }
 }
 
 
+.textarea {
+	display: block;
+	line-height: 1.2;
+	padding: 10px;
+	max-height: 600px;
+	min-height: 120px;
+	min-width: 100%;
+	resize: vertical;
+}
+
+.icon {
+	height: 24px;
+	width: 24px;
+	line-height: 24px;
+	margin-left: 4px;
+	margin-right: -2px;
+}
+
 .fadein-helpbox-enter-active {
 .fadein-helpbox-enter-active {
 	transition-duration: 0.3s;
 	transition-duration: 0.3s;
 	transition-timing-function: ease-in;
 	transition-timing-function: ease-in;
@@ -898,6 +1293,18 @@ button.delete:focus {
 
 
 .control {
 .control {
 	margin-bottom: 5px !important;
 	margin-bottom: 5px !important;
+
+	&.is-grouped {
+		display: flex;
+	}
+
+	&.is-expanded {
+		flex: 1;
+	}
+
+	&.has-addons {
+		display: flex;
+	}
 }
 }
 
 
 .input-with-button {
 .input-with-button {
@@ -1295,8 +1702,8 @@ h4.section-title {
 
 
 	blockquote {
 	blockquote {
 		padding: 0px 15px;
 		padding: 0px 15px;
-		color: #6a737d;
-		border-left: 0.25em solid #dfe2e5;
+		color: var(--grey-2);
+		border-left: 0.25em solid var(--light-grey-2);
 	}
 	}
 
 
 	code {
 	code {
@@ -1333,7 +1740,7 @@ h4.section-title {
 		left: 0;
 		left: 0;
 		right: 0;
 		right: 0;
 		bottom: 0;
 		bottom: 0;
-		background-color: #ccc;
+		background-color: var(--light-grey-3);
 		transition: 0.2s;
 		transition: 0.2s;
 		border-radius: 34px;
 		border-radius: 34px;
 	}
 	}
@@ -1345,7 +1752,7 @@ h4.section-title {
 		width: 16px;
 		width: 16px;
 		left: 4px;
 		left: 4px;
 		bottom: 4px;
 		bottom: 4px;
-		background-color: white;
+		background-color: var(--white);
 		transition: 0.2s;
 		transition: 0.2s;
 		border-radius: 50%;
 		border-radius: 50%;
 	}
 	}

+ 1 - 1
frontend/src/components/ActivityItem.vue

@@ -196,7 +196,7 @@ export default {
 	color: var(--primary-color) !important;
 	color: var(--primary-color) !important;
 
 
 	&:hover {
 	&:hover {
-		border-color: #dbdbdb !important;
+		border-color: var(--light-grey-2) !important;
 	}
 	}
 }
 }
 </style>
 </style>

+ 16 - 4
frontend/src/components/FloatingBox.vue

@@ -16,7 +16,9 @@
 		@mousedown.left="onResizeBox"
 		@mousedown.left="onResizeBox"
 	>
 	>
 		<div class="box-header item-draggable" @mousedown.left="onDragBox">
 		<div class="box-header item-draggable" @mousedown.left="onDragBox">
-			<button class="delete" @click.prevent="toggleBox()" />
+			<span class="delete material-icons" @click="toggleBox()"
+				>highlight_off</span
+			>
 		</div>
 		</div>
 		<div class="box-body">
 		<div class="box-body">
 			<slot name="body"></slot>
 			<slot name="body"></slot>
@@ -143,6 +145,7 @@ export default {
 }
 }
 
 
 .floating-box {
 .floating-box {
+	display: flex;
 	background-color: var(--white);
 	background-color: var(--white);
 	color: var(--black);
 	color: var(--black);
 	position: fixed;
 	position: fixed;
@@ -155,6 +158,10 @@ export default {
 	min-width: 50px !important;
 	min-width: 50px !important;
 	padding: 0;
 	padding: 0;
 
 
+	&.column {
+		flex-direction: column;
+	}
+
 	.box-header {
 	.box-header {
 		z-index: 100000001;
 		z-index: 100000001;
 		background-color: var(--primary-color);
 		background-color: var(--primary-color);
@@ -162,12 +169,17 @@ export default {
 		height: 24px;
 		height: 24px;
 		width: 100%;
 		width: 100%;
 
 
-		.delete {
+		.delete.material-icons {
 			position: absolute;
 			position: absolute;
-			height: 20px;
-			width: 20px;
 			top: 2px;
 			top: 2px;
 			right: 2px;
 			right: 2px;
+			font-size: 20px;
+			color: var(--white);
+			cursor: pointer;
+			&:hover,
+			&:focus {
+				filter: brightness(90%);
+			}
 		}
 		}
 	}
 	}
 
 

+ 142 - 33
frontend/src/components/Modal.vue

@@ -1,12 +1,20 @@
 <template>
 <template>
 	<div class="modal is-active">
 	<div class="modal is-active">
 		<div class="modal-background" @click="closeCurrentModal()" />
 		<div class="modal-background" @click="closeCurrentModal()" />
-		<div class="modal-card">
+		<div
+			:class="{
+				'modal-card': true,
+				'modal-wide': wide,
+				'modal-split': split
+			}"
+		>
 			<header class="modal-card-head">
 			<header class="modal-card-head">
 				<h2 class="modal-card-title is-marginless">
 				<h2 class="modal-card-title is-marginless">
 					{{ title }}
 					{{ title }}
 				</h2>
 				</h2>
-				<button class="delete" @click="closeCurrentModal()" />
+				<span class="delete material-icons" @click="closeCurrentModal()"
+					>highlight_off</span
+				>
 			</header>
 			</header>
 			<section class="modal-card-body">
 			<section class="modal-card-body">
 				<slot name="body" />
 				<slot name="body" />
@@ -23,7 +31,9 @@ import { mapActions } from "vuex";
 
 
 export default {
 export default {
 	props: {
 	props: {
-		title: { type: String, default: "Modal" }
+		title: { type: String, default: "Modal" },
+		wide: { type: Boolean, default: false },
+		split: { type: Boolean, default: false }
 	},
 	},
 	mounted() {
 	mounted() {
 		this.type = this.toCamelCase(this.title);
 		this.type = this.toCamelCase(this.title);
@@ -41,8 +51,8 @@ export default {
 };
 };
 </script>
 </script>
 
 
-<style lang="scss" scoped>
-.night-mode {
+<style lang="scss">
+.night-mode .modal .modal-card {
 	.modal-card-head,
 	.modal-card-head,
 	.modal-card-foot {
 	.modal-card-foot {
 		background-color: var(--dark-grey-3);
 		background-color: var(--dark-grey-3);
@@ -53,6 +63,7 @@ export default {
 		background-color: var(--dark-grey-4) !important;
 		background-color: var(--dark-grey-4) !important;
 	}
 	}
 
 
+	.modal-card-head .delete.material-icons,
 	.modal-card-title {
 	.modal-card-title {
 		color: var(--white);
 		color: var(--white);
 	}
 	}
@@ -74,44 +85,142 @@ export default {
 	}
 	}
 }
 }
 
 
-.modal-card {
-	width: 800px;
-	font-size: 16px;
-}
+.modal {
+	display: flex;
+	position: fixed;
+	top: 0;
+	bottom: 0;
+	left: 0;
+	right: 0;
+	z-index: 1984;
+	justify-content: center;
+	align-items: center;
 
 
-p {
-	font-size: 17px;
-}
+	.modal-background {
+		position: absolute;
+		top: 0;
+		bottom: 0;
+		left: 0;
+		right: 0;
+		background-color: rgba(10, 10, 10, 0.85);
+	}
 
 
-.modal-card-title {
-	font-size: 27px;
-}
+	.modal-card {
+		display: flex;
+		flex-direction: column;
+		position: relative;
+		width: 800px;
+		max-width: calc(100% - 40px);
+		max-height: calc(100vh - 40px);
+		overflow: auto;
+		margin: 0;
+		font-size: 16px;
+
+		&.modal-wide {
+			width: 1300px;
+		}
+
+		&.modal-split {
+			height: 100%;
 
 
-.modal-card-foot {
-	overflow: initial;
+			.modal-card-body {
+				display: flex;
+				flex-wrap: wrap;
+				height: 100%;
+				row-gap: 24px;
 
 
-	&::v-deep {
-		& > div {
+				.left-section,
+				.right-section {
+					flex-basis: 50%;
+					max-height: 100%;
+					overflow-y: auto;
+					flex-grow: 1;
+
+					.section {
+						display: flex;
+						flex-direction: column;
+						flex-grow: 1;
+						width: auto;
+						padding: 15px !important;
+						margin: 0 10px;
+					}
+
+					@media screen and (max-width: 1100px) {
+						flex-basis: 100%;
+						max-height: unset;
+					}
+				}
+			}
+		}
+
+		.modal-card-head,
+		.modal-card-foot {
 			display: flex;
 			display: flex;
-			flex-grow: 1;
-			column-gap: 16px;
+			flex-shrink: 0;
+			position: relative;
+			justify-content: flex-start;
+			align-items: center;
+			padding: 20px;
+			background-color: var(--light-grey);
 		}
 		}
 
 
-		.right {
-			margin-left: auto;
-			justify-content: flex-end;
-			column-gap: 16px;
+		.modal-card-head {
+			border-radius: 5px 5px 0 0;
+
+			.modal-card-title {
+				display: flex;
+				flex: 1;
+				margin: 0;
+				font-size: 26px;
+				font-weight: 600;
+			}
+
+			.delete.material-icons {
+				font-size: 28px;
+				cursor: pointer;
+				&:hover,
+				&:focus {
+					filter: brightness(90%);
+				}
+			}
 		}
 		}
-	}
-}
 
 
-@media screen and (max-width: 650px) {
-	.modal-card {
-		max-height: 100vh;
-		height: 100%;
-		.modal-card-head,
 		.modal-card-foot {
 		.modal-card-foot {
-			border-radius: 0;
+			border-radius: 0 0 5px 5px;
+			overflow: initial;
+			column-gap: 16px;
+
+			& > div {
+				display: flex;
+				flex-grow: 1;
+				column-gap: 16px;
+			}
+
+			.right {
+				display: flex;
+				margin-left: auto;
+				margin-right: 0;
+				justify-content: flex-end;
+				column-gap: 16px;
+			}
+		}
+
+		.modal-card-body {
+			flex: 1;
+			flex-wrap: wrap;
+			padding: 20px;
+			overflow: auto;
+			background-color: var(--white);
+		}
+
+		@media screen and (max-width: 650px) {
+			max-height: 100vh;
+			height: 100%;
+			max-width: 100%;
+			.modal-card-head,
+			.modal-card-foot {
+				border-radius: 0;
+			}
 		}
 		}
 	}
 	}
 }
 }

+ 1 - 1
frontend/src/components/ProfilePicture.vue

@@ -59,7 +59,7 @@ export default {
 		display: flex;
 		display: flex;
 		align-items: center;
 		align-items: center;
 		justify-content: center;
 		justify-content: center;
-		background-color: #ddd;
+		background-color: var(--light-grey-2);
 		font-family: Nunito, Arial, sans-serif;
 		font-family: Nunito, Arial, sans-serif;
 		font-weight: 400;
 		font-weight: 400;
 		user-select: none;
 		user-select: none;

+ 7 - 1
frontend/src/components/layout/MainFooter.vue

@@ -1,7 +1,7 @@
 <template>
 <template>
 	<footer class="footer">
 	<footer class="footer">
 		<div class="container">
 		<div class="container">
-			<div class="footer-content has-text-centered">
+			<div class="footer-content">
 				<div id="footer-copyright">
 				<div id="footer-copyright">
 					<p>© Copyright {{ siteSettings.sitename }} 2015 - 2021</p>
 					<p>© Copyright {{ siteSettings.sitename }} 2015 - 2021</p>
 				</div>
 				</div>
@@ -130,10 +130,15 @@ export default {
 	height: 200px;
 	height: 200px;
 	font-size: 16px;
 	font-size: 16px;
 
 
+	.container {
+		position: relative;
+	}
+
 	.footer-content {
 	.footer-content {
 		display: flex;
 		display: flex;
 		align-items: center;
 		align-items: center;
 		flex-direction: column;
 		flex-direction: column;
+		text-align: center;
 
 
 		& > * {
 		& > * {
 			margin: 5px 0;
 			margin: 5px 0;
@@ -163,6 +168,7 @@ export default {
 
 
 		img {
 		img {
 			max-height: 38px;
 			max-height: 38px;
+			max-width: 100%;
 			color: var(--primary-color);
 			color: var(--primary-color);
 			user-select: none;
 			user-select: none;
 		}
 		}

+ 101 - 37
frontend/src/components/layout/MainHeader.vue

@@ -114,9 +114,12 @@ export default {
 
 
 .nav {
 .nav {
 	flex-shrink: 0;
 	flex-shrink: 0;
+	display: flex;
+	position: relative;
 	background-color: var(--primary-color);
 	background-color: var(--primary-color);
 	height: 64px;
 	height: 64px;
 	border-radius: 0% 0% 33% 33% / 0% 0% 7% 7%;
 	border-radius: 0% 0% 33% 33% / 0% 0% 7% 7%;
+	z-index: 2;
 
 
 	&.transparent {
 	&.transparent {
 		background-color: transparent !important;
 		background-color: transparent !important;
@@ -126,14 +129,14 @@ export default {
 		border-radius: 0;
 		border-radius: 0;
 	}
 	}
 
 
-	.nav-menu.is-active {
-		.nav-item {
-			color: var(--dark-grey-2);
+	.nav-left,
+	.nav-right {
+		flex: 1;
+		display: flex;
+	}
 
 
-			&:hover {
-				color: var(--dark-grey-2);
-			}
-		}
+	.nav-right {
+		justify-content: flex-end;
 	}
 	}
 
 
 	a.nav-item.is-tab:hover {
 	a.nav-item.is-tab:hover {
@@ -144,23 +147,54 @@ export default {
 
 
 	.nav-toggle {
 	.nav-toggle {
 		height: 64px;
 		height: 64px;
+		width: 50px;
+		position: relative;
 		background-color: transparent;
 		background-color: transparent;
+		display: none;
+		position: relative;
+		cursor: pointer;
+
+		&.is-active {
+			span:nth-child(1) {
+				margin-left: -5px;
+				transform: rotate(45deg);
+				transform-origin: left top;
+			}
+
+			span:nth-child(2) {
+				opacity: 0;
+			}
+
+			span:nth-child(3) {
+				margin-left: -5px;
+				transform: rotate(-45deg);
+				transform-origin: left bottom;
+			}
+		}
 
 
 		span {
 		span {
 			background-color: var(--white);
 			background-color: var(--white);
-		}
-	}
+			display: block;
+			height: 1px;
+			left: 50%;
+			margin-left: -7px;
+			position: absolute;
+			top: 50%;
+			width: 15px;
+			transition: none 86ms ease-out;
+			transition-property: opacity, transform;
 
 
-	.is-brand {
-		font-size: 2.1rem !important;
-		line-height: 38px !important;
-		padding: 0 20px;
-		font-family: Pacifico, cursive;
+			&:nth-child(1) {
+				margin-top: -6px;
+			}
+
+			&:nth-child(2) {
+				margin-top: -1px;
+			}
 
 
-		img {
-			max-height: 38px;
-			color: var(--primary-color);
-			user-select: none;
+			&:nth-child(3) {
+				margin-top: 4px;
+			}
 		}
 		}
 	}
 	}
 
 
@@ -168,43 +202,73 @@ export default {
 		font-size: 17px;
 		font-size: 17px;
 		color: var(--white);
 		color: var(--white);
 		border-top: 0;
 		border-top: 0;
+		display: flex;
+		align-items: center;
+		padding: 10px;
+		cursor: pointer;
 
 
 		&:hover,
 		&:hover,
 		&:focus {
 		&:focus {
 			color: var(--white);
 			color: var(--white);
 		}
 		}
-	}
 
 
-	.grouped {
-		margin: 0;
-		display: flex;
-		text-decoration: none;
+		&.is-brand {
+			font-size: 2.1rem !important;
+			line-height: 38px !important;
+			padding: 0 20px;
+			font-family: Pacifico, cursive;
+			display: flex;
+			align-items: center;
 
 
-		.nav-item {
-			&:hover,
-			&:focus {
-				border-top: 1px solid white;
-				height: calc(100% - 1px);
+			img {
+				max-height: 38px;
+				color: var(--primary-color);
+				user-select: none;
 			}
 			}
 		}
 		}
 	}
 	}
 }
 }
 
 
+.grouped {
+	margin: 0;
+	display: flex;
+	text-decoration: none;
+	.nav-item {
+		&:hover,
+		&:focus {
+			border-top: 1px solid var(--white);
+			height: calc(100% - 1px);
+		}
+	}
+}
+
 @media screen and (max-width: 768px) {
 @media screen and (max-width: 768px) {
-	.nav .nav-menu .grouped {
-		flex-direction: column;
+	.nav-toggle {
+		display: block !important;
+	}
+
+	.nav-menu {
+		display: none !important;
+		box-shadow: 0 4px 7px rgba(10, 10, 10, 0.1);
+		left: 0;
+		right: 0;
+		top: 100%;
+		position: absolute;
+		background: var(--white);
+	}
+
+	.nav-menu.is-active {
+		display: block !important;
+
 		.nav-item {
 		.nav-item {
-			padding: 10px 20px;
-			&:hover,
-			&:focus {
-				border-top: 0;
-				height: unset;
+			color: var(--dark-grey-2);
+
+			&:hover {
+				color: var(--dark-grey-2);
 			}
 			}
 		}
 		}
 	}
 	}
-}
 
 
-@media screen and (max-width: 768px) {
 	.nav .nav-menu .grouped {
 	.nav .nav-menu .grouped {
 		flex-direction: column;
 		flex-direction: column;
 		.nav-item {
 		.nav-item {

+ 16 - 10
frontend/src/components/modals/CreateCommunityStation.vue → frontend/src/components/modals/CreateStation.vue

@@ -1,11 +1,14 @@
 <template>
 <template>
-	<modal title="Create Community Station">
+	<modal
+		:title="
+			official ? 'Create Official Station' : 'Create Community Station'
+		"
+	>
 		<template #body>
 		<template #body>
-			<!-- validation to check if exists http://bulma.io/documentation/elements/form/ -->
 			<label class="label">Name (unique lowercase station id)</label>
 			<label class="label">Name (unique lowercase station id)</label>
 			<p class="control">
 			<p class="control">
 				<input
 				<input
-					v-model="newCommunity.name"
+					v-model="newStation.name"
 					class="input station-id"
 					class="input station-id"
 					type="text"
 					type="text"
 					placeholder="Name..."
 					placeholder="Name..."
@@ -15,7 +18,7 @@
 			<label class="label">Display Name</label>
 			<label class="label">Display Name</label>
 			<p class="control">
 			<p class="control">
 				<input
 				<input
-					v-model="newCommunity.displayName"
+					v-model="newStation.displayName"
 					class="input"
 					class="input"
 					type="text"
 					type="text"
 					placeholder="Display name..."
 					placeholder="Display name..."
@@ -24,7 +27,7 @@
 			<label class="label">Description</label>
 			<label class="label">Description</label>
 			<p class="control">
 			<p class="control">
 				<input
 				<input
-					v-model="newCommunity.description"
+					v-model="newStation.description"
 					class="input"
 					class="input"
 					type="text"
 					type="text"
 					placeholder="Description..."
 					placeholder="Description..."
@@ -47,9 +50,12 @@ import Modal from "../Modal.vue";
 
 
 export default {
 export default {
 	components: { Modal },
 	components: { Modal },
+	props: {
+		official: { type: Boolean, default: false }
+	},
 	data() {
 	data() {
 		return {
 		return {
-			newCommunity: {
+			newStation: {
 				name: "",
 				name: "",
 				displayName: "",
 				displayName: "",
 				description: ""
 				description: ""
@@ -61,8 +67,8 @@ export default {
 	}),
 	}),
 	methods: {
 	methods: {
 		submitModal() {
 		submitModal() {
-			this.newCommunity.name = this.newCommunity.name.toLowerCase();
-			const { name, displayName, description } = this.newCommunity;
+			this.newStation.name = this.newStation.name.toLowerCase();
+			const { name, displayName, description } = this.newStation;
 
 
 			if (!name || !displayName || !description)
 			if (!name || !displayName || !description)
 				return new Toast("Please fill in all fields");
 				return new Toast("Please fill in all fields");
@@ -102,14 +108,14 @@ export default {
 				"stations.create",
 				"stations.create",
 				{
 				{
 					name,
 					name,
-					type: "community",
+					type: this.official ? "official" : "community",
 					displayName,
 					displayName,
 					description
 					description
 				},
 				},
 				res => {
 				res => {
 					if (res.status === "success") {
 					if (res.status === "success") {
 						new Toast(`You have added the station successfully`);
 						new Toast(`You have added the station successfully`);
-						this.closeModal("createCommunityStation");
+						this.closeModal("createStation");
 					} else new Toast(res.message);
 					} else new Toast(res.message);
 				}
 				}
 			);
 			);

+ 29 - 44
frontend/src/components/modals/EditNews.vue

@@ -2,21 +2,21 @@
 	<modal
 	<modal
 		class="edit-news-modal"
 		class="edit-news-modal"
 		:title="newsId ? 'Edit News' : 'Create News'"
 		:title="newsId ? 'Edit News' : 'Create News'"
+		:wide="true"
+		:split="true"
 	>
 	>
 		<template #body>
 		<template #body>
-			<div id="markdown-editor-and-preview">
-				<div class="column">
-					<p><strong>Markdown</strong></p>
-					<textarea v-model="markdown"></textarea>
-				</div>
-				<div class="column">
-					<p><strong>Preview</strong></p>
-					<div
-						class="news-item"
-						id="preview"
-						v-html="sanitize(marked(markdown))"
-					></div>
-				</div>
+			<div class="left-section">
+				<p><strong>Markdown</strong></p>
+				<textarea v-model="markdown"></textarea>
+			</div>
+			<div class="right-section">
+				<p><strong>Preview</strong></p>
+				<div
+					class="news-item"
+					id="preview"
+					v-html="sanitize(marked(markdown))"
+				></div>
 			</div>
 			</div>
 		</template>
 		</template>
 		<template #footer>
 		<template #footer>
@@ -46,8 +46,9 @@
 							:user-id="createdBy"
 							:user-id="createdBy"
 							:alt="createdBy"
 							:alt="createdBy"
 							:link="true"
 							:link="true"
-						/> </span
-					>&nbsp;<span :title="new Date(createdAt)">
+						/>
+					</span>
+					<span :title="new Date(createdAt)">
 						{{
 						{{
 							formatDistance(createdAt, new Date(), {
 							formatDistance(createdAt, new Date(), {
 								addSuffix: true
 								addSuffix: true
@@ -81,7 +82,7 @@ export default {
 	data() {
 	data() {
 		return {
 		return {
 			markdown:
 			markdown:
-				"# Header\n ## Sub-Header\n- **So**\n- _Many_\n- ~Points~\n\nOther things you want to say and [link](https://example.com).\n\n### Sub-Sub-Header\n> Oh look, a quote!\n\n`lil code`\n\n```\nbig code\n```\n",
+				"# Header\n## Sub-Header\n- **So**\n- _Many_\n- ~Points~\n\nOther things you want to say and [link](https://example.com).\n\n### Sub-Sub-Header\n> Oh look, a quote!\n\n`lil code`\n\n```\nbig code\n```\n",
 			status: "published",
 			status: "published",
 			createdBy: null,
 			createdBy: null,
 			createdAt: 0
 			createdAt: 0
@@ -211,50 +212,33 @@ export default {
 </script>
 </script>
 
 
 <style lang="scss">
 <style lang="scss">
-.edit-news-modal {
-	.modal-card {
-		width: 1300px;
-		.modal-card-foot .right {
-			margin: auto 0 auto auto !important;
-
-			span:not(:last-child) {
-				margin-right: 0 !important;
-			}
-		}
-	}
+.edit-news-modal .modal-card .modal-card-foot .right {
+	column-gap: 5px;
 }
 }
 </style>
 </style>
 
 
 <style lang="scss" scoped>
 <style lang="scss" scoped>
 .night-mode {
 .night-mode {
-	#markdown-editor-and-preview textarea,
-	#markdown-editor-and-preview #preview {
+	.edit-news-modal .modal-card .modal-card-body textarea,
+	.edit-news-modal .modal-card .modal-card-body #preview {
 		border-color: var(--grey-3);
 		border-color: var(--grey-3);
 	}
 	}
 
 
-	#markdown-editor-and-preview textarea {
+	.edit-news-modal .modal-card .modal-card-body textarea {
 		background-color: var(--dark-grey);
 		background-color: var(--dark-grey);
 		color: var(--white);
 		color: var(--white);
 	}
 	}
 }
 }
-
-#markdown-editor-and-preview {
-	display: flex;
-	flex-wrap: wrap;
-
-	.column {
-		display: flex;
-		flex-direction: column;
-		width: 350px;
-		flex-grow: 1;
-		flex-basis: initial;
+.edit-news-modal .modal-card .modal-card-body {
+	.left-section,
+	.right-section {
+		padding: 10px;
 	}
 	}
 
 
 	textarea {
 	textarea {
 		border: 0;
 		border: 0;
 		outline: none;
 		outline: none;
 		resize: none;
 		resize: none;
-		margin-right: 5px;
 		font-size: 16px;
 		font-size: 16px;
 	}
 	}
 
 
@@ -268,8 +252,9 @@ export default {
 	#preview {
 	#preview {
 		padding: 5px;
 		padding: 5px;
 		border: 1px solid var(--light-grey-3) !important;
 		border: 1px solid var(--light-grey-3) !important;
-		border-radius: 3px;
-		height: 700px;
+		border-radius: 5px;
+		height: calc(100vh - 280px);
+		width: 100%;
 	}
 	}
 }
 }
 </style>
 </style>

+ 182 - 255
frontend/src/components/modals/EditPlaylist/index.vue

@@ -3,63 +3,28 @@
 		:title="
 		:title="
 			userId === playlist.createdBy ? 'Edit Playlist' : 'View Playlist'
 			userId === playlist.createdBy ? 'Edit Playlist' : 'View Playlist'
 		"
 		"
-		class="edit-playlist-modal"
+		:class="{
+			'edit-playlist-modal': true,
+			'view-only': !isEditable()
+		}"
+		:wide="true"
+		:split="true"
 	>
 	>
 		<template #body>
 		<template #body>
-			<div
-				:class="{
-					'view-only': !isEditable(),
-					'custom-modal-body': true
-				}"
-			>
-				<div class="left-section">
-					<div id="playlist-info-section" class="section">
-						<h3>{{ playlist.displayName }}</h3>
-						<h5>Song Count: {{ playlist.songs.length }}</h5>
-						<h5>Duration: {{ totalLength() }}</h5>
-					</div>
+			<div class="left-section">
+				<div id="playlist-info-section" class="section">
+					<h3>{{ playlist.displayName }}</h3>
+					<h5>Song Count: {{ playlist.songs.length }}</h5>
+					<h5>Duration: {{ totalLength() }}</h5>
+				</div>
 
 
-					<div class="section tabs-container">
-						<div class="tab-selection">
-							<button
-								class="button is-default"
-								:class="{ selected: tab === 'settings' }"
-								ref="settings-tab"
-								@click="showTab('settings')"
-								v-if="
-									userId === playlist.createdBy ||
-									isEditable() ||
-									((playlist.type === 'genre' ||
-										playlist.type === 'artist') &&
-										isAdmin())
-								"
-							>
-								Settings
-							</button>
-							<button
-								class="button is-default"
-								:class="{ selected: tab === 'add-songs' }"
-								ref="add-songs-tab"
-								@click="showTab('add-songs')"
-								v-if="isEditable()"
-							>
-								Add Songs
-							</button>
-							<button
-								class="button is-default"
-								:class="{
-									selected: tab === 'import-playlists'
-								}"
-								ref="import-playlists-tab"
-								@click="showTab('import-playlists')"
-								v-if="isEditable()"
-							>
-								Import Playlists
-							</button>
-						</div>
-						<settings
-							class="tab"
-							v-show="tab === 'settings'"
+				<div class="tabs-container">
+					<div class="tab-selection">
+						<button
+							class="button is-default"
+							:class="{ selected: tab === 'settings' }"
+							ref="settings-tab"
+							@click="showTab('settings')"
 							v-if="
 							v-if="
 								userId === playlist.createdBy ||
 								userId === playlist.createdBy ||
 								isEditable() ||
 								isEditable() ||
@@ -67,149 +32,171 @@
 									playlist.type === 'artist') &&
 									playlist.type === 'artist') &&
 									isAdmin())
 									isAdmin())
 							"
 							"
-						/>
-						<add-songs
-							class="tab"
-							v-show="tab === 'add-songs'"
+						>
+							Settings
+						</button>
+						<button
+							class="button is-default"
+							:class="{ selected: tab === 'add-songs' }"
+							ref="add-songs-tab"
+							@click="showTab('add-songs')"
 							v-if="isEditable()"
 							v-if="isEditable()"
-						/>
-						<import-playlists
-							class="tab"
-							v-show="tab === 'import-playlists'"
+						>
+							Add Songs
+						</button>
+						<button
+							class="button is-default"
+							:class="{
+								selected: tab === 'import-playlists'
+							}"
+							ref="import-playlists-tab"
+							@click="showTab('import-playlists')"
 							v-if="isEditable()"
 							v-if="isEditable()"
-						/>
+						>
+							Import Playlists
+						</button>
 					</div>
 					</div>
+					<settings
+						class="tab"
+						v-show="tab === 'settings'"
+						v-if="
+							userId === playlist.createdBy ||
+							isEditable() ||
+							(playlist.type === 'genre' && isAdmin())
+						"
+					/>
+					<add-songs
+						class="tab"
+						v-show="tab === 'add-songs'"
+						v-if="isEditable()"
+					/>
+					<import-playlists
+						class="tab"
+						v-show="tab === 'import-playlists'"
+						v-if="isEditable()"
+					/>
 				</div>
 				</div>
+			</div>
 
 
-				<div class="right-section">
-					<div id="rearrange-songs-section" class="section">
-						<div v-if="isEditable()">
-							<h4 class="section-title">Rearrange Songs</h4>
-
-							<p class="section-description">
-								Drag and drop songs to change their order
-							</p>
-
-							<hr class="section-horizontal-rule" />
-						</div>
-
-						<aside class="menu">
-							<draggable
-								tag="transition-group"
-								:component-data="{
-									name: !drag
-										? 'draggable-list-transition'
-										: null
-								}"
-								v-if="playlistSongs.length > 0"
-								v-model="playlistSongs"
-								item-key="_id"
-								v-bind="dragOptions"
-								@start="drag = true"
-								@end="drag = false"
-								@change="repositionSong"
-							>
-								<template #item="{ element, index }">
-									<div class="menu-list scrollable-list">
-										<song-item
-											:song="element"
-											:class="{
-												'item-draggable': isEditable()
-											}"
-											:ref="`song-item-${index}`"
-										>
-											<template #tippyActions>
+			<div class="right-section">
+				<div id="rearrange-songs-section" class="section">
+					<div v-if="isEditable()">
+						<h4 class="section-title">Rearrange Songs</h4>
+
+						<p class="section-description">
+							Drag and drop songs to change their order
+						</p>
+
+						<hr class="section-horizontal-rule" />
+					</div>
+
+					<aside class="menu">
+						<draggable
+							tag="transition-group"
+							:component-data="{
+								name: !drag ? 'draggable-list-transition' : null
+							}"
+							v-if="playlistSongs.length > 0"
+							v-model="playlistSongs"
+							item-key="_id"
+							v-bind="dragOptions"
+							@start="drag = true"
+							@end="drag = false"
+							@change="repositionSong"
+						>
+							<template #item="{ element, index }">
+								<div class="menu-list scrollable-list">
+									<song-item
+										:song="element"
+										:class="{
+											'item-draggable': isEditable()
+										}"
+										:ref="`song-item-${index}`"
+									>
+										<template #tippyActions>
+											<i
+												class="
+													material-icons
+													add-to-queue-icon
+												"
+												v-if="
+													station.partyMode &&
+													!station.locked
+												"
+												@click="
+													addSongToQueue(
+														element.youtubeId
+													)
+												"
+												content="Add Song to Queue"
+												v-tippy
+												>queue</i
+											>
+											<confirm
+												v-if="
+													userId ===
+														playlist.createdBy ||
+													isEditable()
+												"
+												placement="left"
+												@confirm="
+													removeSongFromPlaylist(
+														element.youtubeId
+													)
+												"
+											>
 												<i
 												<i
 													class="
 													class="
 														material-icons
 														material-icons
-														add-to-queue-icon
-													"
-													v-if="
-														station.partyMode &&
-														!station.locked
-													"
-													@click="
-														addSongToQueue(
-															element.youtubeId
-														)
+														delete-icon
 													"
 													"
-													content="Add Song to Queue"
+													content="Remove Song from Playlist"
 													v-tippy
 													v-tippy
-													>queue</i
-												>
-												<confirm
-													v-if="
-														userId ===
-															playlist.createdBy ||
-														isEditable()
-													"
-													placement="left"
-													@confirm="
-														removeSongFromPlaylist(
-															element.youtubeId
-														)
-													"
+													>delete_forever</i
 												>
 												>
-													<i
-														class="
-															material-icons
-															delete-icon
-														"
-														content="Remove Song from Playlist"
-														v-tippy
-														>delete_forever</i
-													>
-												</confirm>
-												<i
-													class="material-icons"
-													v-if="
-														isEditable() &&
-														index > 0
-													"
-													@click="
-														moveSongToTop(
-															element,
-															index
-														)
-													"
-													content="Move to top of Playlist"
-													v-tippy
-													>vertical_align_top</i
-												>
-												<i
-													v-if="
-														isEditable() &&
-														playlistSongs.length -
-															1 !==
-															index
-													"
-													@click="
-														moveSongToBottom(
-															element,
-															index
-														)
-													"
-													class="material-icons"
-													content="Move to bottom of Playlist"
-													v-tippy
-													>vertical_align_bottom</i
-												>
-											</template>
-										</song-item>
-									</div>
-								</template>
-							</draggable>
-							<p
-								v-else-if="gettingSongs"
-								class="nothing-here-text"
-							>
-								Loading songs...
-							</p>
-							<p v-else class="nothing-here-text">
-								This playlist doesn't have any songs.
-							</p>
-						</aside>
-					</div>
+											</confirm>
+											<i
+												class="material-icons"
+												v-if="isEditable() && index > 0"
+												@click="
+													moveSongToTop(
+														element,
+														index
+													)
+												"
+												content="Move to top of Playlist"
+												v-tippy
+												>vertical_align_top</i
+											>
+											<i
+												v-if="
+													isEditable() &&
+													playlistSongs.length - 1 !==
+														index
+												"
+												@click="
+													moveSongToBottom(
+														element,
+														index
+													)
+												"
+												class="material-icons"
+												content="Move to bottom of Playlist"
+												v-tippy
+												>vertical_align_bottom</i
+											>
+										</template>
+									</song-item>
+								</div>
+							</template>
+						</draggable>
+						<p v-else-if="gettingSongs" class="nothing-here-text">
+							Loading songs...
+						</p>
+						<p v-else class="nothing-here-text">
+							This playlist doesn't have any songs.
+						</p>
+					</aside>
 				</div>
 				</div>
 			</div>
 			</div>
 		</template>
 		</template>
@@ -620,20 +607,6 @@ export default {
 };
 };
 </script>
 </script>
 
 
-<style lang="scss">
-.edit-playlist-modal {
-	.modal-card {
-		width: 1300px;
-		height: 100%;
-		overflow: auto;
-
-		.modal-card-body {
-			padding: 16px;
-		}
-	}
-}
-</style>
-
 <style lang="scss" scoped>
 <style lang="scss" scoped>
 .night-mode {
 .night-mode {
 	.label,
 	.label,
@@ -642,7 +615,7 @@ export default {
 		color: var(--light-grey-2);
 		color: var(--light-grey-2);
 	}
 	}
 
 
-	.edit-playlist-modal.modal .modal-card-body .custom-modal-body {
+	.edit-playlist-modal.modal .modal-card-body {
 		.left-section {
 		.left-section {
 			#playlist-info-section {
 			#playlist-info-section {
 				background-color: var(--dark-grey-3) !important;
 				background-color: var(--dark-grey-3) !important;
@@ -699,6 +672,14 @@ export default {
 		.left-section {
 		.left-section {
 			flex-basis: 100%;
 			flex-basis: 100%;
 		}
 		}
+
+		.right-section {
+			max-height: unset !important;
+		}
+
+		/deep/ .section {
+			max-width: 100% !important;
+		}
 	}
 	}
 
 
 	.nothing-here-text {
 	.nothing-here-text {
@@ -716,56 +697,7 @@ export default {
 		width: 150px;
 		width: 150px;
 	}
 	}
 
 
-	.section {
-		display: flex;
-		flex-direction: column;
-		flex-grow: 1;
-		width: auto;
-		padding: 15px !important;
-		margin: 0 10px;
-	}
-
 	.left-section {
 	.left-section {
-		flex-basis: 50%;
-		height: 100%;
-		overflow-y: auto;
-		flex-grow: 1;
-
-		.tabs-container {
-			padding: 15px 0 !important;
-			.tab-selection {
-				display: flex;
-				overflow-x: auto;
-
-				.button {
-					border-radius: 5px 5px 0 0;
-					border: 0;
-					text-transform: uppercase;
-					font-size: 14px;
-					color: var(--dark-grey-3);
-					background-color: var(--light-grey-2);
-					flex-grow: 1;
-					height: 32px;
-
-					&:not(:first-of-type) {
-						margin-left: 5px;
-					}
-				}
-
-				.selected {
-					background-color: var(--primary-color) !important;
-					color: var(--white) !important;
-					font-weight: 600;
-				}
-			}
-			.tab {
-				border: 1px solid var(--light-grey-3);
-				padding: 15px;
-				border-radius: 0 0 5px 5px;
-				margin: 0;
-			}
-		}
-
 		#playlist-info-section {
 		#playlist-info-section {
 			border: 1px solid var(--light-grey-3);
 			border: 1px solid var(--light-grey-3);
 			border-radius: 3px;
 			border-radius: 3px;
@@ -788,11 +720,6 @@ export default {
 	}
 	}
 
 
 	.right-section {
 	.right-section {
-		flex-basis: 50%;
-		height: 100%;
-		overflow-y: auto;
-		flex-grow: 1;
-
 		#rearrange-songs-section {
 		#rearrange-songs-section {
 			.scrollable-list:not(:last-of-type) {
 			.scrollable-list:not(:last-of-type) {
 				margin-bottom: 10px;
 				margin-bottom: 10px;

+ 5 - 68
frontend/src/components/modals/EditSong/index.vue

@@ -1,6 +1,6 @@
 <template>
 <template>
 	<div>
 	<div>
-		<modal title="Edit Song" class="song-modal">
+		<modal title="Edit Song" class="song-modal" :wide="true" :split="true">
 			<template #body>
 			<template #body>
 				<div class="left-section">
 				<div class="left-section">
 					<div class="top-section">
 					<div class="top-section">
@@ -1612,7 +1612,7 @@ export default {
 
 
 	.autosuggest-item {
 	.autosuggest-item {
 		background-color: var(--dark-grey) !important;
 		background-color: var(--dark-grey) !important;
-		color: white !important;
+		color: var(--white) !important;
 		border-color: var(--dark-grey) !important;
 		border-color: var(--dark-grey) !important;
 	}
 	}
 
 
@@ -1627,69 +1627,8 @@ export default {
 	}
 	}
 }
 }
 
 
-.song-modal {
-	&::v-deep {
-		.modal-card {
-			width: 1300px;
-			height: 100%;
-
-			.modal-card-body {
-				display: flex;
-				column-gap: 16px;
-				row-gap: 16px;
-
-				@media screen and (max-width: 1000px) {
-					flex-wrap: wrap;
-				}
-
-				> div {
-					display: flex;
-					flex-grow: 1;
-					height: 100%;
-					overflow: auto;
-				}
-			}
-
-			.modal-card-foot {
-				.right {
-					display: flex;
-					margin-left: auto;
-					margin-right: 0;
-				}
-			}
-		}
-	}
-}
-
-#video-container {
-	position: relative;
-	padding-bottom: 56.25%; /* proportion value to aspect ratio 16:9 (9 / 16 = 0.5625 or 56.25%) */
-	height: 0;
-	overflow: hidden;
-
-	.player-cannot-autoplay {
-		position: relative;
-		width: 100%;
-		height: 100%;
-		bottom: calc(100% + 5px);
-		background-color: var(--primary-color);
-		display: flex;
-		align-items: center;
-		justify-content: center;
-
-		p {
-			color: var(--white);
-			font-size: 26px;
-			text-align: center;
-		}
-	}
-}
-
 .left-section {
 .left-section {
-	display: flex;
-	flex-direction: column;
-	flex-grow: 0 !important;
-	height: inherit !important;
+	flex-basis: unset !important;
 
 
 	@media screen and (max-width: 1245px) {
 	@media screen and (max-width: 1245px) {
 		flex-grow: 1 !important;
 		flex-grow: 1 !important;
@@ -2080,10 +2019,8 @@ export default {
 }
 }
 
 
 .right-section {
 .right-section {
-	display: flex;
-	flex-wrap: wrap;
-	flex-basis: 450px;
-	overflow-y: auto;
+	flex-basis: unset !important;
+	flex-grow: 0 !important;
 
 
 	@media screen and (max-width: 1245px) {
 	@media screen and (max-width: 1245px) {
 		height: inherit !important;
 		height: inherit !important;

+ 92 - 117
frontend/src/components/modals/Login.vue

@@ -1,119 +1,96 @@
 <template>
 <template>
 	<div>
 	<div>
-		<page-metadata title="Login" v-if="isPage" />
-		<div class="modal is-active">
-			<div class="modal-background" @click="closeLoginModal()" />
-			<div class="modal-card">
-				<header class="modal-card-head">
-					<p class="modal-card-title">Login</p>
-					<button
-						v-if="!isPage"
-						class="delete"
-						@click="closeLoginModal()"
-					/>
-				</header>
-
-				<section class="modal-card-body">
-					<form>
-						<!-- email address -->
-						<p class="control">
-							<label class="label">Email</label>
-							<input
-								v-model="email"
-								class="input"
-								type="email"
-								placeholder="Email..."
-								@keypress="submitOnEnter(submitModal, $event)"
-							/>
-						</p>
-
-						<!-- password -->
-						<p class="control">
-							<label class="label">Password</label>
-						</p>
-
-						<div id="password-visibility-container">
-							<input
-								v-model="password.value"
-								class="input"
-								type="password"
-								ref="password"
-								placeholder="Password..."
-								@input="checkForAutofill($event)"
-								@keypress="submitOnEnter(submitModal, $event)"
-							/>
-							<a @click="togglePasswordVisibility()">
-								<i class="material-icons">
-									{{
-										!password.visible
-											? "visibility"
-											: "visibility_off"
-									}}
-								</i>
-							</a>
-						</div>
-
-						<p class="content-box-optional-helper">
-							<router-link
-								id="forgot-password"
-								to="/reset_password"
-								@click="closeLoginModal()"
-							>
-								Forgot password?
-							</router-link>
-						</p>
+		<modal title="Login" class="login-modal" @closed="closeLoginModal()">
+			<template #body>
+				<form>
+					<!-- email address -->
+					<p class="control">
+						<label class="label">Email</label>
+						<input
+							v-model="email"
+							class="input"
+							type="email"
+							placeholder="Email..."
+							@keypress="submitOnEnter(submitModal, $event)"
+						/>
+					</p>
 
 
-						<br />
-						<p>
-							By logging in you agree to our
-							<router-link to="/terms" @click="closeLoginModal()">
-								Terms of Service
-							</router-link>
-							and
-							<router-link
-								to="/privacy"
-								@click="closeLoginModal()"
-							>
-								Privacy Policy</router-link
-							>.
-						</p>
-					</form>
-				</section>
+					<!-- password -->
+					<p class="control">
+						<label class="label">Password</label>
+					</p>
 
 
-				<footer class="modal-card-foot">
-					<div id="actions">
-						<button
-							class="button is-primary"
-							@click="submitModal()"
-						>
-							Login
-						</button>
-						<a
-							class="button is-github"
-							:href="apiDomain + '/auth/github/authorize'"
-							@click="githubRedirect()"
-						>
-							<div class="icon">
-								<img
-									class="invert"
-									src="/assets/social/github.svg"
-								/>
-							</div>
-							&nbsp;&nbsp;Login with GitHub
+					<div id="password-visibility-container">
+						<input
+							v-model="password.value"
+							class="input"
+							type="password"
+							ref="password"
+							placeholder="Password..."
+							@input="checkForAutofill($event)"
+							@keypress="submitOnEnter(submitModal, $event)"
+						/>
+						<a @click="togglePasswordVisibility()">
+							<i class="material-icons">
+								{{
+									!password.visible
+										? "visibility"
+										: "visibility_off"
+								}}
+							</i>
 						</a>
 						</a>
 					</div>
 					</div>
 
 
 					<p class="content-box-optional-helper">
 					<p class="content-box-optional-helper">
-						<router-link to="/register" v-if="isPage">
-							Don't have an account?
+						<router-link
+							id="forgot-password"
+							to="/reset_password"
+							@click="closeLoginModal()"
+						>
+							Forgot password?
 						</router-link>
 						</router-link>
-						<a v-else @click="changeToRegisterModal()">
-							Don't have an account?
-						</a>
 					</p>
 					</p>
-				</footer>
-			</div>
-		</div>
+
+					<br />
+					<p>
+						By logging in you agree to our
+						<router-link to="/terms" @click="closeLoginModal()">
+							Terms of Service
+						</router-link>
+						and
+						<router-link to="/privacy" @click="closeLoginModal()">
+							Privacy Policy</router-link
+						>.
+					</p>
+				</form>
+			</template>
+			<template #footer>
+				<div id="actions">
+					<button class="button is-primary" @click="submitModal()">
+						Login
+					</button>
+					<a
+						class="button is-github"
+						:href="apiDomain + '/auth/github/authorize'"
+						@click="githubRedirect()"
+					>
+						<div class="icon">
+							<img
+								class="invert"
+								src="/assets/social/github.svg"
+							/>
+						</div>
+						&nbsp;&nbsp;Login with GitHub
+					</a>
+				</div>
+
+				<p class="content-box-optional-helper">
+					<a @click="changeToRegisterModal()">
+						Don't have an account?
+					</a>
+				</p>
+			</template>
+		</modal>
 	</div>
 	</div>
 </template>
 </template>
 
 
@@ -121,8 +98,12 @@
 import { mapActions } from "vuex";
 import { mapActions } from "vuex";
 
 
 import Toast from "toasters";
 import Toast from "toasters";
+import Modal from "../Modal.vue";
 
 
 export default {
 export default {
+	components: {
+		Modal
+	},
 	data() {
 	data() {
 		return {
 		return {
 			email: "",
 			email: "",
@@ -130,14 +111,11 @@ export default {
 				value: "",
 				value: "",
 				visible: false
 				visible: false
 			},
 			},
-			apiDomain: "",
-			isPage: false
+			apiDomain: ""
 		};
 		};
 	},
 	},
 	async mounted() {
 	async mounted() {
 		this.apiDomain = await lofig.get("backend.apiDomain");
 		this.apiDomain = await lofig.get("backend.apiDomain");
-
-		if (this.$route.path === "/login") this.isPage = true;
 	},
 	},
 	methods: {
 	methods: {
 		checkForAutofill(event) {
 		checkForAutofill(event) {
@@ -173,17 +151,14 @@ export default {
 			}
 			}
 		},
 		},
 		changeToRegisterModal() {
 		changeToRegisterModal() {
-			if (!this.isPage) {
-				this.closeLoginModal();
-				this.openModal("register");
-			}
+			this.closeLoginModal();
+			this.openModal("register");
 		},
 		},
 		closeLoginModal() {
 		closeLoginModal() {
-			if (!this.isPage) this.closeModal("login");
+			this.closeModal("login");
 		},
 		},
 		githubRedirect() {
 		githubRedirect() {
-			if (!this.isPage)
-				localStorage.setItem("github_redirect", this.$route.path);
+			localStorage.setItem("github_redirect", this.$route.path);
 		},
 		},
 		...mapActions("modalVisibility", ["closeModal", "openModal"]),
 		...mapActions("modalVisibility", ["closeModal", "openModal"]),
 		...mapActions("user/auth", ["login"])
 		...mapActions("user/auth", ["login"])

+ 127 - 168
frontend/src/components/modals/ManageStation/index.vue

@@ -8,68 +8,23 @@
 		"
 		"
 		:style="`--primary-color: var(--${station.theme})`"
 		:style="`--primary-color: var(--${station.theme})`"
 		class="manage-station-modal"
 		class="manage-station-modal"
+		:wide="true"
+		:split="true"
 	>
 	>
-		<template #body>
-			<div class="custom-modal-body" v-if="station && station._id">
-				<div class="left-section">
-					<div class="section tabs-container">
-						<div class="tab-selection">
-							<button
-								v-if="isOwnerOrAdmin()"
-								class="button is-default"
-								:class="{ selected: tab === 'settings' }"
-								ref="settings-tab"
-								@click="showTab('settings')"
-							>
-								Settings
-							</button>
-							<button
-								v-if="
-									isOwnerOrAdmin() ||
-									(loggedIn &&
-										station.type === 'community' &&
-										station.partyMode &&
-										((station.locked && isOwnerOrAdmin()) ||
-											!station.locked))
-								"
-								class="button is-default"
-								:class="{ selected: tab === 'playlists' }"
-								ref="playlists-tab"
-								@click="showTab('playlists')"
-							>
-								Playlists
-							</button>
-							<button
-								v-if="
-									loggedIn &&
-									station.type === 'community' &&
-									station.partyMode &&
-									((station.locked && isOwnerOrAdmin()) ||
-										!station.locked)
-								"
-								class="button is-default"
-								:class="{ selected: tab === 'songs' }"
-								ref="songs-tab"
-								@click="showTab('songs')"
-							>
-								Add Songs
-							</button>
-							<button
-								v-if="isOwnerOrAdmin()"
-								class="button is-default"
-								:class="{ selected: tab === 'blacklist' }"
-								ref="blacklist-tab"
-								@click="showTab('blacklist')"
-							>
-								Blacklist
-							</button>
-						</div>
-						<settings
+		<template #body v-if="station && station._id">
+			<div class="left-section">
+				<div class="section tabs-container">
+					<div class="tab-selection">
+						<button
 							v-if="isOwnerOrAdmin()"
 							v-if="isOwnerOrAdmin()"
-							class="tab"
-							v-show="tab === 'settings'"
-						/>
-						<playlists
+							class="button is-default"
+							:class="{ selected: tab === 'settings' }"
+							ref="settings-tab"
+							@click="showTab('settings')"
+						>
+							Settings
+						</button>
+						<button
 							v-if="
 							v-if="
 								isOwnerOrAdmin() ||
 								isOwnerOrAdmin() ||
 								(loggedIn &&
 								(loggedIn &&
@@ -78,10 +33,14 @@
 									((station.locked && isOwnerOrAdmin()) ||
 									((station.locked && isOwnerOrAdmin()) ||
 										!station.locked))
 										!station.locked))
 							"
 							"
-							class="tab"
-							v-show="tab === 'playlists'"
-						/>
-						<songs
+							class="button is-default"
+							:class="{ selected: tab === 'playlists' }"
+							ref="playlists-tab"
+							@click="showTab('playlists')"
+						>
+							Playlists
+						</button>
+						<button
 							v-if="
 							v-if="
 								loggedIn &&
 								loggedIn &&
 								station.type === 'community' &&
 								station.type === 'community' &&
@@ -89,64 +48,105 @@
 								((station.locked && isOwnerOrAdmin()) ||
 								((station.locked && isOwnerOrAdmin()) ||
 									!station.locked)
 									!station.locked)
 							"
 							"
-							class="tab"
-							v-show="tab === 'songs'"
-						/>
-						<blacklist
+							class="button is-default"
+							:class="{ selected: tab === 'songs' }"
+							ref="songs-tab"
+							@click="showTab('songs')"
+						>
+							Add Songs
+						</button>
+						<button
 							v-if="isOwnerOrAdmin()"
 							v-if="isOwnerOrAdmin()"
-							class="tab"
-							v-show="tab === 'blacklist'"
-						/>
+							class="button is-default"
+							:class="{ selected: tab === 'blacklist' }"
+							ref="blacklist-tab"
+							@click="showTab('blacklist')"
+						>
+							Blacklist
+						</button>
 					</div>
 					</div>
+					<settings
+						v-if="isOwnerOrAdmin()"
+						class="tab"
+						v-show="tab === 'settings'"
+					/>
+					<playlists
+						v-if="
+							isOwnerOrAdmin() ||
+							(loggedIn &&
+								station.type === 'community' &&
+								station.partyMode &&
+								((station.locked && isOwnerOrAdmin()) ||
+									!station.locked))
+						"
+						class="tab"
+						v-show="tab === 'playlists'"
+					/>
+					<songs
+						v-if="
+							loggedIn &&
+							station.type === 'community' &&
+							station.partyMode &&
+							((station.locked && isOwnerOrAdmin()) ||
+								!station.locked)
+						"
+						class="tab"
+						v-show="tab === 'songs'"
+					/>
+					<blacklist
+						v-if="isOwnerOrAdmin()"
+						class="tab"
+						v-show="tab === 'blacklist'"
+					/>
 				</div>
 				</div>
-				<div class="right-section">
-					<div class="section">
-						<div class="queue-title">
-							<h4 class="section-title">Queue</h4>
-							<i
-								v-if="isOwnerOrAdmin() && stationPaused"
-								@click="resumeStation()"
-								class="material-icons resume-station"
-								content="Resume Station"
-								v-tippy
-							>
-								play_arrow
-							</i>
+			</div>
+			<div class="right-section">
+				<div class="section">
+					<div class="queue-title">
+						<h4 class="section-title">Queue</h4>
+						<i
+							v-if="isOwnerOrAdmin() && stationPaused"
+							@click="resumeStation()"
+							class="material-icons resume-station"
+							content="Resume Station"
+							v-tippy
+						>
+							play_arrow
+						</i>
+						<i
+							v-if="isOwnerOrAdmin() && !stationPaused"
+							@click="pauseStation()"
+							class="material-icons pause-station"
+							content="Pause Station"
+							v-tippy
+						>
+							pause
+						</i>
+						<confirm
+							v-if="isOwnerOrAdmin()"
+							@confirm="skipStation()"
+						>
 							<i
 							<i
-								v-if="isOwnerOrAdmin() && !stationPaused"
-								@click="pauseStation()"
-								class="material-icons pause-station"
-								content="Pause Station"
+								class="material-icons skip-station"
+								content="Force Skip Station"
 								v-tippy
 								v-tippy
 							>
 							>
-								pause
+								skip_next
 							</i>
 							</i>
-							<confirm
-								v-if="isOwnerOrAdmin()"
-								@confirm="skipStation()"
-							>
-								<i
-									class="material-icons skip-station"
-									content="Force Skip Station"
-									v-tippy
-								>
-									skip_next
-								</i>
-							</confirm>
-						</div>
-						<hr class="section-horizontal-rule" />
-						<song-item
-							v-if="currentSong._id"
-							:song="currentSong"
-							:requested-by="
-								station.type === 'community' &&
-								station.partyMode === true
-							"
-							header="Currently Playing.."
-							class="currently-playing"
-						/>
-						<queue sector="manageStation" />
+						</confirm>
 					</div>
 					</div>
+					<hr class="section-horizontal-rule" />
+					<song-item
+						v-if="currentSong._id"
+						:song="currentSong"
+						:requested-by="
+							station.type === 'community' &&
+							station.partyMode === true
+						"
+						header="Currently Playing.."
+						class="currently-playing"
+					/>
+					<queue sector="manageStation" />
 				</div>
 				</div>
 			</div>
 			</div>
 		</template>
 		</template>
@@ -182,10 +182,7 @@
 						Clear and refill station queue
 						Clear and refill station queue
 					</a>
 					</a>
 				</confirm>
 				</confirm>
-				<confirm
-					v-if="station && station.type === 'community'"
-					@confirm="removeStation()"
-				>
+				<confirm @confirm="removeStation()">
 					<button class="button is-danger">Delete station</button>
 					<button class="button is-danger">Delete station</button>
 				</confirm>
 				</confirm>
 			</div>
 			</div>
@@ -589,22 +586,16 @@ export default {
 </script>
 </script>
 
 
 <style lang="scss">
 <style lang="scss">
-.manage-station-modal.modal {
-	z-index: 1800;
-	.modal-card {
-		width: 1300px;
-		height: 100%;
-		overflow: auto;
-		.tab > button {
-			width: 100%;
-			margin-bottom: 10px;
-		}
-		.currently-playing.song-item {
-			.thumbnail {
-				min-width: 130px;
-				width: 130px;
-				height: 130px;
-			}
+.manage-station-modal.modal .modal-card {
+	.tab > button {
+		width: 100%;
+		margin-bottom: 10px;
+	}
+	.currently-playing.song-item {
+		.thumbnail {
+			min-width: 130px;
+			width: 130px;
+			height: 130px;
 		}
 		}
 	}
 	}
 }
 }
@@ -612,7 +603,7 @@ export default {
 
 
 <style lang="scss" scoped>
 <style lang="scss" scoped>
 .night-mode {
 .night-mode {
-	.manage-station-modal.modal .modal-card-body .custom-modal-body {
+	.manage-station-modal.modal .modal-card-body {
 		.left-section {
 		.left-section {
 			.tabs-container.section {
 			.tabs-container.section {
 				background-color: transparent !important;
 				background-color: transparent !important;
@@ -633,26 +624,8 @@ export default {
 	}
 	}
 }
 }
 
 
-.manage-station-modal.modal .modal-card-body .custom-modal-body {
-	display: flex;
-	flex-wrap: wrap;
-	height: 100%;
-
-	.section {
-		display: flex;
-		flex-direction: column;
-		flex-grow: 1;
-		width: auto;
-		padding: 15px !important;
-		margin: 0 10px;
-	}
-
+.manage-station-modal.modal .modal-card-body {
 	.left-section {
 	.left-section {
-		flex-basis: 50%;
-		height: 100%;
-		overflow-y: auto;
-		flex-grow: 1;
-
 		.tabs-container {
 		.tabs-container {
 			padding: 15px 0 !important;
 			padding: 15px 0 !important;
 			.tab-selection {
 			.tab-selection {
@@ -688,10 +661,6 @@ export default {
 		}
 		}
 	}
 	}
 	.right-section {
 	.right-section {
-		flex-basis: 50%;
-		height: 100%;
-		overflow-y: auto;
-		flex-grow: 1;
 		.section {
 		.section {
 			.queue-title {
 			.queue-title {
 				display: flex;
 				display: flex;
@@ -719,14 +688,4 @@ export default {
 		}
 		}
 	}
 	}
 }
 }
-
-@media screen and (max-width: 1100px) {
-	.manage-station-modal.modal .modal-card-body .custom-modal-body {
-		.left-section,
-		.right-section {
-			flex-basis: 100%;
-			height: auto;
-		}
-	}
-}
 </style>
 </style>

+ 129 - 151
frontend/src/components/modals/Register.vue

@@ -1,164 +1,148 @@
 <template>
 <template>
 	<div>
 	<div>
-		<page-metadata title="Register" v-if="isPage" />
-		<div class="modal is-active">
-			<div class="modal-background" @click="closeRegisterModal()" />
-			<div class="modal-card">
-				<header class="modal-card-head">
-					<p class="modal-card-title">Register</p>
-					<button
-						v-if="!isPage"
-						class="delete"
-						@click="closeRegisterModal()"
+		<modal
+			title="Register"
+			class="register-modal"
+			@closed="closeRegisterModal()"
+		>
+			<template #body>
+				<!-- email address -->
+				<p class="control">
+					<label class="label">Email</label>
+					<input
+						v-model="email.value"
+						class="input"
+						type="email"
+						placeholder="Email..."
+						@keypress="
+							onInput('email') &
+								submitOnEnter(submitModal, $event)
+						"
+						@paste="onInput('email')"
+						autofocus
 					/>
 					/>
-				</header>
-				<section class="modal-card-body">
-					<!-- email address -->
-					<p class="control">
-						<label class="label">Email</label>
-						<input
-							v-model="email.value"
-							class="input"
-							type="email"
-							placeholder="Email..."
-							@keypress="
-								onInput('email') &
-									submitOnEnter(submitModal, $event)
-							"
-							@paste="onInput('email')"
-							autofocus
-						/>
-					</p>
-					<transition name="fadein-helpbox">
-						<input-help-box
-							:entered="email.entered"
-							:valid="email.valid"
-							:message="email.message"
-						/>
-					</transition>
+				</p>
+				<transition name="fadein-helpbox">
+					<input-help-box
+						:entered="email.entered"
+						:valid="email.valid"
+						:message="email.message"
+					/>
+				</transition>
 
 
-					<!-- username -->
-					<p class="control">
-						<label class="label">Username</label>
-						<input
-							v-model="username.value"
-							class="input"
-							type="text"
-							placeholder="Username..."
-							@keypress="
-								onInput('username') &
-									submitOnEnter(submitModal, $event)
-							"
-							@paste="onInput('username')"
-						/>
-					</p>
-					<transition name="fadein-helpbox">
-						<input-help-box
-							:entered="username.entered"
-							:valid="username.valid"
-							:message="username.message"
-						/>
-					</transition>
+				<!-- username -->
+				<p class="control">
+					<label class="label">Username</label>
+					<input
+						v-model="username.value"
+						class="input"
+						type="text"
+						placeholder="Username..."
+						@keypress="
+							onInput('username') &
+								submitOnEnter(submitModal, $event)
+						"
+						@paste="onInput('username')"
+					/>
+				</p>
+				<transition name="fadein-helpbox">
+					<input-help-box
+						:entered="username.entered"
+						:valid="username.valid"
+						:message="username.message"
+					/>
+				</transition>
 
 
-					<!-- password -->
-					<p class="control">
-						<label class="label">Password</label>
-					</p>
+				<!-- password -->
+				<p class="control">
+					<label class="label">Password</label>
+				</p>
 
 
-					<div id="password-visibility-container">
-						<input
-							v-model="password.value"
-							class="input"
-							type="password"
-							ref="password"
-							placeholder="Password..."
-							@keypress="
-								onInput('password') &
-									submitOnEnter(submitModal, $event)
-							"
-							@paste="onInput('password')"
-						/>
-						<a @click="togglePasswordVisibility()">
-							<i class="material-icons">
-								{{
-									!password.visible
-										? "visibility"
-										: "visibility_off"
-								}}
-							</i>
-						</a>
-					</div>
+				<div id="password-visibility-container">
+					<input
+						v-model="password.value"
+						class="input"
+						type="password"
+						ref="password"
+						placeholder="Password..."
+						@keypress="
+							onInput('password') &
+								submitOnEnter(submitModal, $event)
+						"
+						@paste="onInput('password')"
+					/>
+					<a @click="togglePasswordVisibility()">
+						<i class="material-icons">
+							{{
+								!password.visible
+									? "visibility"
+									: "visibility_off"
+							}}
+						</i>
+					</a>
+				</div>
 
 
-					<transition name="fadein-helpbox">
-						<input-help-box
-							:valid="password.valid"
-							:entered="password.entered"
-							:message="password.message"
-						/>
-					</transition>
+				<transition name="fadein-helpbox">
+					<input-help-box
+						:valid="password.valid"
+						:entered="password.entered"
+						:message="password.message"
+					/>
+				</transition>
 
 
-					<br />
+				<br />
 
 
-					<p>
-						By registering you agree to our
-						<router-link to="/terms" @click="closeRegisterModal()">
-							Terms of Service
-						</router-link>
-						and
-						<router-link
-							to="/privacy"
-							@click="closeRegisterModal()"
-						>
-							Privacy Policy</router-link
-						>.
-					</p>
-				</section>
-				<footer class="modal-card-foot">
-					<div id="actions">
-						<button
-							class="button is-primary"
-							@click="submitModal()"
-						>
-							Register
-						</button>
-						<a
-							class="button is-github"
-							:href="apiDomain + '/auth/github/authorize'"
-							@click="githubRedirect()"
-						>
-							<div class="icon">
-								<img
-									class="invert"
-									src="/assets/social/github.svg"
-								/>
-							</div>
-							&nbsp;&nbsp;Register with GitHub
-						</a>
-					</div>
+				<p>
+					By registering you agree to our
+					<router-link to="/terms" @click="closeRegisterModal()">
+						Terms of Service
+					</router-link>
+					and
+					<router-link to="/privacy" @click="closeRegisterModal()">
+						Privacy Policy</router-link
+					>.
+				</p>
+			</template>
+			<template #footer>
+				<div id="actions">
+					<button class="button is-primary" @click="submitModal()">
+						Register
+					</button>
+					<a
+						class="button is-github"
+						:href="apiDomain + '/auth/github/authorize'"
+						@click="githubRedirect()"
+					>
+						<div class="icon">
+							<img
+								class="invert"
+								src="/assets/social/github.svg"
+							/>
+						</div>
+						&nbsp;&nbsp;Register with GitHub
+					</a>
+				</div>
 
 
-					<p class="content-box-optional-helper">
-						<router-link to="/login" v-if="isPage">
-							Already have an account?
-						</router-link>
-						<a v-else @click="changeToLoginModal()">
-							Already have an account?
-						</a>
-					</p>
-				</footer>
-			</div>
-		</div>
+				<p class="content-box-optional-helper">
+					<a @click="changeToLoginModal()">
+						Already have an account?
+					</a>
+				</p>
+			</template>
+		</modal>
 	</div>
 	</div>
 </template>
 </template>
 
 
 <script>
 <script>
 import { mapActions } from "vuex";
 import { mapActions } from "vuex";
 import Toast from "toasters";
 import Toast from "toasters";
+import Modal from "../Modal.vue";
 
 
 import validation from "@/validation";
 import validation from "@/validation";
 import InputHelpBox from "../InputHelpBox.vue";
 import InputHelpBox from "../InputHelpBox.vue";
 
 
 export default {
 export default {
-	components: { InputHelpBox },
+	components: { Modal, InputHelpBox },
 	data() {
 	data() {
 		return {
 		return {
 			username: {
 			username: {
@@ -186,8 +170,7 @@ export default {
 				token: "",
 				token: "",
 				enabled: false
 				enabled: false
 			},
 			},
-			apiDomain: "",
-			isPage: false
+			apiDomain: ""
 		};
 		};
 	},
 	},
 	watch: {
 	watch: {
@@ -244,8 +227,6 @@ export default {
 		}
 		}
 	},
 	},
 	async mounted() {
 	async mounted() {
-		if (this.$route.path === "/register") this.isPage = true;
-
 		this.apiDomain = await lofig.get("backend.apiDomain");
 		this.apiDomain = await lofig.get("backend.apiDomain");
 
 
 		lofig.get("recaptcha").then(obj => {
 		lofig.get("recaptcha").then(obj => {
@@ -286,13 +267,11 @@ export default {
 			}
 			}
 		},
 		},
 		changeToLoginModal() {
 		changeToLoginModal() {
-			if (!this.isPage) {
-				this.closeRegisterModal();
-				this.openModal("login");
-			}
+			this.closeRegisterModal();
+			this.openModal("login");
 		},
 		},
 		closeRegisterModal() {
 		closeRegisterModal() {
-			if (!this.isPage) this.closeModal("register");
+			this.closeModal("register");
 		},
 		},
 		submitModal() {
 		submitModal() {
 			if (
 			if (
@@ -317,8 +296,7 @@ export default {
 			this[inputName].entered = true;
 			this[inputName].entered = true;
 		},
 		},
 		githubRedirect() {
 		githubRedirect() {
-			if (!this.isPage)
-				localStorage.setItem("github_redirect", this.$route.path);
+			localStorage.setItem("github_redirect", this.$route.path);
 		},
 		},
 		...mapActions("modalVisibility", ["closeModal", "openModal"]),
 		...mapActions("modalVisibility", ["closeModal", "openModal"]),
 		...mapActions("user/auth", ["register"])
 		...mapActions("user/auth", ["register"])

+ 1 - 1
frontend/src/components/modals/ViewReport.vue

@@ -233,7 +233,7 @@ export default {
 		background-color: var(--dark-grey-2) !important;
 		background-color: var(--dark-grey-2) !important;
 
 
 		.report-sub-item {
 		.report-sub-item {
-			border: 0.5px solid #fff !important;
+			border: 0.5px solid var(--white) !important;
 		}
 		}
 	}
 	}
 }
 }

+ 6 - 0
frontend/src/components/modals/WhatIsNew.vue

@@ -101,6 +101,12 @@ export default {
 };
 };
 </script>
 </script>
 
 
+<style lang="scss">
+.what-is-news-modal .modal-card .modal-card-foot {
+	column-gap: 0;
+}
+</style>
+
 <style lang="scss" scoped>
 <style lang="scss" scoped>
 .night-mode {
 .night-mode {
 	.modal-card,
 	.modal-card,

+ 10 - 14
frontend/src/main.js

@@ -87,6 +87,16 @@ const router = createRouter({
 			path: "/",
 			path: "/",
 			component: () => import("@/pages/Home.vue")
 			component: () => import("@/pages/Home.vue")
 		},
 		},
+		{
+			path: "/login",
+			name: "login",
+			redirect: "/"
+		},
+		{
+			path: "/register",
+			name: "register",
+			redirect: "/"
+		},
 		{
 		{
 			path: "/404",
 			path: "/404",
 			alias: ["/:pathMatch(.*)*"],
 			alias: ["/:pathMatch(.*)*"],
@@ -136,20 +146,6 @@ const router = createRouter({
 				loginRequired: true
 				loginRequired: true
 			}
 			}
 		},
 		},
-		{
-			path: "/login",
-			component: () => import("@/components/modals/Login.vue"),
-			meta: {
-				guestsOnly: true
-			}
-		},
-		{
-			path: "/register",
-			component: () => import("@/components/modals/Register.vue"),
-			meta: {
-				guestsOnly: true
-			}
-		},
 		{
 		{
 			path: "/admin",
 			path: "/admin",
 			component: () => import("@/pages/Admin/index.vue"),
 			component: () => import("@/pages/Admin/index.vue"),

+ 52 - 44
frontend/src/pages/About.vue

@@ -5,57 +5,51 @@
 		<div class="container">
 		<div class="container">
 			<div class="content-wrapper">
 			<div class="content-wrapper">
 				<h1 class="has-text-centered page-title">About</h1>
 				<h1 class="has-text-centered page-title">About</h1>
-				<div class="card is-fullwidth">
+				<div class="card">
 					<header class="card-header">
 					<header class="card-header">
-						<p class="card-header-title">The project</p>
+						<p>The project</p>
 					</header>
 					</header>
 					<div class="card-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.
-							</p>
-						</div>
+						<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.
+						</p>
 					</div>
 					</div>
 				</div>
 				</div>
-				<div class="card is-fullwidth">
+				<div class="card">
 					<header class="card-header">
 					<header class="card-header">
-						<p class="card-header-title">How you can help</p>
+						<p>How you can help</p>
 					</header>
 					</header>
 					<div class="card-content">
 					<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.
-									</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.
-									</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.
-									</li>
-								</ol>
-							</span>
-						</div>
+						<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.
+								</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.
+								</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.
+								</li>
+							</ol>
+						</span>
 					</div>
 					</div>
 				</div>
 				</div>
 			</div>
 			</div>
@@ -86,6 +80,20 @@ export default {
 }
 }
 
 
 .card {
 .card {
-	margin-top: 50px;
+	display: flex;
+	flex-grow: 1;
+	flex-direction: column;
+	padding: 20px;
+	margin: 10px 10px 50px 10px;
+	border-radius: 5px;
+	overflow: hidden;
+	background-color: var(--white);
+	color: var(--dark-grey);
+	box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.1);
+
+	.card-header {
+		font-weight: 700;
+		padding-bottom: 10px;
+	}
 }
 }
 </style>
 </style>

+ 45 - 0
frontend/src/pages/Admin/index.vue

@@ -265,6 +265,19 @@ export default {
 	}
 	}
 }
 }
 
 
+/deep/ .container {
+	position: relative;
+}
+
+/deep/ .box {
+	box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.1);
+	display: block;
+
+	&:not(:last-child) {
+		margin-bottom: 20px;
+	}
+}
+
 .main-container {
 .main-container {
 	height: auto;
 	height: auto;
 	.admin-container {
 	.admin-container {
@@ -277,6 +290,22 @@ export default {
 	padding-top: 10px;
 	padding-top: 10px;
 	margin-top: -10px;
 	margin-top: -10px;
 	background-color: var(--white);
 	background-color: var(--white);
+	display: flex;
+	line-height: 24px;
+	overflow-y: hidden;
+	overflow-x: auto;
+	margin-bottom: 20px;
+	user-select: none;
+
+	ul {
+		display: flex;
+		align-items: center;
+		/* -webkit-box-flex: 1; */
+		flex-grow: 1;
+		flex-shrink: 0;
+		justify-content: center;
+	}
+
 	.unverifiedsongs {
 	.unverifiedsongs {
 		color: var(--teal);
 		color: var(--teal);
 		border-color: var(--teal);
 		border-color: var(--teal);
@@ -321,6 +350,9 @@ export default {
 		transition: all 0.2s ease-in-out;
 		transition: all 0.2s ease-in-out;
 		font-weight: 500;
 		font-weight: 500;
 		border-bottom: solid 0px;
 		border-bottom: solid 0px;
+		padding: 6px 12px;
+		display: flex;
+		margin-bottom: -1px;
 	}
 	}
 	.tab:hover {
 	.tab:hover {
 		border-width: 3px;
 		border-width: 3px;
@@ -332,4 +364,17 @@ export default {
 		border-width: 3px;
 		border-width: 3px;
 	}
 	}
 }
 }
+
+@media screen and (min-width: 980px) {
+	/deep/ .container {
+		margin: 0 auto;
+		max-width: 960px;
+	}
+}
+
+@media screen and (min-width: 1180px) {
+	/deep/ .container {
+		max-width: 1200px;
+	}
+}
 </style>
 </style>

+ 52 - 36
frontend/src/pages/Admin/tabs/Playlists.vue

@@ -3,48 +3,62 @@
 		<page-metadata title="Admin | Playlists" />
 		<page-metadata title="Admin | Playlists" />
 		<div class="container">
 		<div class="container">
 			<div class="button-row">
 			<div class="button-row">
-				<button
-					class="button is-primary"
-					@click="deleteOrphanedStationPlaylists()"
+				<confirm
+					placement="bottom"
+					@confirm="deleteOrphanedStationPlaylists()"
 				>
 				>
-					Delete orphaned station playlists
-				</button>
-				<button
-					class="button is-primary"
-					@click="deleteOrphanedGenrePlaylists()"
+					<button class="button is-danger">
+						Delete orphaned station playlists
+					</button>
+				</confirm>
+				<confirm
+					placement="bottom"
+					@confirm="deleteOrphanedGenrePlaylists()"
 				>
 				>
-					Delete orphaned genre playlists
-				</button>
-				<button
-					class="button is-primary"
-					@click="deleteOrphanedArtistPlaylists()"
+					<button class="button is-danger">
+						Delete orphaned genre playlists
+					</button>
+				</confirm>
+				<confirm
+					placement="bottom"
+					@confirm="deleteOrphanedArtistPlaylists()"
 				>
 				>
-					Delete orphaned artist playlists
-				</button>
-				<button
-					class="button is-primary"
-					@click="requestOrphanedPlaylistSongs()"
+					<button class="button is-danger">
+						Delete orphaned artist playlists
+					</button>
+				</confirm>
+				<confirm
+					placement="bottom"
+					@confirm="requestOrphanedPlaylistSongs()"
 				>
 				>
-					Request orphaned playlist songs
-				</button>
-				<button
-					class="button is-primary"
-					@click="clearAndRefillAllStationPlaylists()"
+					<button class="button is-danger">
+						Request orphaned playlist songs
+					</button>
+				</confirm>
+				<confirm
+					placement="bottom"
+					@confirm="clearAndRefillAllStationPlaylists()"
 				>
 				>
-					Clear and refill all station playlists
-				</button>
-				<button
-					class="button is-primary"
-					@click="clearAndRefillAllGenrePlaylists()"
+					<button class="button is-danger">
+						Clear and refill all station playlists
+					</button>
+				</confirm>
+				<confirm
+					placement="bottom"
+					@confirm="clearAndRefillAllGenrePlaylists()"
 				>
 				>
-					Clear and refill all genre playlists
-				</button>
-				<button
-					class="button is-primary"
-					@click="clearAndRefillAllArtistPlaylists()"
+					<button class="button is-danger">
+						Clear and refill all genre playlists
+					</button>
+				</confirm>
+				<confirm
+					placement="bottom"
+					@confirm="clearAndRefillAllArtistPlaylists()"
 				>
 				>
-					Clear and refill all artist playlists
-				</button>
+					<button class="button is-danger">
+						Clear and refill all artist playlists
+					</button>
+				</confirm>
 			</div>
 			</div>
 			<table class="table is-striped">
 			<table class="table is-striped">
 				<thead>
 				<thead>
@@ -106,6 +120,7 @@ import { mapState, mapActions, mapGetters } from "vuex";
 import { defineAsyncComponent } from "vue";
 import { defineAsyncComponent } from "vue";
 
 
 import Toast from "toasters";
 import Toast from "toasters";
+import Confirm from "@/components/Confirm.vue";
 
 
 import UserIdToUsername from "@/components/UserIdToUsername.vue";
 import UserIdToUsername from "@/components/UserIdToUsername.vue";
 
 
@@ -123,7 +138,8 @@ export default {
 		),
 		),
 		EditSong: defineAsyncComponent(() =>
 		EditSong: defineAsyncComponent(() =>
 			import("@/components/modals/EditSong")
 			import("@/components/modals/EditSong")
-		)
+		),
+		Confirm
 	},
 	},
 	data() {
 	data() {
 		return {
 		return {

+ 55 - 40
frontend/src/pages/Admin/tabs/Punishments.vue

@@ -57,46 +57,44 @@
 					</tr>
 					</tr>
 				</tbody>
 				</tbody>
 			</table>
 			</table>
-			<div class="card is-fullwidth">
+			<div class="card">
 				<header class="card-header">
 				<header class="card-header">
-					<p class="card-header-title">Ban an IP</p>
+					<p>Ban an IP</p>
 				</header>
 				</header>
 				<div class="card-content">
 				<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>
+					<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>
+					<button class="button is-primary" @click="banIP()">
+						Ban IP
+					</button>
 				</div>
 				</div>
-				<footer class="card-footer">
-					<a class="card-footer-item" @click="banIP()">Ban IP</a>
-				</footer>
 			</div>
 			</div>
 		</div>
 		</div>
 		<view-punishment
 		<view-punishment
@@ -209,10 +207,6 @@ export default {
 	.card {
 	.card {
 		background: var(--dark-grey-3);
 		background: var(--dark-grey-3);
 
 
-		.card-header {
-			box-shadow: 0 1px 2px rgba(10, 10, 10, 0.8);
-		}
-
 		p,
 		p,
 		.label {
 		.label {
 			color: var(--light-grey-2);
 			color: var(--light-grey-2);
@@ -220,6 +214,27 @@ export default {
 	}
 	}
 }
 }
 
 
+.card {
+	display: flex;
+	flex-grow: 1;
+	flex-direction: column;
+	padding: 20px;
+	margin: 10px 0;
+	border-radius: 5px;
+	background-color: var(--white);
+	color: var(--dark-grey);
+	box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.1);
+
+	.card-header {
+		font-weight: 700;
+		padding-bottom: 10px;
+	}
+
+	.button.is-primary {
+		width: 100%;
+	}
+}
+
 td {
 td {
 	vertical-align: middle;
 	vertical-align: middle;
 }
 }

+ 12 - 210
frontend/src/pages/Admin/tabs/Stations.vue

@@ -5,10 +5,15 @@
 			<div class="button-row">
 			<div class="button-row">
 				<button
 				<button
 					class="button is-primary"
 					class="button is-primary"
-					@click="clearEveryStationQueue()"
+					@click="openModal('createStation')"
 				>
 				>
-					Clear every station queue
+					Create Station
 				</button>
 				</button>
+				<confirm placement="bottom" @confirm="clearEveryStationQueue()">
+					<button class="button is-danger">
+						Clear every station queue
+					</button>
+				</confirm>
 			</div>
 			</div>
 			<table class="table is-striped">
 			<table class="table is-striped">
 				<thead>
 				<thead>
@@ -72,111 +77,6 @@
 				</tbody>
 				</tbody>
 			</table>
 			</table>
 		</div>
 		</div>
-		<div class="container">
-			<div class="card is-fullwidth">
-				<header class="card-header">
-					<p class="card-header-title">Create official station</p>
-				</header>
-				<div class="card-content">
-					<div class="content">
-						<div class="control is-horizontal">
-							<div class="control is-grouped">
-								<p class="control is-expanded">
-									<input
-										v-model="newStation.name"
-										class="input"
-										type="text"
-										placeholder="Name"
-									/>
-								</p>
-								<p class="control is-expanded">
-									<input
-										v-model="newStation.displayName"
-										class="input"
-										type="text"
-										placeholder="Display Name"
-									/>
-								</p>
-							</div>
-						</div>
-						<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
-										ref="new-genre"
-										class="input"
-										type="text"
-										placeholder="Genre"
-										@keyup.enter="addGenre()"
-									/>
-									<button
-										class="button is-info"
-										@click="addGenre()"
-									>
-										Add genre
-									</button>
-								</p>
-								<span
-									v-for="(genre, index) in newStation.genres"
-									:key="genre"
-									class="tag is-info"
-								>
-									{{ genre }}
-									<button
-										class="delete is-info"
-										@click="removeGenre(index)"
-									/>
-								</span>
-							</div>
-							<div class="sector">
-								<p class="control has-addons">
-									<input
-										ref="new-blacklisted-genre"
-										class="input"
-										type="text"
-										placeholder="Blacklisted Genre"
-										@keyup.enter="addBlacklistedGenre()"
-									/>
-									<button
-										class="button is-info"
-										@click="addBlacklistedGenre()"
-									>
-										Add blacklisted genre
-									</button>
-								</p>
-								<span
-									v-for="(
-										genre, index
-									) in newStation.blacklistedGenres"
-									:key="genre"
-									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" @click="createStation()"
-						>Create</a
-					>
-				</footer>
-			</div>
-		</div>
 
 
 		<request-song v-if="modals.requestSong" />
 		<request-song v-if="modals.requestSong" />
 		<edit-playlist v-if="modals.editPlaylist" />
 		<edit-playlist v-if="modals.editPlaylist" />
@@ -188,6 +88,7 @@
 		/>
 		/>
 		<edit-song v-if="modals.editSong" song-type="songs" sector="admin" />
 		<edit-song v-if="modals.editSong" song-type="songs" sector="admin" />
 		<report v-if="modals.report" />
 		<report v-if="modals.report" />
+		<create-station v-if="modals.createStation" :official="true" />
 	</div>
 	</div>
 </template>
 </template>
 
 
@@ -220,16 +121,15 @@ export default {
 		EditSong: defineAsyncComponent(() =>
 		EditSong: defineAsyncComponent(() =>
 			import("@/components/modals/EditSong")
 			import("@/components/modals/EditSong")
 		),
 		),
+		CreateStation: defineAsyncComponent(() =>
+			import("@/components/modals/CreateStation.vue")
+		),
 		UserIdToUsername,
 		UserIdToUsername,
 		Confirm
 		Confirm
 	},
 	},
 	data() {
 	data() {
 		return {
 		return {
-			editingStationId: "",
-			newStation: {
-				genres: [],
-				blacklistedGenres: []
-			}
+			editingStationId: ""
 		};
 		};
 	},
 	},
 	computed: {
 	computed: {
@@ -255,44 +155,6 @@ export default {
 		);
 		);
 	},
 	},
 	methods: {
 	methods: {
-		createStation() {
-			const {
-				newStation: {
-					name,
-					displayName,
-					description,
-					genres,
-					blacklistedGenres
-				}
-			} = this;
-
-			if (name === undefined)
-				return new Toast("Field (Name) cannot be empty");
-			if (displayName === undefined)
-				return new Toast("Field (Display Name) cannot be empty");
-			if (description === undefined)
-				return new Toast("Field (Description) cannot be empty");
-
-			return this.socket.dispatch(
-				"stations.create",
-				{
-					name,
-					type: "official",
-					displayName,
-					description,
-					genres,
-					blacklistedGenres
-				},
-				res => {
-					new Toast(res.message);
-					if (res.status === "success")
-						this.newStation = {
-							genres: [],
-							blacklistedGenres: []
-						};
-				}
-			);
-		},
 		removeStation(index) {
 		removeStation(index) {
 			this.socket.dispatch(
 			this.socket.dispatch(
 				"stations.remove",
 				"stations.remove",
@@ -304,37 +166,6 @@ export default {
 			this.editingStationId = station._id;
 			this.editingStationId = station._id;
 			this.openModal("manageStation");
 			this.openModal("manageStation");
 		},
 		},
-		addGenre() {
-			const genre = this.$refs["new-genre"].value.toLowerCase().trim();
-			if (this.newStation.genres.indexOf(genre) !== -1)
-				return new Toast("Genre already exists");
-			if (genre) {
-				this.newStation.genres.push(genre);
-				this.$refs["new-genre"].value = "";
-				return true;
-			}
-			return new Toast("Genre cannot be empty");
-		},
-		removeGenre(index) {
-			this.newStation.genres.splice(index, 1);
-		},
-		addBlacklistedGenre() {
-			const genre = this.$refs["new-blacklisted-genre"].value
-				.toLowerCase()
-				.trim();
-			if (this.newStation.blacklistedGenres.indexOf(genre) !== -1)
-				return new Toast("Genre already exists");
-
-			if (genre) {
-				this.newStation.blacklistedGenres.push(genre);
-				this.$refs["new-blacklisted-genre"].value = "";
-				return true;
-			}
-			return new Toast("Genre cannot be empty");
-		},
-		removeBlacklistedGenre(index) {
-			this.newStation.blacklistedGenres.splice(index, 1);
-		},
 		clearEveryStationQueue() {
 		clearEveryStationQueue() {
 			this.socket.dispatch("stations.clearEveryStationQueue", res => {
 			this.socket.dispatch("stations.clearEveryStationQueue", res => {
 				if (res.status === "success") new Toast(res.message);
 				if (res.status === "success") new Toast(res.message);
@@ -385,26 +216,6 @@ export default {
 			color: var(--light-grey-2);
 			color: var(--light-grey-2);
 		}
 		}
 	}
 	}
-
-	.card {
-		background: var(--dark-grey-3);
-
-		.card-header {
-			box-shadow: 0 1px 2px rgba(10, 10, 10, 0.8);
-		}
-
-		p,
-		.label {
-			color: var(--light-grey-2);
-		}
-	}
-}
-
-.tag {
-	margin-top: 5px;
-	&:not(:last-child) {
-		margin-right: 5px;
-	}
 }
 }
 
 
 td {
 td {
@@ -420,13 +231,4 @@ td {
 .is-info:focus {
 .is-info:focus {
 	background-color: var(--primary-color);
 	background-color: var(--primary-color);
 }
 }
-
-.genre-wrapper {
-	display: flex;
-	justify-content: space-around;
-}
-
-.card-footer-item {
-	color: var(--primary-color);
-}
 </style>
 </style>

+ 151 - 180
frontend/src/pages/Admin/tabs/Statistics.vue

@@ -1,210 +1,164 @@
 <template>
 <template>
 	<div class="container">
 	<div class="container">
 		<page-metadata title="Admin | Statistics" />
 		<page-metadata title="Admin | Statistics" />
-		<div class="columns">
-			<div
-				class="
-					card
-					column
-					is-10-desktop is-offset-1-desktop is-12-mobile
-				"
-			>
-				<header class="card-header">
-					<p class="card-header-title">Average Logs</p>
-				</header>
-				<div class="card-content">
-					<div class="content">
-						<table class="table">
-							<thead>
-								<tr>
-									<th>Name</th>
-									<th>Status</th>
-									<th>Stage</th>
-									<th>Jobs in queue</th>
-									<th>Jobs in progress</th>
-									<th>Jobs paused</th>
-									<th>Concurrency</th>
-								</tr>
-							</thead>
-							<tbody>
-								<tr
-									v-for="moduleItem in modules"
-									:key="moduleItem.name"
+		<div class="card">
+			<header class="card-header">
+				<p>Average Logs</p>
+			</header>
+			<div class="card-content">
+				<table class="table">
+					<thead>
+						<tr>
+							<th>Name</th>
+							<th>Status</th>
+							<th>Stage</th>
+							<th>Jobs in queue</th>
+							<th>Jobs in progress</th>
+							<th>Jobs paused</th>
+							<th>Concurrency</th>
+						</tr>
+					</thead>
+					<tbody>
+						<tr
+							v-for="moduleItem in modules"
+							:key="moduleItem.name"
+						>
+							<td>
+								<router-link
+									:to="'?moduleName=' + moduleItem.name"
+									>{{ moduleItem.name }}</router-link
 								>
 								>
-									<td>
-										<router-link
-											:to="
-												'?moduleName=' + moduleItem.name
-											"
-											>{{ moduleItem.name }}</router-link
-										>
-									</td>
-									<td>{{ moduleItem.status }}</td>
-									<td>{{ moduleItem.stage }}</td>
-									<td>{{ moduleItem.jobsInQueue }}</td>
-									<td>{{ moduleItem.jobsInProgress }}</td>
-									<td>{{ moduleItem.jobsPaused }}</td>
-									<td>{{ moduleItem.concurrency }}</td>
-								</tr>
-							</tbody>
-						</table>
-					</div>
-				</div>
+							</td>
+							<td>{{ moduleItem.status }}</td>
+							<td>{{ moduleItem.stage }}</td>
+							<td>{{ moduleItem.jobsInQueue }}</td>
+							<td>{{ moduleItem.jobsInProgress }}</td>
+							<td>{{ moduleItem.jobsPaused }}</td>
+							<td>{{ moduleItem.concurrency }}</td>
+						</tr>
+					</tbody>
+				</table>
 			</div>
 			</div>
 		</div>
 		</div>
 		<br />
 		<br />
-		<div class="columns" v-if="module">
-			<div
-				class="
-					card
-					column
-					is-10-desktop is-offset-1-desktop is-12-mobile
-				"
-			>
+		<div v-if="module">
+			<div class="card">
 				<header class="card-header">
 				<header class="card-header">
-					<p class="card-header-title">Running tasks</p>
+					<p>Running tasks</p>
 				</header>
 				</header>
 				<div class="card-content">
 				<div class="card-content">
-					<div class="content">
-						<table class="table">
-							<thead>
-								<tr>
-									<th>Name</th>
-									<th>Payload</th>
-								</tr>
-							</thead>
-							<tbody>
-								<tr
-									v-for="job in module.runningTasks"
-									:key="JSON.stringify(job)"
-								>
-									<td>{{ job.name }}</td>
-									<td>
-										{{ JSON.stringify(job.payload) }}
-									</td>
-								</tr>
-							</tbody>
-						</table>
-					</div>
+					<table class="table">
+						<thead>
+							<tr>
+								<th>Name</th>
+								<th>Payload</th>
+							</tr>
+						</thead>
+						<tbody>
+							<tr
+								v-for="job in module.runningTasks"
+								:key="JSON.stringify(job)"
+							>
+								<td>{{ job.name }}</td>
+								<td>
+									{{ JSON.stringify(job.payload) }}
+								</td>
+							</tr>
+						</tbody>
+					</table>
 				</div>
 				</div>
 			</div>
 			</div>
-			<div
-				class="
-					card
-					column
-					is-10-desktop is-offset-1-desktop is-12-mobile
-				"
-			>
+			<div class="card">
 				<header class="card-header">
 				<header class="card-header">
-					<p class="card-header-title">Paused tasks</p>
+					<p>Paused tasks</p>
 				</header>
 				</header>
 				<div class="card-content">
 				<div class="card-content">
-					<div class="content">
-						<table class="table">
-							<thead>
-								<tr>
-									<th>Name</th>
-									<th>Payload</th>
-								</tr>
-							</thead>
-							<tbody>
-								<tr
-									v-for="job in module.pausedTasks"
-									:key="JSON.stringify(job)"
-								>
-									<td>{{ job.name }}</td>
-									<td>
-										{{ JSON.stringify(job.payload) }}
-									</td>
-								</tr>
-							</tbody>
-						</table>
-					</div>
+					<table class="table">
+						<thead>
+							<tr>
+								<th>Name</th>
+								<th>Payload</th>
+							</tr>
+						</thead>
+						<tbody>
+							<tr
+								v-for="job in module.pausedTasks"
+								:key="JSON.stringify(job)"
+							>
+								<td>{{ job.name }}</td>
+								<td>
+									{{ JSON.stringify(job.payload) }}
+								</td>
+							</tr>
+						</tbody>
+					</table>
 				</div>
 				</div>
 			</div>
 			</div>
-			<div
-				class="
-					card
-					column
-					is-10-desktop is-offset-1-desktop is-12-mobile
-				"
-			>
+			<div class="card">
 				<header class="card-header">
 				<header class="card-header">
-					<p class="card-header-title">Queued tasks</p>
+					<p>Queued tasks</p>
 				</header>
 				</header>
 				<div class="card-content">
 				<div class="card-content">
-					<div class="content">
-						<table class="table">
-							<thead>
-								<tr>
-									<th>Name</th>
-									<th>Payload</th>
-								</tr>
-							</thead>
-							<tbody>
-								<tr
-									v-for="job in module.queuedTasks"
-									:key="JSON.stringify(job)"
-								>
-									<td>{{ job.name }}</td>
-									<td>
-										{{ JSON.stringify(job.payload) }}
-									</td>
-								</tr>
-							</tbody>
-						</table>
-					</div>
+					<table class="table">
+						<thead>
+							<tr>
+								<th>Name</th>
+								<th>Payload</th>
+							</tr>
+						</thead>
+						<tbody>
+							<tr
+								v-for="job in module.queuedTasks"
+								:key="JSON.stringify(job)"
+							>
+								<td>{{ job.name }}</td>
+								<td>
+									{{ JSON.stringify(job.payload) }}
+								</td>
+							</tr>
+						</tbody>
+					</table>
 				</div>
 				</div>
 			</div>
 			</div>
 		</div>
 		</div>
 		<br />
 		<br />
-		<div class="columns" v-if="module">
-			<div
-				class="
-					card
-					column
-					is-10-desktop is-offset-1-desktop is-12-mobile
-				"
-			>
+		<div v-if="module">
+			<div class="card">
 				<header class="card-header">
 				<header class="card-header">
-					<p class="card-header-title">Average Logs</p>
+					<p>Average Logs</p>
 				</header>
 				</header>
 				<div class="card-content">
 				<div class="card-content">
-					<div class="content">
-						<table class="table">
-							<thead>
-								<tr>
-									<th>Job name</th>
-									<th>Successful</th>
-									<th>Failed</th>
-									<th>Total</th>
-									<th>Average timing</th>
-								</tr>
-							</thead>
-							<tbody>
-								<tr
-									v-for="(
-										job, jobName
-									) in module.jobStatistics"
-									:key="jobName"
-								>
-									<td>{{ jobName }}</td>
-									<td>
-										{{ job.successful }}
-									</td>
-									<td>
-										{{ job.failed }}
-									</td>
-									<td>
-										{{ job.total }}
-									</td>
-									<td>
-										{{ job.averageTiming }}
-									</td>
-								</tr>
-							</tbody>
-						</table>
-					</div>
+					<table class="table">
+						<thead>
+							<tr>
+								<th>Job name</th>
+								<th>Successful</th>
+								<th>Failed</th>
+								<th>Total</th>
+								<th>Average timing</th>
+							</tr>
+						</thead>
+						<tbody>
+							<tr
+								v-for="(job, jobName) in module.jobStatistics"
+								:key="jobName"
+							>
+								<td>{{ jobName }}</td>
+								<td>
+									{{ job.successful }}
+								</td>
+								<td>
+									{{ job.failed }}
+								</td>
+								<td>
+									{{ job.total }}
+								</td>
+								<td>
+									{{ job.averageTiming }}
+								</td>
+							</tr>
+						</tbody>
+					</table>
 				</div>
 				</div>
 			</div>
 			</div>
 		</div>
 		</div>
@@ -305,4 +259,21 @@ td {
 .is-primary:focus {
 .is-primary:focus {
 	background-color: var(--primary-color) !important;
 	background-color: var(--primary-color) !important;
 }
 }
+
+.card {
+	display: flex;
+	flex-grow: 1;
+	flex-direction: column;
+	padding: 20px;
+	margin: 10px;
+	border-radius: 5px;
+	background-color: var(--white);
+	color: var(--dark-grey);
+	box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.1);
+
+	.card-header {
+		font-weight: 700;
+		padding-bottom: 10px;
+	}
+}
 </style>
 </style>

+ 46 - 29
frontend/src/pages/Home.vue

@@ -58,7 +58,6 @@
 								params: { id: element.name }
 								params: { id: element.name }
 							}"
 							}"
 							:class="{
 							:class="{
-								card: true,
 								'station-card': true,
 								'station-card': true,
 								'item-draggable': true,
 								'item-draggable': true,
 								isPrivate: element.privacy === 'private',
 								isPrivate: element.privacy === 'private',
@@ -68,10 +67,7 @@
 								'--primary-color: var(--' + element.theme + ')'
 								'--primary-color: var(--' + element.theme + ')'
 							"
 							"
 						>
 						>
-							<song-thumbnail
-								class="card-image"
-								:song="element.currentSong"
-							/>
+							<song-thumbnail :song="element.currentSong" />
 							<div class="card-content">
 							<div class="card-content">
 								<div class="media">
 								<div class="media">
 									<div class="media-left displayName">
 									<div class="media-left displayName">
@@ -238,11 +234,11 @@
 				</div>
 				</div>
 				<a
 				<a
 					v-if="loggedIn"
 					v-if="loggedIn"
-					@click="openModal('createCommunityStation')"
-					class="card station-card createStation"
+					@click="openModal('createStation')"
+					class="station-card createStation"
 				>
 				>
-					<div class="card-image">
-						<figure class="image is-square">
+					<div class="thumbnail">
+						<figure class="image">
 							<i class="material-icons">radio</i>
 							<i class="material-icons">radio</i>
 						</figure>
 						</figure>
 					</div>
 					</div>
@@ -262,10 +258,10 @@
 				<a
 				<a
 					v-else
 					v-else
 					@click="openModal('login')"
 					@click="openModal('login')"
-					class="card station-card createStation"
+					class="station-card createStation"
 				>
 				>
-					<div class="card-image">
-						<figure class="image is-square">
+					<div class="thumbnail">
+						<figure class="image">
 							<i class="material-icons">radio</i>
 							<i class="material-icons">radio</i>
 						</figure>
 						</figure>
 					</div>
 					</div>
@@ -288,17 +284,14 @@
 						name: 'station',
 						name: 'station',
 						params: { id: station.name }
 						params: { id: station.name }
 					}"
 					}"
-					class="card station-card"
+					class="station-card"
 					:class="{
 					:class="{
 						isPrivate: station.privacy === 'private',
 						isPrivate: station.privacy === 'private',
 						isMine: isOwner(station)
 						isMine: isOwner(station)
 					}"
 					}"
 					:style="'--primary-color: var(--' + station.theme + ')'"
 					:style="'--primary-color: var(--' + station.theme + ')'"
 				>
 				>
-					<song-thumbnail
-						class="card-image"
-						:song="station.currentSong"
-					/>
+					<song-thumbnail :song="station.currentSong" />
 					<div class="card-content">
 					<div class="card-content">
 						<div class="media">
 						<div class="media">
 							<div class="media-left displayName">
 							<div class="media-left displayName">
@@ -438,7 +431,7 @@
 			</div>
 			</div>
 			<main-footer />
 			<main-footer />
 		</div>
 		</div>
-		<create-community-station v-if="modals.createCommunityStation" />
+		<create-station v-if="modals.createStation" />
 	</div>
 	</div>
 </template>
 </template>
 
 
@@ -460,8 +453,8 @@ export default {
 		MainHeader,
 		MainHeader,
 		MainFooter,
 		MainFooter,
 		SongThumbnail,
 		SongThumbnail,
-		CreateCommunityStation: defineAsyncComponent(() =>
-			import("@/components/modals/CreateCommunityStation.vue")
+		CreateStation: defineAsyncComponent(() =>
+			import("@/components/modals/CreateStation.vue")
 		),
 		),
 		UserIdToUsername,
 		UserIdToUsername,
 		draggable
 		draggable
@@ -476,7 +469,8 @@ export default {
 				logo_white: "",
 				logo_white: "",
 				sitename: ""
 				sitename: ""
 			},
 			},
-			orderOfFavoriteStations: []
+			orderOfFavoriteStations: [],
+			handledLoginRegisterRedirect: false
 		};
 		};
 	},
 	},
 	computed: {
 	computed: {
@@ -529,6 +523,18 @@ export default {
 	async mounted() {
 	async mounted() {
 		this.siteSettings = await lofig.get("siteSettings");
 		this.siteSettings = await lofig.get("siteSettings");
 
 
+		if (
+			!this.loggedIn &&
+			this.$route.redirectedFrom &&
+			(this.$route.redirectedFrom.name === "login" ||
+				this.$route.redirectedFrom.name === "register") &&
+			!this.handledLoginRegisterRedirect
+		) {
+			// Makes sure the login/register modal isn't opened whenever the home page gets remounted due to a code change
+			this.handledLoginRegisterRedirect = true;
+			this.openModal(this.$route.redirectedFrom.name);
+		}
+
 		ws.onConnect(this.init);
 		ws.onConnect(this.init);
 
 
 		this.socket.on("event:station.created", res => {
 		this.socket.on("event:station.created", res => {
@@ -804,7 +810,7 @@ html {
 			rgba(34, 34, 34, 0.8) 100%
 			rgba(34, 34, 34, 0.8) 100%
 		);
 		);
 	}
 	}
-	.card,
+	.station-card,
 	.card-content,
 	.card-content,
 	.card-content div {
 	.card-content div {
 		background-color: var(--dark-grey-3);
 		background-color: var(--dark-grey-3);
@@ -815,12 +821,12 @@ html {
 		color: var(--light-grey-2);
 		color: var(--light-grey-2);
 	}
 	}
 
 
-	.card-image i {
+	.thumbnail i {
 		user-select: none;
 		user-select: none;
 		-webkit-user-select: none;
 		-webkit-user-select: none;
 	}
 	}
 
 
-	.card-image.thumbnail {
+	.thumbnail {
 		background-color: var(--dark-grey-2);
 		background-color: var(--dark-grey-2);
 	}
 	}
 
 
@@ -1031,6 +1037,9 @@ html {
 
 
 .station-card {
 .station-card {
 	display: inline-flex;
 	display: inline-flex;
+	position: relative;
+	background-color: var(--white);
+	color: var(--dark-grey);
 	flex-direction: row;
 	flex-direction: row;
 	overflow: hidden;
 	overflow: hidden;
 	margin: 10px;
 	margin: 10px;
@@ -1043,6 +1052,8 @@ html {
 	box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.1);
 	box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.1);
 
 
 	.card-content {
 	.card-content {
+		display: flex;
+		position: relative;
 		padding: 10px 10px 10px 15px;
 		padding: 10px 10px 10px 15px;
 		display: flex;
 		display: flex;
 		flex-direction: column;
 		flex-direction: column;
@@ -1108,11 +1119,19 @@ html {
 		}
 		}
 	}
 	}
 
 
-	.card-image.thumbnail {
+	.thumbnail {
+		display: flex;
+		position: relative;
 		min-width: 120px;
 		min-width: 120px;
 		width: 120px;
 		width: 120px;
 		height: 120px;
 		height: 120px;
 		margin: 0;
 		margin: 0;
+
+		.image {
+			display: flex;
+			position: relative;
+			padding-top: 100%;
+		}
 	}
 	}
 
 
 	.bottomBar {
 	.bottomBar {
@@ -1148,10 +1167,8 @@ html {
 	}
 	}
 
 
 	&.createStation {
 	&.createStation {
-		height: auto;
-
-		.card-image {
-			.image.is-square {
+		.thumbnail {
+			.image {
 				width: 120px;
 				width: 120px;
 
 
 				@media screen and (max-width: 330px) {
 				@media screen and (max-width: 330px) {

+ 3 - 1
frontend/src/pages/Station/Sidebar/Playlists.vue

@@ -61,7 +61,9 @@
 							</confirm>
 							</confirm>
 							<confirm
 							<confirm
 								v-if="
 								v-if="
-									isOwnerOrAdmin() && !isExcluded(element._id)
+									station.type === 'community' &&
+									isOwnerOrAdmin() &&
+									!isExcluded(element._id)
 								"
 								"
 								@confirm="blacklistPlaylist(element._id)"
 								@confirm="blacklistPlaylist(element._id)"
 							>
 							>

+ 7 - 0
frontend/src/pages/Team.vue

@@ -327,6 +327,9 @@ h2 {
 
 
 	.card {
 	.card {
 		display: inline-flex;
 		display: inline-flex;
+		position: relative;
+		background-color: var(--white);
+		color: var(--dark-grey);
 		flex-direction: column;
 		flex-direction: column;
 		width: calc(100% - 30px);
 		width: calc(100% - 30px);
 		max-width: 400px;
 		max-width: 400px;
@@ -336,6 +339,8 @@ h2 {
 		box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1),
 		box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1),
 			0 0 0 1px rgba(10, 10, 10, 0.1);
 			0 0 0 1px rgba(10, 10, 10, 0.1);
 		.card-header {
 		.card-header {
+			display: flex;
+			position: relative;
 			line-height: 22.5px;
 			line-height: 22.5px;
 			padding: 10px;
 			padding: 10px;
 			.profile-picture {
 			.profile-picture {
@@ -369,6 +374,8 @@ h2 {
 			display: flex;
 			display: flex;
 			flex-direction: column;
 			flex-direction: column;
 			flex-grow: 1;
 			flex-grow: 1;
+			padding: 20px;
+
 			.bio {
 			.bio {
 				font-size: 16px;
 				font-size: 16px;
 				margin-bottom: 10px;
 				margin-bottom: 10px;

+ 1 - 1
frontend/src/store/modules/modalVisibility.js

@@ -7,7 +7,7 @@ const state = {
 		manageStation: false,
 		manageStation: false,
 		login: false,
 		login: false,
 		register: false,
 		register: false,
-		createCommunityStation: false,
+		createStation: false,
 		requestSong: false,
 		requestSong: false,
 		editPlaylist: false,
 		editPlaylist: false,
 		createPlaylist: false,
 		createPlaylist: false,

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