Explorar o código

Merge branch 'staging'

Owen Diffey %!s(int64=3) %!d(string=hai) anos
pai
achega
ddcfc47751
Modificáronse 55 ficheiros con 2217 adicións e 2211 borrados
  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
      backend/package-lock.json
  7. 1 1
      backend/package.json
  8. 1 1
      frontend/dist/config/template.json
  9. 0 248
      frontend/dist/index.css
  10. 8 5
      frontend/dist/index.tpl.html
  11. 0 0
      frontend/dist/vendor/bulma.0.2.3.min.css
  12. 6 1
      frontend/package-lock.json
  13. 2 1
      frontend/package.json
  14. 487 31
      frontend/src/App.vue
  15. 1 1
      frontend/src/components/ActivityItem.vue
  16. 16 4
      frontend/src/components/FloatingBox.vue
  17. 145 34
      frontend/src/components/Modal.vue
  18. 1 1
      frontend/src/components/ProfilePicture.vue
  19. 1 0
      frontend/src/components/Sidebar.vue
  20. 10 1
      frontend/src/components/layout/MainFooter.vue
  21. 108 19
      frontend/src/components/layout/MainHeader.vue
  22. 16 10
      frontend/src/components/modals/CreateStation.vue
  23. 38 43
      frontend/src/components/modals/EditNews.vue
  24. 188 239
      frontend/src/components/modals/EditPlaylist/index.vue
  25. 19 30
      frontend/src/components/modals/EditSong/index.vue
  26. 92 117
      frontend/src/components/modals/Login.vue
  27. 137 174
      frontend/src/components/modals/ManageStation/index.vue
  28. 129 151
      frontend/src/components/modals/Register.vue
  29. 19 13
      frontend/src/components/modals/Report.vue
  30. 1 1
      frontend/src/components/modals/ViewReport.vue
  31. 7 1
      frontend/src/components/modals/WhatIsNew.vue
  32. 10 14
      frontend/src/main.js
  33. 56 44
      frontend/src/pages/About.vue
  34. 46 0
      frontend/src/pages/Admin/index.vue
  35. 1 1
      frontend/src/pages/Admin/tabs/HiddenSongs.vue
  36. 1 1
      frontend/src/pages/Admin/tabs/News.vue
  37. 39 27
      frontend/src/pages/Admin/tabs/Playlists.vue
  38. 56 41
      frontend/src/pages/Admin/tabs/Punishments.vue
  39. 1 1
      frontend/src/pages/Admin/tabs/Reports.vue
  40. 14 212
      frontend/src/pages/Admin/tabs/Stations.vue
  41. 151 180
      frontend/src/pages/Admin/tabs/Statistics.vue
  42. 1 1
      frontend/src/pages/Admin/tabs/UnverifiedSongs.vue
  43. 6 2
      frontend/src/pages/Admin/tabs/Users.vue
  44. 1 1
      frontend/src/pages/Admin/tabs/VerifiedSongs.vue
  45. 48 30
      frontend/src/pages/Home.vue
  46. 1 1
      frontend/src/pages/News.vue
  47. 1 1
      frontend/src/pages/Profile/Tabs/Playlists.vue
  48. 11 13
      frontend/src/pages/Profile/Tabs/RecentActivity.vue
  49. 1 1
      frontend/src/pages/Profile/index.vue
  50. 242 222
      frontend/src/pages/ResetPassword.vue
  51. 3 1
      frontend/src/pages/Station/Sidebar/Playlists.vue
  52. 1 0
      frontend/src/pages/Station/Sidebar/Users.vue
  53. 1 1
      frontend/src/pages/Station/index.vue
  54. 7 0
      frontend/src/pages/Team.vue
  55. 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.secure` | Should be `true` for SSL connections, and `false` for normal http connections. |
 | `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. |
 | `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`. 

+ 2 - 1
backend/config/template.json

@@ -59,6 +59,7 @@
 		"secure": false,
 		"SIDname": "SID"
 	},
+	"blacklistedStationNames": ["musare"],
 	"skipConfigVersionCheck": false,
 	"skipDbDocumentsVersionCheck": false,
 	"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 config from "config";
 
-const REQUIRED_CONFIG_VERSION = 6;
+const REQUIRED_CONFIG_VERSION = 7;
 
 // eslint-disable-next-line
 Array.prototype.remove = function (item) {

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

@@ -1,5 +1,6 @@
 import async from "async";
 import mongoose from "mongoose";
+import config from "config";
 
 import { isLoginRequired, isOwnerRequired, isAdminRequired } from "./hooks";
 
@@ -456,6 +457,11 @@ CacheModule.runJob("SUB", {
 			args: ["event:station.deleted"]
 		});
 
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `manage-station.${stationId}`,
+			args: ["event:station.deleted"]
+		});
+
 		WSModule.runJob("EMIT_TO_ROOM", {
 			room: `home`,
 			args: ["event:station.deleted", { data: { stationId } }]
@@ -2534,16 +2540,7 @@ export default {
 
 		data.name = data.name.toLowerCase();
 
-		const blacklist = [
-			"country",
-			"edm",
-			"musare",
-			"hip-hop",
-			"rap",
-			"top-hits",
-			"todays-hits",
-			"old-school",
-			"christmas",
+		let blacklist = [
 			"about",
 			"support",
 			"staff",
@@ -2560,7 +2557,6 @@ export default {
 			"p",
 			"official",
 			"o",
-			"trap",
 			"faq",
 			"team",
 			"donate",
@@ -2576,9 +2572,16 @@ export default {
 			"api",
 			"songs",
 			"playlists",
-			"playlist"
+			"playlist",
+			"albums",
+			"artists",
+			"artist",
+			"station"
 		];
 
+		if (data.type === "community" && config.get("blacklistedCommunityStationNames "))
+			blacklist = [...blacklist, ...config.get("blacklistedCommunityStationNames")];
+
 		async.waterfall(
 			[
 				next => {
@@ -2595,242 +2598,84 @@ export default {
 					);
 				},
 
-				// eslint-disable-next-line consistent-return
 				(station, next) => {
 					this.log(station);
 
 					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) => {
 							if (err) return next(err);
 							if (!user) return next("User not found.");
 							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) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 					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
backend/package-lock.json

@@ -1,6 +1,6 @@
 {
   "name": "musare-backend",
-  "version": "3.0.0",
+  "version": "3.1.0",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {

+ 1 - 1
backend/package.json

@@ -1,7 +1,7 @@
 {
   "name": "musare-backend",
   "private": true,
-  "version": "3.0.0",
+  "version": "3.1.0",
   "type": "module",
   "description": "An open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.",
   "main": "index.js",

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

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

+ 0 - 248
frontend/dist/index.css

@@ -1,248 +0,0 @@
-/* inter-regular - latin */
-@font-face {
-	font-family: "Inter";
-	font-style: normal;
-	font-weight: 400;
-	src: url("/fonts/inter-v3-latin-regular.eot"); /* IE9 Compat Modes */
-	src: local(""),
-		url("/fonts/inter-v3-latin-regular.eot?#iefix")
-			format("embedded-opentype"),
-		/* IE6-IE8 */ url("/fonts/inter-v3-latin-regular.woff2") format("woff2"),
-		/* Super Modern Browsers */ url("/fonts/inter-v3-latin-regular.woff")
-			format("woff"),
-		/* Modern Browsers */ url("/fonts/inter-v3-latin-regular.ttf")
-			format("truetype"),
-		/* Safari, Android, iOS */
-			url("/fonts/inter-v3-latin-regular.svg#Inter") format("svg"); /* Legacy iOS */
-}
-
-/* inter-200 - latin */
-@font-face {
-	font-family: "Inter";
-	font-style: normal;
-	font-weight: 200;
-	src: url("/fonts/inter-v3-latin-200.eot"); /* IE9 Compat Modes */
-	src: local(""),
-		url("/fonts/inter-v3-latin-200.eot?#iefix") format("embedded-opentype"),
-		/* IE6-IE8 */ url("/fonts/inter-v3-latin-200.woff2") format("woff2"),
-		/* Super Modern Browsers */ url("/fonts/inter-v3-latin-200.woff")
-			format("woff"),
-		/* Modern Browsers */ url("/fonts/inter-v3-latin-200.ttf")
-			format("truetype"),
-		/* Safari, Android, iOS */ url("/fonts/inter-v3-latin-200.svg#Inter")
-			format("svg"); /* Legacy iOS */
-}
-
-/* inter-800 - latin */
-@font-face {
-	font-family: "Inter";
-	font-style: normal;
-	font-weight: 800;
-	src: url("/fonts/inter-v3-latin-800.eot"); /* IE9 Compat Modes */
-	src: local(""),
-		url("/fonts/inter-v3-latin-800.eot?#iefix") format("embedded-opentype"),
-		/* IE6-IE8 */ url("/fonts/inter-v3-latin-800.woff2") format("woff2"),
-		/* Super Modern Browsers */ url("/fonts/inter-v3-latin-800.woff")
-			format("woff"),
-		/* Modern Browsers */ url("/fonts/inter-v3-latin-800.ttf")
-			format("truetype"),
-		/* Safari, Android, iOS */ url("/fonts/inter-v3-latin-800.svg#Inter")
-			format("svg"); /* Legacy iOS */
-}
-
-/* inter-600 - latin */
-@font-face {
-	font-family: "Inter";
-	font-style: normal;
-	font-weight: 600;
-	src: url("/fonts/inter-v3-latin-600.eot"); /* IE9 Compat Modes */
-	src: local(""),
-		url("/fonts/inter-v3-latin-600.eot?#iefix") format("embedded-opentype"),
-		/* IE6-IE8 */ url("/fonts/inter-v3-latin-600.woff2") format("woff2"),
-		/* Super Modern Browsers */ url("/fonts/inter-v3-latin-600.woff")
-			format("woff"),
-		/* Modern Browsers */ url("/fonts/inter-v3-latin-600.ttf")
-			format("truetype"),
-		/* Safari, Android, iOS */ url("/fonts/inter-v3-latin-600.svg#Inter")
-			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;
-}

+ 8 - 5
frontend/dist/index.tpl.html

@@ -1,13 +1,16 @@
 <!DOCTYPE html>
 <html lang='en'>
+
 <head>
 	<title>Musare</title>
 
 	<meta charset='UTF-8'>
 	<meta http-equiv='X-UA-Compatible' content='IE=edge'>
 	<meta name='viewport' content='width=device-width, initial-scale=1, user-scalable=no'>
-	<meta name='keywords' content='music, musare, listen, station, station, radio, edm, chill, community, official, rooms, room, party, good, mus, pop'>
-	<meta name='description' content='On Musare you can listen to lots of different songs, playing 24/7 in our official stations and in user-made community stations!'>
+	<meta name='keywords'
+		content='music, musare, listen, station, station, radio, edm, chill, community, official, rooms, room, party, good, mus, pop'>
+	<meta name='description'
+		content='On Musare you can listen to lots of different songs, playing 24/7 in our official stations and in user-made community stations!'>
 	<meta name='copyright' content='© Copyright Musare 2015-2021 All Right Reserved'>
 
 	<link rel='apple-touch-icon' sizes='57x57' href='/assets/favicon/apple-touch-icon-57x57.png?v=06042016'>
@@ -32,16 +35,16 @@
 	<meta name='theme-color' content='#03a9f4'>
 	<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 type='text/javascript' src='/vendor/can-autoplay.min.js'></script>
 	<script type='text/javascript' src='/vendor/lofig.1.3.4.min.js'></script>
 </head>
+
 <body>
 	<div id="root"></div>
 	<div id="toasts-container" class="position-right position-bottom">
 		<div id="toasts-content"></div>
 	</div>
 </body>
-</html>
+
+</html>

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
frontend/dist/vendor/bulma.0.2.3.min.css


+ 6 - 1
frontend/package-lock.json

@@ -1,6 +1,6 @@
 {
   "name": "musare-frontend",
-  "version": "3.0.0",
+  "version": "3.1.0",
   "lockfileVersion": 1,
   "requires": true,
   "dependencies": {
@@ -5315,6 +5315,11 @@
       "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
       "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": {
       "version": "4.0.1",
       "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",

+ 2 - 1
frontend/package.json

@@ -5,7 +5,7 @@
     "*.vue"
   ],
   "private": true,
-  "version": "3.0.0",
+  "version": "3.1.0",
   "description": "An open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.",
   "main": "main.js",
   "author": "Musare Team",
@@ -48,6 +48,7 @@
     "eslint-config-airbnb-base": "^14.2.1",
     "html-webpack-plugin": "^5.3.2",
     "marked": "^3.0.7",
+    "normalize.css": "^8.0.1",
     "toasters": "^2.3.1",
     "vue": "^3.2.20",
     "vue-content-loader": "^2.0.0",

+ 487 - 31
frontend/src/App.vue

@@ -252,6 +252,7 @@ export default {
 </script>
 
 <style lang="scss">
+@import "normalize.css/normalize.css";
 @import "tippy.js/dist/tippy.css";
 @import "tippy.js/animations/scale.css";
 
@@ -334,6 +335,133 @@ export default {
 	code {
 		background-color: var(--dark-grey-2) !important;
 	}
+
+	.button.is-dark {
+		background-color: var(--light-grey) !important;
+		color: var(--dark-grey-2) !important;
+	}
+}
+
+/* inter-regular - latin */
+@font-face {
+	font-family: "Inter";
+	font-style: normal;
+	font-weight: 400;
+	src: url("/fonts/inter-v3-latin-regular.eot"); /* IE9 Compat Modes */
+	src: local(""),
+		url("/fonts/inter-v3-latin-regular.eot?#iefix")
+			format("embedded-opentype"),
+		/* IE6-IE8 */ url("/fonts/inter-v3-latin-regular.woff2") format("woff2"),
+		/* Super Modern Browsers */ url("/fonts/inter-v3-latin-regular.woff")
+			format("woff"),
+		/* Modern Browsers */ url("/fonts/inter-v3-latin-regular.ttf")
+			format("truetype"),
+		/* Safari, Android, iOS */
+			url("/fonts/inter-v3-latin-regular.svg#Inter") format("svg"); /* Legacy iOS */
+}
+
+/* inter-200 - latin */
+@font-face {
+	font-family: "Inter";
+	font-style: normal;
+	font-weight: 200;
+	src: url("/fonts/inter-v3-latin-200.eot"); /* IE9 Compat Modes */
+	src: local(""),
+		url("/fonts/inter-v3-latin-200.eot?#iefix") format("embedded-opentype"),
+		/* IE6-IE8 */ url("/fonts/inter-v3-latin-200.woff2") format("woff2"),
+		/* Super Modern Browsers */ url("/fonts/inter-v3-latin-200.woff")
+			format("woff"),
+		/* Modern Browsers */ url("/fonts/inter-v3-latin-200.ttf")
+			format("truetype"),
+		/* Safari, Android, iOS */ url("/fonts/inter-v3-latin-200.svg#Inter")
+			format("svg"); /* Legacy iOS */
+}
+
+/* inter-800 - latin */
+@font-face {
+	font-family: "Inter";
+	font-style: normal;
+	font-weight: 800;
+	src: url("/fonts/inter-v3-latin-800.eot"); /* IE9 Compat Modes */
+	src: local(""),
+		url("/fonts/inter-v3-latin-800.eot?#iefix") format("embedded-opentype"),
+		/* IE6-IE8 */ url("/fonts/inter-v3-latin-800.woff2") format("woff2"),
+		/* Super Modern Browsers */ url("/fonts/inter-v3-latin-800.woff")
+			format("woff"),
+		/* Modern Browsers */ url("/fonts/inter-v3-latin-800.ttf")
+			format("truetype"),
+		/* Safari, Android, iOS */ url("/fonts/inter-v3-latin-800.svg#Inter")
+			format("svg"); /* Legacy iOS */
+}
+
+/* inter-600 - latin */
+@font-face {
+	font-family: "Inter";
+	font-style: normal;
+	font-weight: 600;
+	src: url("/fonts/inter-v3-latin-600.eot"); /* IE9 Compat Modes */
+	src: local(""),
+		url("/fonts/inter-v3-latin-600.eot?#iefix") format("embedded-opentype"),
+		/* IE6-IE8 */ url("/fonts/inter-v3-latin-600.woff2") format("woff2"),
+		/* Super Modern Browsers */ url("/fonts/inter-v3-latin-600.woff")
+			format("woff"),
+		/* Modern Browsers */ url("/fonts/inter-v3-latin-600.ttf")
+			format("truetype"),
+		/* Safari, Android, iOS */ url("/fonts/inter-v3-latin-600.svg#Inter")
+			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 {
@@ -350,34 +478,148 @@ body.night-mode {
 
 	.toast {
 		font-weight: 600;
+		z-index: 10000 !important;
 	}
 }
 
 html {
 	overflow: auto !important;
 	height: 100%;
+	background-color: inherit;
+	font-size: 14px;
 }
 
 body {
 	background-color: var(--light-grey);
 	color: var(--dark-grey);
 	height: 100%;
+	line-height: 1.4285714;
+	font-size: 1rem;
 	font-family: "Inter", Helvetica, 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;
+	flex-direction: column;
+	padding: 10px;
+}
+
+ul {
+	list-style: none;
+	margin: 0;
+	display: block;
+}
+
 h1,
 h2,
 h3,
 h4,
 h5,
-h6,
-.sidebar-title {
+h6 {
 	font-family: "Inter", Helvetica, Arial, sans-serif;
+	font-weight: 400;
+	line-height: 1.1;
+
+	a {
+		font-weight: inherit;
+	}
 }
 
-.modal-card-title {
-	font-weight: 600;
-	font-family: "Inter", Helvetica, 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;
+}
+
+.content {
+	h4 {
+		line-height: 1.125;
+	}
+}
+
+.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,
@@ -388,6 +630,21 @@ textarea {
 	font-family: "Inter", Helvetica, Arial, sans-serif;
 }
 
+input,
+select,
+textarea {
+	outline: none;
+}
+
+.label {
+	display: flex;
+	font-weight: 700;
+
+	&:not(:last-child) {
+		margin-bottom: 5px;
+	}
+}
+
 #page-title {
 	margin-top: 0;
 	font-size: 35px;
@@ -402,6 +659,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 {
 	height: 100%;
 }
@@ -412,23 +683,57 @@ textarea {
 	display: flex;
 	flex-direction: column;
 
+	&.main-container-modal-active {
+		height: 100% !important;
+		overflow: hidden !important;
+	}
+
 	> .container {
+		position: relative;
 		flex: 1 0 auto;
+		margin: 0 auto;
+		max-width: 1200px;
 	}
 }
 
-.main-container.main-container-modal-active {
-	height: 100% !important;
-	overflow: hidden !important;
-}
-
 a {
 	color: var(--primary-color);
 	text-decoration: none;
+	cursor: pointer;
+
+	&:hover,
+	&:focus {
+		filter: brightness(90%);
+	}
+}
+
+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;
+			}
+		}
+	}
 }
 
-.modal-card {
-	margin: 0 !important;
+img {
+	max-width: 100%;
 }
 
 .absolute-a {
@@ -744,7 +1049,7 @@ a {
 					left: 0;
 					right: 0;
 					bottom: 0;
-					background-color: #ccc;
+					background-color: var(--light-grey-3);
 					transition: 0.2s;
 					border-radius: 34px;
 				}
@@ -756,7 +1061,7 @@ a {
 					width: 16px;
 					left: 4px;
 					bottom: 4px;
-					background-color: white;
+					background-color: var(--white);
 					transition: 0.2s;
 					border-radius: 50%;
 				}
@@ -800,15 +1105,41 @@ a {
 	}
 }
 
+.has-text-centered {
+	text-align: center;
+}
+
 .select {
+	position: relative;
+
 	&: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 {
 		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;
 	}
 }
 
@@ -834,7 +1165,56 @@ button.delete:focus {
 	padding-right: 6px !important;
 }
 
+#tab-selection,
+.tab-selection {
+	overflow-x: auto;
+	.button {
+		white-space: nowrap;
+	}
+}
+
+.table {
+	background-color: var(--white);
+	color: var(--dark-grey);
+	width: 100%;
+	border-collapse: collapse;
+	border-spacing: 0;
+	border-radius: 5px;
+
+	thead th {
+		padding: 5px 10px;
+		text-align: left;
+		font-weight: 600;
+		color: var(--grey-3);
+	}
+
+	tr {
+		&:nth-child(even) {
+			background-color: #fafafa;
+		}
+		&:hover,
+		&:focus {
+			background-color: var(--light-grey);
+		}
+	}
+}
+
 .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;
+	white-space: nowrap;
+
 	&:hover,
 	&:focus {
 		filter: brightness(95%);
@@ -842,30 +1222,76 @@ button.delete:focus {
 
 	&.is-success {
 		background-color: var(--green) !important;
+		border-width: 0;
+		color: var(--white);
 	}
 
 	&.is-primary {
 		background-color: var(--primary-color) !important;
+		border-width: 0;
+		color: var(--white);
 	}
 
 	&.is-danger {
 		background-color: var(--red) !important;
+		border-width: 0;
+		color: var(--white);
 	}
 
 	&.is-info {
 		background-color: var(--primary-color) !important;
+		border-width: 0;
+		color: var(--white);
 	}
 
 	&.is-warning {
 		background-color: var(--yellow) !important;
+		border-width: 0;
+		color: rgba(0, 0, 0, 0.7);
+	}
+
+	&.is-dark {
+		background-color: var(--dark-grey-2);
+		border-width: 0;
+		color: var(--light-grey);
 	}
 }
 
+.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,
 .button {
 	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 {
 	transition-duration: 0.3s;
 	transition-timing-function: ease-in;
@@ -890,6 +1316,40 @@ button.delete:focus {
 
 .control {
 	margin-bottom: 5px !important;
+
+	&.is-grouped {
+		display: flex;
+	}
+
+	&.is-expanded {
+		flex: 1;
+	}
+
+	&.has-addons {
+		display: flex;
+
+		.button {
+			border-radius: 0;
+			margin-right: -1px;
+
+			&:first-child {
+				border-radius: 3px 0 0 3px;
+			}
+
+			&:last-child {
+				border-radius: 0 3px 3px 0;
+				padding-left: 10px;
+			}
+		}
+
+		.input {
+			margin-right: -1px;
+
+			&:first-child {
+				border-radius: 3px 0 0 3px;
+			}
+		}
+	}
 }
 
 .input-with-button {
@@ -1089,11 +1549,14 @@ h4.section-title {
 }
 
 #forgot-password {
-	display: flex;
 	justify-content: flex-start;
 	margin: 5px 0;
 }
 
+.steps-fade-leave-active {
+	display: none;
+}
+
 .steps-fade-enter-active,
 .steps-fade-leave-active {
 	transition: all 0.3s ease;
@@ -1150,25 +1613,19 @@ h4.section-title {
 
 /* This class is used for content-box in ResetPassword, but not in RemoveAccount. This is because ResetPassword uses transitions and RemoveAccount does not */
 .content-box-wrapper {
-	position: relative;
+	margin-top: 90px;
 	width: 100%;
 	display: flex;
-	flex-direction: column;
 	align-items: center;
-	min-height: 200px;
-
-	.content-box {
-		position: absolute;
-	}
 }
 
 .content-box {
-	margin-top: 90px;
 	border-radius: 3px;
 	background-color: var(--white);
 	border: 1px solid var(--dark-grey);
 	max-width: 580px;
 	padding: 40px;
+	flex: 1;
 
 	@media screen and (max-width: 300px) {
 		margin-top: 30px;
@@ -1179,7 +1636,6 @@ h4.section-title {
 .content-box-optional-helper {
 	margin-top: 15px;
 	color: var(--primary-color);
-	text-decoration: underline;
 	font-size: 16px;
 
 	a {
@@ -1287,8 +1743,8 @@ h4.section-title {
 
 	blockquote {
 		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 {
@@ -1325,7 +1781,7 @@ h4.section-title {
 		left: 0;
 		right: 0;
 		bottom: 0;
-		background-color: #ccc;
+		background-color: var(--light-grey-3);
 		transition: 0.2s;
 		border-radius: 34px;
 	}
@@ -1337,7 +1793,7 @@ h4.section-title {
 		width: 16px;
 		left: 4px;
 		bottom: 4px;
-		background-color: white;
+		background-color: var(--white);
 		transition: 0.2s;
 		border-radius: 50%;
 	}

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

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

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

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

+ 145 - 34
frontend/src/components/Modal.vue

@@ -1,12 +1,20 @@
 <template>
 	<div class="modal is-active">
 		<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">
 				<h2 class="modal-card-title is-marginless">
 					{{ title }}
 				</h2>
-				<button class="delete" @click="closeCurrentModal()" />
+				<span class="delete material-icons" @click="closeCurrentModal()"
+					>highlight_off</span
+				>
 			</header>
 			<section class="modal-card-body">
 				<slot name="body" />
@@ -23,7 +31,9 @@ import { mapActions } from "vuex";
 
 export default {
 	props: {
-		title: { type: String, default: "Modal" }
+		title: { type: String, default: "Modal" },
+		wide: { type: Boolean, default: false },
+		split: { type: Boolean, default: false }
 	},
 	mounted() {
 		this.type = this.toCamelCase(this.title);
@@ -41,18 +51,19 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
-.night-mode {
+<style lang="scss">
+.night-mode .modal .modal-card {
 	.modal-card-head,
 	.modal-card-foot {
 		background-color: var(--dark-grey-3);
-		border-color: var(--dark-grey-2);
+		border: none;
 	}
 
 	.modal-card-body {
 		background-color: var(--dark-grey-4) !important;
 	}
 
+	.modal-card-head .delete.material-icons,
 	.modal-card-title {
 		color: var(--white);
 	}
@@ -74,44 +85,144 @@ 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;
-			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-bottom: 1px solid var(--light-grey-2);
+			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 {
-			border-radius: 0;
+			border-top: 1px solid var(--light-grey-2);
+			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;
 		align-items: center;
 		justify-content: center;
-		background-color: #ddd;
+		background-color: var(--light-grey-2);
 		font-family: "Inter", sans-serif;
 		font-weight: 400;
 		user-select: none;

+ 1 - 0
frontend/src/components/Sidebar.vue

@@ -36,6 +36,7 @@ export default {
 }
 
 .sidebar-title {
+	font-family: "Inter", Helvetica, Arial, sans-serif; // Like h1-h6
 	background-color: var(--primary-color);
 	text-align: center;
 	padding: 10px;

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

@@ -1,7 +1,7 @@
 <template>
 	<footer class="footer">
 		<div class="container">
-			<div class="footer-content has-text-centered">
+			<div class="footer-content">
 				<div id="footer-copyright">
 					<p>© Copyright Musare 2015 - 2021</p>
 				</div>
@@ -61,10 +61,15 @@ export default {
 	height: 160px;
 	font-size: 16px;
 
+	.container {
+		position: relative;
+	}
+
 	.footer-content {
 		display: flex;
 		align-items: center;
 		flex-direction: column;
+		text-align: center;
 
 		& > * {
 			margin: 5px 0;
@@ -86,6 +91,10 @@ export default {
 		width: 160px;
 		order: 1;
 		user-select: none;
+
+		img {
+			max-width: 100%;
+		}
 	}
 
 	#footer-links {

+ 108 - 19
frontend/src/components/layout/MainHeader.vue

@@ -159,9 +159,12 @@ export default {
 
 .nav {
 	flex-shrink: 0;
+	display: flex;
+	position: relative;
 	background-color: var(--primary-color);
 	height: 64px;
 	border-radius: 0% 0% 33% 33% / 0% 0% 7% 7%;
+	z-index: 2;
 
 	&.transparent {
 		background-color: transparent !important;
@@ -171,14 +174,14 @@ export default {
 		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 {
@@ -189,23 +192,54 @@ export default {
 
 	.nav-toggle {
 		height: 64px;
+		width: 50px;
+		position: relative;
 		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 {
 			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;
+			}
 
-		img {
-			max-height: 38px;
-			color: var(--primary-color);
-			user-select: none;
+			&:nth-child(2) {
+				margin-top: -1px;
+			}
+
+			&:nth-child(3) {
+				margin-top: 4px;
+			}
 		}
 	}
 
@@ -213,11 +247,40 @@ export default {
 		font-size: 17px;
 		color: var(--white);
 		border-top: 0;
+		display: flex;
+		align-items: center;
+		padding: 10px;
+		cursor: pointer;
 
 		&:hover,
 		&:focus {
 			color: var(--white);
 		}
+
+		&.is-brand {
+			font-size: 2.1rem !important;
+			line-height: 38px !important;
+			padding: 0 20px;
+			font-family: Pacifico, cursive;
+			display: flex;
+			align-items: center;
+
+			img {
+				max-height: 38px;
+				color: var(--primary-color);
+				user-select: none;
+			}
+		}
+	}
+
+	.nav-menu {
+		// box-shadow: 0 4px 7px rgb(10 10 10 / 10%);
+		// left: 0;
+		// display: block;
+		// right: 0;
+		// top: 100%;
+		// position: absolute;
+		// background: var(--white);
 	}
 }
 
@@ -228,13 +291,39 @@ export default {
 	.nav-item {
 		&:hover,
 		&:focus {
-			border-top: 1px solid white;
+			border-top: 1px solid var(--white);
 			height: calc(100% - 1px);
 		}
 	}
 }
 
 @media screen and (max-width: 768px) {
+	.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 {
+			color: var(--dark-grey-2);
+
+			&:hover {
+				color: var(--dark-grey-2);
+			}
+		}
+	}
+
 	.nav .nav-menu .grouped {
 		flex-direction: column;
 		.nav-item {

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

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

+ 38 - 43
frontend/src/components/modals/EditNews.vue

@@ -2,21 +2,21 @@
 	<modal
 		class="edit-news-modal"
 		:title="newsId ? 'Edit News' : 'Create News'"
+		:wide="true"
+		:split="true"
 	>
 		<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>
 		</template>
 		<template #footer>
@@ -81,7 +81,7 @@ export default {
 	data() {
 		return {
 			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",
 			createdBy: null,
 			createdAt: 0
@@ -95,7 +95,7 @@ export default {
 		marked.use({
 			renderer: {
 				table(header, body) {
-					return `<table class="table is-striped">
+					return `<table class="table">
 					<thead>${header}</thead>
 					<tbody>${body}</tbody>
 					</table>`;
@@ -211,50 +211,33 @@ export default {
 </script>
 
 <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 lang="scss" scoped>
 .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);
 	}
 
-	#markdown-editor-and-preview textarea {
+	.edit-news-modal .modal-card .modal-card-body textarea {
 		background-color: var(--dark-grey);
 		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 {
 		border: 0;
 		outline: none;
 		resize: none;
-		margin-right: 5px;
 		font-size: 16px;
 	}
 
@@ -268,8 +251,20 @@ export default {
 	#preview {
 		padding: 5px;
 		border: 1px solid var(--light-grey-3) !important;
-		border-radius: 3px;
-		height: 700px;
+		border-radius: 5px;
+		height: calc(100vh - 280px);
+		width: 100%;
+	}
+}
+
+.edit-news-modal .modal-card .modal-card-foot {
+	.control {
+		margin-bottom: 0 !important;
+	}
+
+	.right {
+		line-height: 36px;
+		column-gap: 0;
 	}
 }
 </style>

+ 188 - 239
frontend/src/components/modals/EditPlaylist/index.vue

@@ -3,209 +3,198 @@
 		:title="
 			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>
-			<div
-				:class="{
-					'view-only': !isEditable(),
-					'custom-modal-body': true
-				}"
-			>
-				<div class="first-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="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' && 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="
 								userId === playlist.createdBy ||
 								isEditable() ||
 								(playlist.type === 'genre' && 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()"
-						/>
-						<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()"
-						/>
+						>
+							Import Playlists
+						</button>
 					</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 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>
 
-				<div class="second-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>
+						<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
 													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
-														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
-														)
+														delete-icon
 													"
-													class="material-icons"
-													content="Move to bottom of Playlist"
+													content="Remove Song from Playlist"
 													v-tippy
-													>vertical_align_bottom</i
+													>delete_forever</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>
 		</template>
@@ -594,20 +583,6 @@ export default {
 };
 </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>
 .night-mode {
 	.label,
@@ -616,8 +591,8 @@ export default {
 		color: var(--light-grey-2);
 	}
 
-	.edit-playlist-modal.modal .modal-card-body .custom-modal-body {
-		.first-section {
+	.edit-playlist-modal.modal .modal-card-body {
+		.left-section {
 			#playlist-info-section {
 				background-color: var(--dark-grey-3) !important;
 				border: 0;
@@ -634,7 +609,7 @@ export default {
 				}
 			}
 		}
-		.second-section .section {
+		.right-section .section {
 			border-radius: 5px;
 		}
 	}
@@ -696,22 +671,19 @@ export default {
 }
 
 .edit-playlist-modal {
-	.custom-modal-body {
-		display: flex;
-		flex-wrap: wrap;
-		height: 100%;
-		row-gap: 24px;
+	&.view-only {
+		height: auto !important;
 
-		&.view-only {
-			height: auto !important;
+		.left-section {
+			flex-basis: 100% !important;
+		}
 
-			.first-section {
-				flex-basis: 100%;
-			}
+		.right-section {
+			max-height: unset !important;
+		}
 
-			/deep/ .section {
-				max-width: 100% !important;
-			}
+		/deep/ .section {
+			max-width: 100% !important;
 		}
 	}
 
@@ -721,15 +693,6 @@ export default {
 		justify-content: center;
 	}
 
-	/deep/ .section {
-		padding: 15px !important;
-		margin: 0 10px;
-		max-width: 100%;
-		display: flex;
-		flex-direction: column;
-		flex-grow: 1;
-	}
-
 	.label {
 		font-size: 1rem;
 		font-weight: normal;
@@ -739,16 +702,7 @@ export default {
 		width: 150px;
 	}
 
-	.first-section {
-		flex-basis: 550px;
-		height: 100%;
-		overflow-y: auto;
-		flex-grow: 1;
-
-		/deep/ .section {
-			width: auto;
-		}
-
+	.left-section {
 		#playlist-info-section {
 			border: 1px solid var(--light-grey-3);
 			border-radius: 3px;
@@ -770,12 +724,7 @@ export default {
 		}
 	}
 
-	.second-section {
-		flex-basis: 650px;
-		height: 100%;
-		overflow-y: auto;
-		flex-grow: 1;
-
+	.right-section {
 		#rearrange-songs-section {
 			.scrollable-list:not(:last-of-type) {
 				margin-bottom: 10px;

+ 19 - 30
frontend/src/components/modals/EditSong/index.vue

@@ -1,6 +1,6 @@
 <template>
 	<div>
-		<modal title="Edit Song" class="song-modal">
+		<modal title="Edit Song" class="song-modal" :wide="true" :split="true">
 			<template #body>
 				<div class="left-section">
 					<div class="top-section">
@@ -1605,7 +1605,7 @@ export default {
 
 	.autosuggest-item {
 		background-color: var(--dark-grey) !important;
-		color: white !important;
+		color: var(--white) !important;
 		border-color: var(--dark-grey) !important;
 	}
 
@@ -1618,41 +1618,28 @@ export default {
 		background: var(--dark-grey) !important;
 		color: var(--white) !important;
 	}
-}
-
-.song-modal {
-	&::v-deep {
-		.modal-card {
-			width: 1160px;
-			height: 100%;
-
-			.modal-card-body {
-				display: flex;
-				padding: 16px;
-
-				@media screen and (max-width: 1000px) {
-					flex-wrap: wrap;
-				}
 
-				> div {
-					display: flex;
-					height: 100%;
-					overflow: auto;
-				}
-			}
-
-			.modal-card-foot {
-				.right {
-					display: flex;
-					margin-left: auto;
-					margin-right: 0;
+	.left-section {
+		.edit-section {
+			.album-get-button,
+			.duration-fill-button,
+			.add-button {
+				&:focus,
+				&:hover {
+					border: none !important;
 				}
 			}
 		}
 	}
 }
 
+.modal-card-body {
+	display: flex;
+}
+
 .left-section {
+	flex-basis: unset !important;
+	height: 100%;
 	display: flex;
 	flex-direction: column;
 	margin-right: 16px;
@@ -2026,8 +2013,10 @@ export default {
 }
 
 .right-section {
+	flex-basis: unset !important;
+	flex-grow: 0 !important;
 	display: flex;
-	flex-wrap: wrap;
+	height: 100%;
 
 	#tabs-container {
 		width: 376px;

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

@@ -1,119 +1,96 @@
 <template>
 	<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>
 					</div>
 
 					<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>
-						<a v-else @click="changeToRegisterModal()">
-							Don't have an account?
-						</a>
 					</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>
 </template>
 
@@ -121,8 +98,12 @@
 import { mapActions } from "vuex";
 
 import Toast from "toasters";
+import Modal from "../Modal.vue";
 
 export default {
+	components: {
+		Modal
+	},
 	data() {
 		return {
 			email: "",
@@ -130,14 +111,11 @@ export default {
 				value: "",
 				visible: false
 			},
-			apiDomain: "",
-			isPage: false
+			apiDomain: ""
 		};
 	},
 	async mounted() {
 		this.apiDomain = await lofig.get("backend.apiDomain");
-
-		if (this.$route.path === "/login") this.isPage = true;
 	},
 	methods: {
 		checkForAutofill(event) {
@@ -173,17 +151,14 @@ export default {
 			}
 		},
 		changeToRegisterModal() {
-			if (!this.isPage) {
-				this.closeLoginModal();
-				this.openModal("register");
-			}
+			this.closeLoginModal();
+			this.openModal("register");
 		},
 		closeLoginModal() {
-			if (!this.isPage) this.closeModal("login");
+			this.closeModal("login");
 		},
 		githubRedirect() {
-			if (!this.isPage)
-				localStorage.setItem("github_redirect", this.$route.path);
+			localStorage.setItem("github_redirect", this.$route.path);
 		},
 		...mapActions("modalVisibility", ["closeModal", "openModal"]),
 		...mapActions("user/auth", ["login"])

+ 137 - 174
frontend/src/components/modals/ManageStation/index.vue

@@ -8,151 +8,151 @@
 		"
 		:style="`--primary-color: var(--${station.theme})`"
 		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">
-						<div id="about-station-container">
-							<div id="station-info">
-								<div id="station-name">
-									<h1>{{ station.displayName }}</h1>
-									<i
-										v-if="station.type === 'official'"
-										class="material-icons verified-station"
-										content="Verified Station"
-										v-tippy
-									>
-										check_circle
-									</i>
-									<i
-										class="material-icons stationMode"
-										:content="
-											station.partyMode
-												? 'Station in Party mode'
-												: 'Station in Playlist mode'
-										"
-										v-tippy
-										>{{
-											station.partyMode
-												? "emoji_people"
-												: "playlist_play"
-										}}</i
-									>
-								</div>
-								<p>{{ station.description }}</p>
-							</div>
-
-							<div id="admin-buttons" v-if="isOwnerOrAdmin()">
-								<!-- (Admin) Pause/Resume Button -->
-								<button
-									class="button is-danger"
-									v-if="stationPaused"
-									@click="resumeStation()"
-								>
-									<i class="material-icons icon-with-button"
-										>play_arrow</i
-									>
-									<span> Resume Station </span>
-								</button>
-								<button
-									class="button is-danger"
-									@click="pauseStation()"
-									v-else
+		<template #body v-if="station && station._id">
+			<div class="left-section">
+				<div class="section">
+					<div id="about-station-container">
+						<div id="station-info">
+							<div id="station-name">
+								<h1>{{ station.displayName }}</h1>
+								<i
+									v-if="station.type === 'official'"
+									class="material-icons verified-station"
+									content="Verified Station"
+									v-tippy
 								>
-									<i class="material-icons icon-with-button"
-										>pause</i
-									>
-									<span> Pause Station </span>
-								</button>
-
-								<!-- (Admin) Skip Button -->
-								<button
-									class="button is-danger"
-									@click="skipStation()"
+									check_circle
+								</i>
+								<i
+									class="material-icons stationMode"
+									:content="
+										station.partyMode
+											? 'Station in Party mode'
+											: 'Station in Playlist mode'
+									"
+									v-tippy
+									>{{
+										station.partyMode
+											? "emoji_people"
+											: "playlist_play"
+									}}</i
 								>
-									<i class="material-icons icon-with-button"
-										>skip_next</i
-									>
-									<span> Force Skip </span>
-								</button>
-
-								<router-link
-									v-if="sector !== 'station' && station.name"
-									:to="{
-										name: 'station',
-										params: { id: station.name }
-									}"
-									class="button is-primary"
-								>
-									Go To Station
-								</router-link>
 							</div>
+							<p>{{ station.description }}</p>
 						</div>
-						<div class="tab-selection">
+
+						<div id="admin-buttons" v-if="isOwnerOrAdmin()">
+							<!-- (Admin) Pause/Resume Button -->
 							<button
-								v-if="isOwnerOrAdmin()"
-								class="button is-default"
-								:class="{ selected: tab === 'settings' }"
-								ref="settings-tab"
-								@click="showTab('settings')"
+								class="button is-danger"
+								v-if="stationPaused"
+								@click="resumeStation()"
 							>
-								Settings
+								<i class="material-icons icon-with-button"
+									>play_arrow</i
+								>
+								<span> Resume Station </span>
 							</button>
 							<button
-								v-if="isAllowedToParty() || isOwnerOrAdmin()"
-								class="button is-default"
-								:class="{ selected: tab === 'playlists' }"
-								ref="playlists-tab"
-								@click="showTab('playlists')"
+								class="button is-danger"
+								@click="pauseStation()"
+								v-else
 							>
-								Playlists
+								<i class="material-icons icon-with-button"
+									>pause</i
+								>
+								<span> Pause Station </span>
 							</button>
+
+							<!-- (Admin) Skip Button -->
 							<button
-								v-if="isAllowedToParty() || isOwnerOrAdmin()"
-								class="button is-default"
-								:class="{ selected: tab === 'songs' }"
-								ref="songs-tab"
-								@click="showTab('songs')"
+								class="button is-danger"
+								@click="skipStation()"
 							>
-								Songs
+								<i class="material-icons icon-with-button"
+									>skip_next</i
+								>
+								<span> Force Skip </span>
 							</button>
+
+							<router-link
+								v-if="sector !== 'station' && station.name"
+								:to="{
+									name: 'station',
+									params: { id: station.name }
+								}"
+								class="button is-primary"
+							>
+								Go To Station
+							</router-link>
 						</div>
-						<settings
+					</div>
+					<div class="tab-selection">
+						<button
 							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="isAllowedToParty() || isOwnerOrAdmin()"
-							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="isAllowedToParty() || isOwnerOrAdmin()"
-							class="tab"
-							v-show="tab === 'songs'"
-						/>
+							class="button is-default"
+							:class="{ selected: tab === 'songs' }"
+							ref="songs-tab"
+							@click="showTab('songs')"
+						>
+							Songs
+						</button>
 					</div>
+					<settings
+						v-if="isOwnerOrAdmin()"
+						class="tab"
+						v-show="tab === 'settings'"
+					/>
+					<playlists
+						v-if="isAllowedToParty() || isOwnerOrAdmin()"
+						class="tab"
+						v-show="tab === 'playlists'"
+					/>
+					<songs
+						v-if="isAllowedToParty() || isOwnerOrAdmin()"
+						class="tab"
+						v-show="tab === 'songs'"
+					/>
 				</div>
-				<div class="right-section">
-					<div class="section">
-						<div class="queue-title">
-							<h4 class="section-title">Queue</h4>
-						</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 class="right-section">
+				<div class="section">
+					<div class="queue-title">
+						<h4 class="section-title">Queue</h4>
 					</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>
 		</template>
@@ -171,10 +171,7 @@
 						Clear and refill station queue
 					</a>
 				</confirm>
-				<confirm
-					v-if="station && station.type === 'community'"
-					@confirm="removeStation()"
-				>
+				<confirm @confirm="removeStation()">
 					<button class="button is-danger">Delete station</button>
 				</confirm>
 			</div>
@@ -641,22 +638,16 @@ export default {
 </script>
 
 <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;
 		}
 	}
 }
@@ -664,7 +655,7 @@ export default {
 
 <style lang="scss" scoped>
 .night-mode {
-	.manage-station-modal.modal .modal-card-body .custom-modal-body {
+	.manage-station-modal.modal .modal-card-body {
 		.left-section {
 			#about-station-container {
 				background-color: var(--dark-grey-3) !important;
@@ -690,26 +681,12 @@ export default {
 	}
 }
 
-.manage-station-modal.modal .modal-card-body .custom-modal-body {
+.manage-station-modal.modal .modal-card-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;
-	}
-
 	.left-section {
-		flex-basis: 50%;
-		height: 100%;
-		overflow-y: auto;
-		flex-grow: 1;
-
 		.section:first-child {
 			padding: 0 15px 15px !important;
 		}
@@ -800,10 +777,6 @@ export default {
 		}
 	}
 	.right-section {
-		flex-basis: 50%;
-		height: 100%;
-		overflow-y: auto;
-		flex-grow: 1;
 		.section {
 			.queue-title {
 				display: flex;
@@ -831,14 +804,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: unset;
-			height: auto;
-		}
-	}
-}
 </style>

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

@@ -1,164 +1,148 @@
 <template>
 	<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>
 </template>
 
 <script>
 import { mapActions } from "vuex";
 import Toast from "toasters";
+import Modal from "../Modal.vue";
 
 import validation from "@/validation";
 import InputHelpBox from "../InputHelpBox.vue";
 
 export default {
-	components: { InputHelpBox },
+	components: { Modal, InputHelpBox },
 	data() {
 		return {
 			username: {
@@ -186,8 +170,7 @@ export default {
 				token: "",
 				enabled: false
 			},
-			apiDomain: "",
-			isPage: false
+			apiDomain: ""
 		};
 	},
 	watch: {
@@ -244,8 +227,6 @@ export default {
 		}
 	},
 	async mounted() {
-		if (this.$route.path === "/register") this.isPage = true;
-
 		this.apiDomain = await lofig.get("backend.apiDomain");
 
 		lofig.get("recaptcha").then(obj => {
@@ -286,13 +267,11 @@ export default {
 			}
 		},
 		changeToLoginModal() {
-			if (!this.isPage) {
-				this.closeRegisterModal();
-				this.openModal("login");
-			}
+			this.closeRegisterModal();
+			this.openModal("login");
 		},
 		closeRegisterModal() {
-			if (!this.isPage) this.closeModal("register");
+			this.closeModal("register");
 		},
 		submitModal() {
 			if (
@@ -317,8 +296,7 @@ export default {
 			this[inputName].entered = true;
 		},
 		githubRedirect() {
-			if (!this.isPage)
-				localStorage.setItem("github_redirect", this.$route.path);
+			localStorage.setItem("github_redirect", this.$route.path);
 		},
 		...mapActions("modalVisibility", ["closeModal", "openModal"]),
 		...mapActions("user/auth", ["register"])

+ 19 - 13
frontend/src/components/modals/Report.vue

@@ -1,6 +1,10 @@
 <template>
 	<div>
-		<modal class="report-modal" title="Report">
+		<modal
+			class="report-modal"
+			title="Report"
+			:wide="existingReports.length > 0"
+		>
 			<template #body>
 				<div class="report-modal-inner-container">
 					<div id="left-part">
@@ -431,18 +435,10 @@ export default {
 </script>
 
 <style lang="scss">
-.report-modal {
-	.modal-card {
-		width: 1050px;
-	}
-
-	.song-item {
-		.thumbnail {
-			min-width: 130px;
-			width: 130px;
-			height: 130px;
-		}
-	}
+.report-modal .song-item .thumbnail {
+	min-width: 130px;
+	width: 130px;
+	height: 130px;
 }
 </style>
 
@@ -477,6 +473,7 @@ export default {
 			margin-bottom: 20px;
 			padding: 20px;
 			background-color: var(--light-grey);
+			border-radius: 5px;
 		}
 	}
 
@@ -502,10 +499,19 @@ export default {
 }
 
 .columns {
+	display: flex;
+	flex-wrap: wrap;
 	margin-left: unset;
 	margin-right: unset;
 	margin-top: 20px;
 
+	.column {
+		flex-basis: 50%;
+		@media screen and (max-width: 900px) {
+			flex-basis: 100% !important;
+		}
+	}
+
 	.control {
 		display: flex;
 		flex-direction: column;

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

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

+ 7 - 1
frontend/src/components/modals/WhatIsNew.vue

@@ -54,7 +54,7 @@ export default {
 		marked.use({
 			renderer: {
 				table(header, body) {
-					return `<table class="table is-striped">
+					return `<table class="table">
 					<thead>${header}</thead>
 					<tbody>${body}</tbody>
 					</table>`;
@@ -101,6 +101,12 @@ export default {
 };
 </script>
 
+<style lang="scss">
+.what-is-news-modal .modal-card .modal-card-foot {
+	column-gap: 0;
+}
+</style>
+
 <style lang="scss" scoped>
 .night-mode {
 	.modal-card,

+ 10 - 14
frontend/src/main.js

@@ -79,6 +79,16 @@ const router = createRouter({
 			path: "/",
 			component: () => import("@/pages/Home.vue")
 		},
+		{
+			path: "/login",
+			name: "login",
+			redirect: "/"
+		},
+		{
+			path: "/register",
+			name: "register",
+			redirect: "/"
+		},
 		{
 			path: "/404",
 			alias: ["/:pathMatch(.*)*"],
@@ -128,20 +138,6 @@ const router = createRouter({
 				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",
 			component: () => import("@/pages/Admin/index.vue"),

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

@@ -5,57 +5,51 @@
 		<div class="container">
 			<div class="content-wrapper">
 				<h1 class="has-text-centered page-title">About</h1>
-				<div class="card is-fullwidth">
+				<div class="card">
 					<header class="card-header">
-						<p class="card-header-title">The project</p>
+						<p>The project</p>
 					</header>
 					<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 class="card is-fullwidth">
+				<div class="card">
 					<header class="card-header">
-						<p class="card-header-title">How you can help</p>
+						<p>How you can help</p>
 					</header>
 					<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>
@@ -86,6 +80,24 @@ export default {
 }
 
 .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;
+	}
+}
+
+ol {
+	margin-left: 2em;
 }
 </style>

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

@@ -259,6 +259,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 {
 	height: auto;
 }
@@ -267,6 +280,23 @@ export default {
 	padding-top: 10px;
 	margin-top: -10px;
 	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;
+		border-bottom: 1px solid var(--light-grey-2);
+	}
+
 	.unverifiedsongs {
 		color: var(--teal);
 		border-color: var(--teal);
@@ -311,6 +341,9 @@ export default {
 		transition: all 0.2s ease-in-out;
 		font-weight: 500;
 		border-bottom: solid 0px;
+		padding: 6px 12px;
+		display: flex;
+		margin-bottom: -1px;
 	}
 	.tab:hover {
 		border-width: 3px;
@@ -322,4 +355,17 @@ export default {
 		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>

+ 1 - 1
frontend/src/pages/Admin/tabs/HiddenSongs.vue

@@ -110,7 +110,7 @@
 				<span>Loaded songs: {{ songs.length }}</span>
 			</p>
 			<br />
-			<table class="table is-striped">
+			<table class="table">
 				<thead>
 					<tr>
 						<td>Thumbnail</td>

+ 1 - 1
frontend/src/pages/Admin/tabs/News.vue

@@ -7,7 +7,7 @@
 					Create News Item
 				</button>
 			</div>
-			<table class="table is-striped">
+			<table class="table">
 				<thead>
 					<tr>
 						<td>Status</td>

+ 39 - 27
frontend/src/pages/Admin/tabs/Playlists.vue

@@ -3,38 +3,48 @@
 		<page-metadata title="Admin | Playlists" />
 		<div class="container">
 			<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="requestOrphanedPlaylistSongs()"
+					<button class="button is-danger">
+						Delete orphaned genre 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-danger">
+						Clear and refill all genre playlists
+					</button>
+				</confirm>
 			</div>
-			<table class="table is-striped">
+			<table class="table">
 				<thead>
 					<tr>
 						<td>Display name</td>
@@ -94,6 +104,7 @@ import { mapState, mapActions, mapGetters } from "vuex";
 import { defineAsyncComponent } from "vue";
 
 import Toast from "toasters";
+import Confirm from "@/components/Confirm.vue";
 
 import UserIdToUsername from "@/components/UserIdToUsername.vue";
 
@@ -111,7 +122,8 @@ export default {
 		),
 		EditSong: defineAsyncComponent(() =>
 			import("@/components/modals/EditSong")
-		)
+		),
+		Confirm
 	},
 	data() {
 		return {

+ 56 - 41
frontend/src/pages/Admin/tabs/Punishments.vue

@@ -2,7 +2,7 @@
 	<div>
 		<page-metadata title="Admin | Punishments" />
 		<div class="container">
-			<table class="table is-striped">
+			<table class="table">
 				<thead>
 					<tr>
 						<td>Status</td>
@@ -57,46 +57,44 @@
 					</tr>
 				</tbody>
 			</table>
-			<div class="card is-fullwidth">
+			<div class="card">
 				<header class="card-header">
-					<p class="card-header-title">Ban an IP</p>
+					<p>Ban an IP</p>
 				</header>
 				<div class="card-content">
-					<div class="content">
-						<label class="label">Expires In</label>
-						<select v-model="ipBan.expiresAt">
-							<option value="1h">1 Hour</option>
-							<option value="12h">12 Hours</option>
-							<option value="1d">1 Day</option>
-							<option value="1w">1 Week</option>
-							<option value="1m">1 Month</option>
-							<option value="3m">3 Months</option>
-							<option value="6m">6 Months</option>
-							<option value="1y">1 Year</option>
-						</select>
-						<label class="label">IP</label>
-						<p class="control is-expanded">
-							<input
-								v-model="ipBan.ip"
-								class="input"
-								type="text"
-								placeholder="IP address (xxx.xxx.xxx.xxx)"
-							/>
-						</p>
-						<label class="label">Reason</label>
-						<p class="control is-expanded">
-							<input
-								v-model="ipBan.reason"
-								class="input"
-								type="text"
-								placeholder="Reason"
-							/>
-						</p>
-					</div>
+					<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>
-				<footer class="card-footer">
-					<a class="card-footer-item" @click="banIP()">Ban IP</a>
-				</footer>
 			</div>
 		</div>
 		<view-punishment
@@ -209,10 +207,6 @@ export default {
 	.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);
@@ -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 {
 	vertical-align: middle;
 }

+ 1 - 1
frontend/src/pages/Admin/tabs/Reports.vue

@@ -2,7 +2,7 @@
 	<div>
 		<page-metadata title="Admin | Reports" />
 		<div class="container">
-			<table class="table is-striped">
+			<table class="table">
 				<thead>
 					<tr>
 						<td>Summary</td>

+ 14 - 212
frontend/src/pages/Admin/tabs/Stations.vue

@@ -5,12 +5,17 @@
 			<div class="button-row">
 				<button
 					class="button is-primary"
-					@click="clearEveryStationQueue()"
+					@click="openModal('createStation')"
 				>
-					Clear every station queue
+					Create Station
 				</button>
+				<confirm placement="bottom" @confirm="clearEveryStationQueue()">
+					<button class="button is-danger">
+						Clear every station queue
+					</button>
+				</confirm>
 			</div>
-			<table class="table is-striped">
+			<table class="table">
 				<thead>
 					<tr>
 						<td>ID</td>
@@ -72,122 +77,18 @@
 				</tbody>
 			</table>
 		</div>
-		<div class="container">
-			<div class="card is-fullwidth">
-				<header class="card-header">
-					<p class="card-header-title">Create official station</p>
-				</header>
-				<div class="card-content">
-					<div class="content">
-						<div class="control is-horizontal">
-							<div class="control is-grouped">
-								<p class="control is-expanded">
-									<input
-										v-model="newStation.name"
-										class="input"
-										type="text"
-										placeholder="Name"
-									/>
-								</p>
-								<p class="control is-expanded">
-									<input
-										v-model="newStation.displayName"
-										class="input"
-										type="text"
-										placeholder="Display Name"
-									/>
-								</p>
-							</div>
-						</div>
-						<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" />
-		<edit-playlist v-if="modals.editPlaylist" />
 		<create-playlist v-if="modals.createPlaylist" />
 		<manage-station
 			v-if="modals.manageStation"
 			:station-id="editingStationId"
 			sector="admin"
 		/>
+		<edit-playlist v-if="modals.editPlaylist" />
 		<edit-song v-if="modals.editSong" song-type="songs" sector="admin" />
 		<report v-if="modals.report" />
+		<create-station v-if="modals.createStation" :official="true" />
 	</div>
 </template>
 
@@ -220,16 +121,15 @@ export default {
 		EditSong: defineAsyncComponent(() =>
 			import("@/components/modals/EditSong")
 		),
+		CreateStation: defineAsyncComponent(() =>
+			import("@/components/modals/CreateStation.vue")
+		),
 		UserIdToUsername,
 		Confirm
 	},
 	data() {
 		return {
-			editingStationId: "",
-			newStation: {
-				genres: [],
-				blacklistedGenres: []
-			}
+			editingStationId: ""
 		};
 	},
 	computed: {
@@ -255,44 +155,6 @@ export default {
 		);
 	},
 	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) {
 			this.socket.dispatch(
 				"stations.remove",
@@ -304,37 +166,6 @@ export default {
 			this.editingStationId = station._id;
 			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() {
 			this.socket.dispatch("stations.clearEveryStationQueue", res => {
 				if (res.status === "success") new Toast(res.message);
@@ -385,26 +216,6 @@ export default {
 			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 {
@@ -420,13 +231,4 @@ td {
 .is-info:focus {
 	background-color: var(--primary-color);
 }
-
-.genre-wrapper {
-	display: flex;
-	justify-content: space-around;
-}
-
-.card-footer-item {
-	color: var(--primary-color);
-}
 </style>

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

@@ -1,210 +1,164 @@
 <template>
 	<div class="container">
 		<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>
 		<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">
-					<p class="card-header-title">Running tasks</p>
+					<p>Running tasks</p>
 				</header>
 				<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
-				class="
-					card
-					column
-					is-10-desktop is-offset-1-desktop is-12-mobile
-				"
-			>
+			<div class="card">
 				<header class="card-header">
-					<p class="card-header-title">Paused tasks</p>
+					<p>Paused tasks</p>
 				</header>
 				<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
-				class="
-					card
-					column
-					is-10-desktop is-offset-1-desktop is-12-mobile
-				"
-			>
+			<div class="card">
 				<header class="card-header">
-					<p class="card-header-title">Queued tasks</p>
+					<p>Queued tasks</p>
 				</header>
 				<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>
 		<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">
-					<p class="card-header-title">Average Logs</p>
+					<p>Average Logs</p>
 				</header>
 				<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>
@@ -305,4 +259,21 @@ td {
 .is-primary:focus {
 	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>

+ 1 - 1
frontend/src/pages/Admin/tabs/UnverifiedSongs.vue

@@ -110,7 +110,7 @@
 				<span>Loaded songs: {{ songs.length }}</span>
 			</p>
 			<br />
-			<table class="table is-striped">
+			<table class="table">
 				<thead>
 					<tr>
 						<td>Thumbnail</td>

+ 6 - 2
frontend/src/pages/Admin/tabs/Users.vue

@@ -4,7 +4,7 @@
 		<div class="container">
 			<h2 v-if="dataRequests.length > 0">Data Requests</h2>
 
-			<table class="table is-striped" v-if="dataRequests.length > 0">
+			<table class="table" v-if="dataRequests.length > 0">
 				<thead>
 					<tr>
 						<td>User ID</td>
@@ -36,7 +36,7 @@
 
 			<h1 id="page-title">Users</h1>
 
-			<table class="table is-striped">
+			<table class="table">
 				<thead>
 					<tr>
 						<td class="ppRow">Profile Picture</td>
@@ -200,6 +200,10 @@ export default {
 	}
 }
 
+#page-title {
+	margin: 30px 0;
+}
+
 h2 {
 	font-size: 30px;
 	text-align: center;

+ 1 - 1
frontend/src/pages/Admin/tabs/VerifiedSongs.vue

@@ -120,7 +120,7 @@
 				<span>Loaded songs: {{ songs.length }}</span>
 			</p>
 			<br />
-			<table class="table is-striped">
+			<table class="table">
 				<thead>
 					<tr>
 						<td>Thumbnail</td>

+ 48 - 30
frontend/src/pages/Home.vue

@@ -54,7 +54,6 @@
 								params: { id: element.name }
 							}"
 							:class="{
-								card: true,
 								'station-card': true,
 								'item-draggable': true,
 								isPrivate: element.privacy === 'private',
@@ -64,10 +63,7 @@
 								'--primary-color: var(--' + element.theme + ')'
 							"
 						>
-							<song-thumbnail
-								class="card-image"
-								:song="element.currentSong"
-							/>
+							<song-thumbnail :song="element.currentSong" />
 							<div class="card-content">
 								<div class="media">
 									<div class="media-left displayName">
@@ -234,11 +230,11 @@
 				</div>
 				<a
 					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>
 						</figure>
 					</div>
@@ -258,10 +254,10 @@
 				<a
 					v-else
 					@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>
 						</figure>
 					</div>
@@ -284,17 +280,14 @@
 						name: 'station',
 						params: { id: station.name }
 					}"
-					class="card station-card"
+					class="station-card"
 					:class="{
 						isPrivate: station.privacy === 'private',
 						isMine: isOwner(station)
 					}"
 					: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="media">
 							<div class="media-left displayName">
@@ -434,7 +427,7 @@
 			</div>
 			<main-footer />
 		</div>
-		<create-community-station v-if="modals.createCommunityStation" />
+		<create-station v-if="modals.createStation" />
 	</div>
 </template>
 
@@ -456,8 +449,8 @@ export default {
 		MainHeader,
 		MainFooter,
 		SongThumbnail,
-		CreateCommunityStation: defineAsyncComponent(() =>
-			import("@/components/modals/CreateCommunityStation.vue")
+		CreateStation: defineAsyncComponent(() =>
+			import("@/components/modals/CreateStation.vue")
 		),
 		UserIdToUsername,
 		draggable
@@ -469,7 +462,8 @@ export default {
 			favoriteStations: [],
 			searchQuery: "",
 			sitename: "Musare",
-			orderOfFavoriteStations: []
+			orderOfFavoriteStations: [],
+			handledLoginRegisterRedirect: false
 		};
 	},
 	computed: {
@@ -522,6 +516,18 @@ export default {
 	async mounted() {
 		this.sitename = await lofig.get("siteSettings.sitename");
 
+		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);
 
 		this.socket.on("event:station.created", res => {
@@ -770,7 +776,7 @@ export default {
 };
 </script>
 
-<style lang="scss">
+<style lang="scss" scoped>
 * {
 	box-sizing: border-box;
 }
@@ -797,7 +803,7 @@ html {
 			rgba(34, 34, 34, 0.8) 100%
 		);
 	}
-	.card,
+	.station-card,
 	.card-content,
 	.card-content div {
 		background-color: var(--dark-grey-3);
@@ -808,12 +814,12 @@ html {
 		color: var(--light-grey-2);
 	}
 
-	.card-image i {
+	.thumbnail i {
 		user-select: none;
 		-webkit-user-select: none;
 	}
 
-	.card-image.thumbnail {
+	.thumbnail {
 		background-color: var(--dark-grey-2);
 	}
 
@@ -1023,10 +1029,14 @@ html {
 
 .station-card {
 	display: inline-flex;
+	position: relative;
+	background-color: var(--white);
+	color: var(--dark-grey);
 	flex-direction: row;
 	overflow: hidden;
 	margin: 10px;
 	cursor: pointer;
+	filter: none;
 	height: 150px;
 	width: calc(100% - 30px);
 	max-width: 400px;
@@ -1035,6 +1045,8 @@ html {
 	box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.1);
 
 	.card-content {
+		display: flex;
+		position: relative;
 		padding: 10px 10px 10px 15px;
 		display: flex;
 		flex-direction: column;
@@ -1100,11 +1112,19 @@ html {
 		}
 	}
 
-	.card-image.thumbnail {
+	.thumbnail {
+		display: flex;
+		position: relative;
 		min-width: 120px;
 		width: 120px;
 		height: 120px;
 		margin: 0;
+
+		.image {
+			display: flex;
+			position: relative;
+			padding-top: 100%;
+		}
 	}
 
 	.bottomBar {
@@ -1140,10 +1160,8 @@ html {
 	}
 
 	&.createStation {
-		height: auto;
-
-		.card-image {
-			.image.is-square {
+		.thumbnail {
+			.image {
 				width: 120px;
 
 				@media screen and (max-width: 330px) {

+ 1 - 1
frontend/src/pages/News.vue

@@ -62,7 +62,7 @@ export default {
 		marked.use({
 			renderer: {
 				table(header, body) {
-					return `<table class="table is-striped">
+					return `<table class="table">
 					<thead>${header}</thead>
 					<tbody>${body}</tbody>
 					</table>`;

+ 1 - 1
frontend/src/pages/Profile/Tabs/Playlists.vue

@@ -78,7 +78,7 @@
 			</button>
 		</div>
 		<div v-else>
-			<h3>No playlists here.</h3>
+			<h5>No playlists here.</h5>
 		</div>
 	</div>
 </template>

+ 11 - 13
frontend/src/pages/Profile/Tabs/RecentActivity.vue

@@ -11,7 +11,7 @@
 
 			<hr class="section-horizontal-rule" />
 
-			<div id="activity-items" @scroll="handleScroll">
+			<div id="activity-items">
 				<activity-item
 					class="item activity-item universal-item"
 					v-for="activity in activities"
@@ -34,7 +34,7 @@
 			</div>
 		</div>
 		<div v-else>
-			<h3>No recent activity.</h3>
+			<h5>No recent activity.</h5>
 		</div>
 	</div>
 </template>
@@ -77,6 +77,8 @@ export default {
 		})
 	},
 	mounted() {
+		window.addEventListener("scroll", this.handleScroll);
+
 		ws.onConnect(this.init);
 
 		this.socket.on("event:activity.updated", res => {
@@ -105,6 +107,9 @@ export default {
 			this.offsettedFromNextSet = 0;
 		});
 	},
+	unmounted() {
+		window.removeEventListener("scroll", this.handleScroll);
+	},
 	methods: {
 		init() {
 			if (this.myUserId !== this.userId)
@@ -145,14 +150,13 @@ export default {
 				}
 			);
 		},
-		handleScroll(event) {
-			const scrollPosition =
-				event.target.clientHeight + event.target.scrollTop;
-			const bottomPosition = event.target.scrollHeight;
+		handleScroll() {
+			const scrollPosition = document.body.clientHeight + window.scrollY;
+			const bottomPosition = document.body.scrollHeight;
 
 			if (this.loadAllSongs) return false;
 
-			if (scrollPosition + 100 >= bottomPosition) this.getSet();
+			if (scrollPosition + 400 >= bottomPosition) this.getSet();
 
 			return this.maxPosition === this.position;
 		},
@@ -167,12 +171,6 @@ export default {
 	border: 0 !important;
 }
 
-#activity-items {
-	overflow: auto;
-	min-height: auto;
-	max-height: 570px;
-}
-
 .content a {
 	border-bottom: 0;
 }

+ 1 - 1
frontend/src/pages/Profile/index.vue

@@ -424,7 +424,7 @@ export default {
 		}
 
 		.item {
-			overflow: initial;
+			overflow: hidden;
 
 			&:not(:last-of-type) {
 				margin-bottom: 10px;

+ 242 - 222
frontend/src/pages/ResetPassword.vue

@@ -20,252 +20,272 @@
 
 				<div class="content-box-wrapper">
 					<transition-group name="steps-fade" mode="out-in">
-						<!-- Step 1 -- Enter email address -->
-						<div class="content-box" v-if="step === 1" key="1">
-							<h2 class="content-box-title">
-								Enter your email address
-							</h2>
-							<p class="content-box-description">
-								We will send a code to your email address to
-								verify your identity.
-							</p>
-
-							<p class="content-box-optional-helper">
-								<a @click="step = 2">Already have a code?</a>
-							</p>
-
-							<div class="content-box-inputs">
-								<div
-									class="control is-grouped input-with-button"
-								>
-									<p class="control is-expanded">
-										<input
-											class="input"
-											type="email"
-											placeholder="Enter email address here..."
-											autofocus
-											v-model="email.value"
-											@keyup.enter="submitEmail()"
-											@keypress="onInput('email')"
-											@paste="onInput('email')"
-										/>
-									</p>
-									<p class="control">
-										<button
-											class="button is-info"
-											@click="submitEmail()"
-										>
-											<i
-												class="
-													material-icons
-													icon-with-button
-												"
-												>mail</i
-											>Request
-										</button>
-									</p>
-								</div>
-								<transition name="fadein-helpbox">
-									<input-help-box
-										:entered="email.entered"
-										:valid="email.valid"
-										:message="email.message"
-									/>
-								</transition>
-							</div>
-						</div>
+						<div class="content-box">
+							<!-- Step 1 -- Enter email address -->
+							<div v-if="step === 1" key="1">
+								<h2 class="content-box-title">
+									Enter your email address
+								</h2>
+								<p class="content-box-description">
+									We will send a code to your email address to
+									verify your identity.
+								</p>
 
-						<!-- Step 2 -- Enter code -->
-						<div class="content-box" v-if="step === 2" key="2">
-							<h2 class="content-box-title">
-								Enter the code sent to your email
-							</h2>
-							<p
-								class="content-box-description"
-								v-if="!email.hasBeenSentAlready"
-							>
-								A code has been sent to
-								<strong>{{ email.value }}.</strong>
-							</p>
-
-							<p class="content-box-optional-helper">
-								<a
-									@click="
-										email.value ? submitEmail() : (step = 1)
-									"
-									>Request another code</a
-								>
-							</p>
+								<p class="content-box-optional-helper">
+									<a @click="step = 2"
+										>Already have a code?</a
+									>
+								</p>
 
-							<div class="content-box-inputs">
-								<div
-									class="control is-grouped input-with-button"
-								>
-									<p class="control is-expanded">
-										<input
-											class="input"
-											type="text"
-											placeholder="Enter code here..."
-											autofocus
-											v-model="code"
-											@keyup.enter="verifyCode()"
+								<div class="content-box-inputs">
+									<div
+										class="
+											control
+											is-grouped
+											input-with-button
+										"
+									>
+										<p class="control is-expanded">
+											<input
+												class="input"
+												type="email"
+												placeholder="Enter email address here..."
+												autofocus
+												v-model="email.value"
+												@keyup.enter="submitEmail()"
+												@keypress="onInput('email')"
+												@paste="onInput('email')"
+											/>
+										</p>
+										<p class="control">
+											<button
+												class="button is-info"
+												@click="submitEmail()"
+											>
+												<i
+													class="
+														material-icons
+														icon-with-button
+													"
+													>mail</i
+												>Request
+											</button>
+										</p>
+									</div>
+									<transition name="fadein-helpbox">
+										<input-help-box
+											:entered="email.entered"
+											:valid="email.valid"
+											:message="email.message"
 										/>
-									</p>
-									<p class="control">
-										<button
-											class="button is-info"
-											@click="verifyCode()"
-										>
-											<i
-												class="
-													material-icons
-													icon-with-button
-												"
-												>vpn_key</i
-											>Verify
-										</button>
-									</p>
+									</transition>
 								</div>
 							</div>
-						</div>
 
-						<!-- Step 3 -- Set new password -->
-						<div class="content-box" v-if="step === 3" key="3">
-							<h2 class="content-box-title">
-								Set a new password
-							</h2>
-							<p class="content-box-description">
-								Create a new password for your account.
-							</p>
-
-							<div class="content-box-inputs">
-								<p class="control is-expanded">
-									<label for="new-password"
-										>New password</label
-									>
+							<!-- Step 2 -- Enter code -->
+							<div v-if="step === 2" key="2">
+								<h2 class="content-box-title">
+									Enter the code sent to your email
+								</h2>
+								<p
+									class="content-box-description"
+									v-if="!email.hasBeenSentAlready"
+								>
+									A code has been sent to
+									<strong>{{ email.value }}.</strong>
 								</p>
 
-								<div id="password-visibility-container">
-									<input
-										class="input"
-										id="new-password"
-										type="password"
-										ref="password"
-										placeholder="Enter password here..."
-										v-model="password.value"
-										@keypress="onInput('password')"
-										@paste="onInput('password')"
-									/>
+								<p class="content-box-optional-helper">
 									<a
 										@click="
-											togglePasswordVisibility('password')
+											email.value
+												? submitEmail()
+												: (step = 1)
+										"
+										>Request another code</a
+									>
+								</p>
+
+								<div class="content-box-inputs">
+									<div
+										class="
+											control
+											is-grouped
+											input-with-button
 										"
 									>
-										<i class="material-icons">
-											{{
-												!password.visible
-													? "visibility"
-													: "visibility_off"
-											}}
-										</i>
-									</a>
+										<p class="control is-expanded">
+											<input
+												class="input"
+												type="text"
+												placeholder="Enter code here..."
+												autofocus
+												v-model="code"
+												@keyup.enter="verifyCode()"
+											/>
+										</p>
+										<p class="control">
+											<button
+												class="button is-info"
+												@click="verifyCode()"
+											>
+												<i
+													class="
+														material-icons
+														icon-with-button
+													"
+													>vpn_key</i
+												>Verify
+											</button>
+										</p>
+									</div>
 								</div>
+							</div>
 
-								<transition name="fadein-helpbox">
-									<input-help-box
-										:entered="password.entered"
-										:valid="password.valid"
-										:message="password.message"
-									/>
-								</transition>
+							<!-- Step 3 -- Set new password -->
+							<div v-if="step === 3" key="3">
+								<h2 class="content-box-title">
+									Set a new password
+								</h2>
+								<p class="content-box-description">
+									Create a new password for your account.
+								</p>
 
-								<p
-									id="new-password-again-input"
-									class="control is-expanded"
-								>
-									<label for="new-password-again"
-										>New password again</label
+								<div class="content-box-inputs">
+									<p class="control is-expanded">
+										<label for="new-password"
+											>New password</label
+										>
+									</p>
+
+									<div id="password-visibility-container">
+										<input
+											class="input"
+											id="new-password"
+											type="password"
+											ref="password"
+											placeholder="Enter password here..."
+											v-model="password.value"
+											@keypress="onInput('password')"
+											@paste="onInput('password')"
+										/>
+										<a
+											@click="
+												togglePasswordVisibility(
+													'password'
+												)
+											"
+										>
+											<i class="material-icons">
+												{{
+													!password.visible
+														? "visibility"
+														: "visibility_off"
+												}}
+											</i>
+										</a>
+									</div>
+
+									<transition name="fadein-helpbox">
+										<input-help-box
+											:entered="password.entered"
+											:valid="password.valid"
+											:message="password.message"
+										/>
+									</transition>
+
+									<p
+										id="new-password-again-input"
+										class="control is-expanded"
 									>
-								</p>
+										<label for="new-password-again"
+											>New password again</label
+										>
+									</p>
 
-								<div id="password-visibility-container">
-									<input
-										class="input"
-										id="new-password-again"
-										type="password"
-										ref="passwordAgain"
-										placeholder="Enter password here..."
-										v-model="passwordAgain.value"
-										@keyup.enter="changePassword()"
-										@keypress="onInput('passwordAgain')"
-										@paste="onInput('passwordAgain')"
-									/>
-									<a
-										@click="
-											togglePasswordVisibility(
-												'passwordAgain'
-											)
-										"
+									<div id="password-visibility-container">
+										<input
+											class="input"
+											id="new-password-again"
+											type="password"
+											ref="passwordAgain"
+											placeholder="Enter password here..."
+											v-model="passwordAgain.value"
+											@keyup.enter="changePassword()"
+											@keypress="onInput('passwordAgain')"
+											@paste="onInput('passwordAgain')"
+										/>
+										<a
+											@click="
+												togglePasswordVisibility(
+													'passwordAgain'
+												)
+											"
+										>
+											<i class="material-icons">
+												{{
+													!passwordAgain.visible
+														? "visibility"
+														: "visibility_off"
+												}}
+											</i>
+										</a>
+									</div>
+
+									<transition name="fadein-helpbox">
+										<input-help-box
+											:entered="passwordAgain.entered"
+											:valid="passwordAgain.valid"
+											:message="passwordAgain.message"
+										/>
+									</transition>
+
+									<button
+										id="change-password-button"
+										class="button is-success"
+										@click="changePassword()"
 									>
-										<i class="material-icons">
-											{{
-												!passwordAgain.visible
-													? "visibility"
-													: "visibility_off"
-											}}
-										</i>
-									</a>
+										Change password
+									</button>
 								</div>
-
-								<transition name="fadein-helpbox">
-									<input-help-box
-										:entered="passwordAgain.entered"
-										:valid="passwordAgain.valid"
-										:message="passwordAgain.message"
-									/>
-								</transition>
-
-								<button
-									id="change-password-button"
-									class="button is-success"
-									@click="changePassword()"
-								>
-									Change password
-								</button>
 							</div>
-						</div>
 
-						<div
-							class="content-box reset-status-box"
-							v-if="step === 4"
-							key="4"
-						>
-							<i class="material-icons success-icon"
-								>check_circle</i
-							>
-							<h2>Password successfully {{ mode }}</h2>
-							<router-link class="button is-dark" to="/settings"
-								><i class="material-icons icon-with-button"
-									>undo</i
-								>Return to Settings</router-link
+							<div
+								class="reset-status-box"
+								v-if="step === 4"
+								key="4"
 							>
-						</div>
+								<i class="material-icons success-icon"
+									>check_circle</i
+								>
+								<h2>Password successfully {{ mode }}</h2>
+								<router-link
+									class="button is-dark"
+									to="/settings"
+									><i class="material-icons icon-with-button"
+										>undo</i
+									>Return to Settings</router-link
+								>
+							</div>
 
-						<div
-							class="content-box reset-status-box"
-							v-if="step === 5"
-							key="5"
-						>
-							<i class="material-icons error-icon">error</i>
-							<h2>
-								Password {{ mode }} failed, please try again
-								later
-							</h2>
-							<router-link class="button is-dark" to="/settings"
-								><i class="material-icons icon-with-button"
-									>undo</i
-								>Return to Settings</router-link
+							<div
+								class="reset-status-box"
+								v-if="step === 5"
+								key="5"
 							>
+								<i class="material-icons error-icon">error</i>
+								<h2>
+									Password {{ mode }} failed, please try again
+									later
+								</h2>
+								<router-link
+									class="button is-dark"
+									to="/settings"
+									><i class="material-icons icon-with-button"
+										>undo</i
+									>Return to Settings</router-link
+								>
+							</div>
 						</div>
 					</transition-group>
 				</div>

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

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

+ 1 - 0
frontend/src/pages/Station/Sidebar/Users.vue

@@ -141,6 +141,7 @@ export default {
 
 		.menu-list {
 			padding: 0 10px;
+			margin-left: 0;
 		}
 
 		li {

+ 1 - 1
frontend/src/pages/Station/index.vue

@@ -692,13 +692,13 @@
 				</div>
 
 				<request-song v-if="modals.requestSong" />
-				<edit-playlist v-if="modals.editPlaylist" />
 				<create-playlist v-if="modals.createPlaylist" />
 				<manage-station
 					v-if="modals.manageStation"
 					:station-id="station._id"
 					sector="station"
 				/>
+				<edit-playlist v-if="modals.editPlaylist" />
 				<edit-song
 					v-if="modals.editSong"
 					song-type="songs"

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

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

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

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

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio