Prechádzať zdrojové kódy

Merge tag 'v3.5.0-rc1' into staging

Owen Diffey 3 rokov pred
rodič
commit
2a4bcf0fa3
100 zmenil súbory, kde vykonal 13225 pridanie a 5139 odobranie
  1. 2 0
      .env.example
  2. 7 5
      .github/workflows/build-eslint.yml
  3. 1 0
      .gitignore
  4. 3 1
      .wiki/Configuration.md
  5. 2 1
      .wiki/Utility_Script.md
  6. 38 0
      CHANGELOG.md
  7. 11 6
      README.md
  8. 3 0
      backend/.dockerignore
  9. 3 4
      backend/Dockerfile
  10. 4 4
      backend/index.js
  11. 2 2
      backend/logic/actions/playlists.js
  12. 209 1230
      backend/logic/actions/stations.js
  13. 1 1
      backend/logic/db/index.js
  14. 13 6
      backend/logic/db/schemas/station.js
  15. 87 0
      backend/logic/migration/migrations/migration20.js
  16. 18 18
      backend/logic/playlists.js
  17. 145 154
      backend/logic/stations.js
  18. 316 268
      backend/package-lock.json
  19. 14 14
      backend/package.json
  20. 20 0
      docker-compose.dev.yml
  21. 8 16
      docker-compose.yml
  22. 3 0
      frontend/.dockerignore
  23. 6 9
      frontend/Dockerfile
  24. 3 3
      frontend/dist/index.tpl.html
  25. 8557 7
      frontend/package-lock.json
  26. 27 27
      frontend/package.json
  27. 50 21
      frontend/src/App.vue
  28. 11 6
      frontend/src/aw.js
  29. 4 15
      frontend/src/components/ActivityItem.vue
  30. 6 4
      frontend/src/components/AdvancedTable.vue
  31. 46 0
      frontend/src/components/ModalManager.vue
  32. 9 5
      frontend/src/components/PlaylistItem.vue
  33. 1067 0
      frontend/src/components/PlaylistTabBase.vue
  34. 0 3
      frontend/src/components/PunishmentItem.vue
  35. 26 71
      frontend/src/components/Queue.vue
  36. 395 0
      frontend/src/components/Request.vue
  37. 0 4
      frontend/src/components/RunJobDropdown.vue
  38. 26 23
      frontend/src/components/SongItem.vue
  39. 253 0
      frontend/src/components/StationInfoBox.vue
  40. 24 0
      frontend/src/components/global/InfoIcon.vue
  41. 6 18
      frontend/src/components/global/MainFooter.vue
  42. 0 0
      frontend/src/components/global/MainHeader.vue
  43. 0 0
      frontend/src/components/global/Modal.vue
  44. 0 0
      frontend/src/components/global/QuickConfirm.vue
  45. 0 0
      frontend/src/components/global/UserIdToUsername.vue
  46. 8 6
      frontend/src/components/modals/BulkActions.vue
  47. 13 20
      frontend/src/components/modals/Confirm.vue
  48. 8 6
      frontend/src/components/modals/CreatePlaylist.vue
  49. 19 6
      frontend/src/components/modals/CreateStation.vue
  50. 20 18
      frontend/src/components/modals/EditNews.vue
  51. 16 3
      frontend/src/components/modals/EditPlaylist/Tabs/AddSongs.vue
  52. 6 2
      frontend/src/components/modals/EditPlaylist/Tabs/ImportPlaylists.vue
  53. 5 1
      frontend/src/components/modals/EditPlaylist/Tabs/Settings.vue
  54. 49 24
      frontend/src/components/modals/EditPlaylist/index.vue
  55. 9 3
      frontend/src/components/modals/EditSong/Tabs/Discogs.vue
  56. 12 6
      frontend/src/components/modals/EditSong/Tabs/Reports.vue
  57. 15 5
      frontend/src/components/modals/EditSong/Tabs/Songs.vue
  58. 9 3
      frontend/src/components/modals/EditSong/Tabs/Youtube.vue
  59. 84 37
      frontend/src/components/modals/EditSong/index.vue
  60. 37 36
      frontend/src/components/modals/EditSongs.vue
  61. 42 29
      frontend/src/components/modals/EditUser.vue
  62. 22 17
      frontend/src/components/modals/ImportAlbum.vue
  63. 25 17
      frontend/src/components/modals/ImportPlaylist.vue
  64. 0 4
      frontend/src/components/modals/Login.vue
  65. 379 0
      frontend/src/components/modals/ManageStation/Settings.vue
  66. 0 171
      frontend/src/components/modals/ManageStation/Tabs/Blacklist.vue
  67. 0 763
      frontend/src/components/modals/ManageStation/Tabs/Playlists.vue
  68. 0 629
      frontend/src/components/modals/ManageStation/Tabs/Settings.vue
  69. 0 188
      frontend/src/components/modals/ManageStation/Tabs/Songs.vue
  70. 436 423
      frontend/src/components/modals/ManageStation/index.vue
  71. 1 2
      frontend/src/components/modals/Register.vue
  72. 3 4
      frontend/src/components/modals/RemoveAccount.vue
  73. 22 20
      frontend/src/components/modals/Report.vue
  74. 17 7
      frontend/src/components/modals/ViewPunishment.vue
  75. 15 13
      frontend/src/components/modals/ViewReport.vue
  76. 33 73
      frontend/src/components/modals/WhatIsNew.vue
  77. 14 3
      frontend/src/main.js
  78. 1 0
      frontend/src/ms.js
  79. 0 9
      frontend/src/pages/404.vue
  80. 0 9
      frontend/src/pages/About.vue
  81. 17 26
      frontend/src/pages/Admin/News.vue
  82. 9 30
      frontend/src/pages/Admin/Playlists.vue
  83. 9 24
      frontend/src/pages/Admin/Punishments.vue
  84. 9 30
      frontend/src/pages/Admin/Reports.vue
  85. 60 90
      frontend/src/pages/Admin/Songs.vue
  86. 172 95
      frontend/src/pages/Admin/Stations.vue
  87. 1 3
      frontend/src/pages/Admin/Users/DataRequests.vue
  88. 2 18
      frontend/src/pages/Admin/Users/index.vue
  89. 0 4
      frontend/src/pages/Admin/index.vue
  90. 132 133
      frontend/src/pages/Home.vue
  91. 0 5
      frontend/src/pages/News.vue
  92. 0 9
      frontend/src/pages/Privacy.vue
  93. 15 20
      frontend/src/pages/Profile/Tabs/Playlists.vue
  94. 1 5
      frontend/src/pages/Profile/Tabs/RecentActivity.vue
  95. 2 27
      frontend/src/pages/Profile/index.vue
  96. 1 3
      frontend/src/pages/ResetPassword.vue
  97. 1 3
      frontend/src/pages/Settings/Tabs/Account.vue
  98. 4 3
      frontend/src/pages/Settings/Tabs/Security.vue
  99. 1 14
      frontend/src/pages/Settings/index.vue
  100. 43 94
      frontend/src/pages/Station/Sidebar/Playlists.vue

+ 2 - 0
.env.example

@@ -1,5 +1,7 @@
 COMPOSE_PROJECT_NAME=musare
 RESTART_POLICY=unless-stopped
+CONTAINER_MODE=prod
+DOCKER_COMMAND=docker
 
 BACKEND_HOST=127.0.0.1
 BACKEND_PORT=8080

+ 7 - 5
.github/workflows/build-eslint.yml

@@ -4,6 +4,8 @@ on: [ push, pull_request, workflow_dispatch ]
 
 env:
     COMPOSE_PROJECT_NAME: musare
+    RESTART_POLICY: unless-stopped
+    CONTAINER_MODE: prod
     BACKEND_HOST: 127.0.0.1
     BACKEND_PORT: 8080
     FRONTEND_HOST: 127.0.0.1
@@ -28,13 +30,13 @@ jobs:
             - uses: actions/checkout@v2
             - name: Build Musare
               run: |
+                  cp .env.example .env
                   cp backend/config/template.json backend/config/default.json
                   cp frontend/dist/config/template.json frontend/dist/config/default.json
-                  docker-compose build
-                  docker-compose pull
+                  ./musare.sh build
             - name: Start Musare
-              run: docker-compose up -d
+              run: ./musare.sh start
             - name: ESlint Backend
-              run: docker-compose exec -T backend /bin/bash -c "npx eslint app/logic"
+              run: ./musare.sh eslint backend
             - name: ESLint Frontend
-              run: docker-compose exec -T frontend /bin/bash -c "cd app && npm run lint"
+              run: ./musare.sh eslint frontend

+ 1 - 0
.gitignore

@@ -13,6 +13,7 @@ startMongo.cmd
 .redis
 *.rdb
 backups/
+docker-compose.override.yml
 
 npm-debug.log
 lerna-debug.log

+ 3 - 1
.wiki/Configuration.md

@@ -100,11 +100,13 @@ The container port refers to the external docker container port, used to access
 | --- | --- |
 | `COMPOSE_PROJECT_NAME` | Should be a unique name for this installation, especially if you have multiple instances of Musare on the same machine. |
 | `RESTART_POLICY` | Restart policy for docker containers, values can be found [here](https://docs.docker.com/config/containers/start-containers-automatically/). |
+| `CONTAINER_MODE` | Should be either `prod` or `dev`.  |
+| `DOCKER_COMMAND` | Should be either `docker` or `podman`.  |
 | `BACKEND_HOST` | Backend container host. |
 | `BACKEND_PORT` | Backend container port. |
 | `FRONTEND_HOST` | Frontend container host. |
 | `FRONTEND_PORT` | Frontend container port. |
-| `FRONTEND_MODE` | Should be either `dev` or `prod`. |
+| `FRONTEND_MODE` | Should be either `prod` or `dev`. |
 | `MONGO_HOST` | Mongo container host. |
 | `MONGO_PORT` | Mongo container port. |
 | `MONGO_ROOT_PASSWORD` | Password of the root/admin user for MongoDB. |

+ 2 - 1
.wiki/Utility_Script.md

@@ -15,9 +15,10 @@ Linux (Bash):
 | `start` | `[frontend backend redis mongo]` | Start service(s). |
 | `stop` | `[frontend backend redis mongo]` | Stop service(s). |
 | `restart` | `[frontend backend redis mongo]` | Restart service(s). |
+| `status` | `[frontend backend redis mongo]` | View status for service(s). |
 | `logs` | `[frontend backend redis mongo]` | View logs for service(s). |
 | `update` | `[auto]` | Update Musare. When auto is specified the update will be cancelled if there are any changes requiring manual intervention, allowing you to run this unattended. |
-| `attach` | `<backend,mongo>` | Attach to backend server or mongodb shell. |
+| `attach` | `<backend,mongo,redis>` | Attach to backend server, mongodb or redis shell. |
 | `build` | `[frontend backend]` | Build service(s). |
 | `eslint` | `[frontend backend] [fix]` | Run eslint on frontend and/or backend. Specify fix to auto fix issues where possible. |
 | `backup` | | Backup database data to file. Configured in .env file. |

+ 38 - 0
CHANGELOG.md

@@ -1,5 +1,43 @@
 # Changelog
 
+## [v3.5.0-rc1] - 2022-04-14
+
+### Added
+- feat: Station autofill configurable limit
+- feat: Station requests configurable access level
+- feat: Station requests configurable per user request limit
+- feat: Added redis attach command to musare.sh
+- feat: Added podman support to musare.sh
+- feat: Added view station button to admin/stations
+- feat: Added info icon component
+
+### Changed
+- refactor: No longer showing unlisted stations on homepage if not owned by user unless toggled by admin
+- refactor: Renamed station excludedPlaylists to blacklist
+- refactor: Unified station update functions and events
+- refactor: Replaced Manage Station settings dropdowns with select elements
+- refactor: Use a local object to edit stations before saving
+- refactor: Replace station modes with 2 modules which are independently toggleable and configurable on every station
+    - Requests: Replaces party mode, users can request songs or auto request from playlists
+    - Autofill: Replaces playlist mode, owners select songs to autofill queue. Also includes old playMode and includedPlaylist functionality
+- refactor: Update active team
+- refactor: Separate docker container modes
+- refactor: Improve musare.sh exit code usage and other tweaks
+- refactor: Made Main Header/Footer, Modal, QuickConfirm and UserIdToUsername global components
+- refactor: Use crypto random values instead of math.random to create UUID
+- refactor: Added trailing slash to URL startsWith check
+- chore: Updated frontend package-lock.json version from 1 to 2
+- refactor: Increased site name configuration usage
+- refactor: Disable pseudo-tty for musare.sh eslint commands
+- refactor: Migrated all modals to new more modular system
+- refactor: Made station info box a component for Station and Manage Station
+
+### Fixed
+- fix: Changing station privacy does not kick out newly-unauthorized users
+
+### Removed
+- refactor: Removed station queue lock
+
 ## [v3.4.0] - 2022-03-27
 
 ### **Breaking Changes**

+ 11 - 6
README.md

@@ -29,21 +29,26 @@ A production demonstration instance of Musare can be found at [demo.musare.com](
     - Add songs from verified catalogue or YouTube
     - Ability to download in JSON format
 - **Stations**
-    - Playlist mode to listen to selected playlists
-    - Party mode to allow other users to add songs to queue
+    - Requests - Toggleable module to allow users to add songs to the queue
+        - Configurable access level and per user request limit
+        - Automatically request songs from selected playlists
+        - Ability to search for songs from verified catalogue or YouTube
+    - Autofill - Toggleable module to allow owners to configure automatic filling of the queue from selected playlists
+        - Configurable song limit
+        - Play mode option to randomly play many playlists, or sequentially play one playlist
+        - Ability to search for playlists on Musare
     - Ability to blacklist playlists to prevent songs within from playing
     - Themes
     - Privacy configuration
     - Favoriting
-    - Official stations controlled by admins (playlist mode only)
+    - Official stations controlled by admins
     - User created and controlled stations
     - Pause playback just in local session
     - Station-wide pausing by admins or owners
     - Vote to skip songs
     - Force skipping song by admins or owners
-    - Add songs to queue from verified catalogue or YouTube (party mode only)
 - **Song Management**
-    - Verify songs to allow them to be searched for and played in official stations
+    - Verify songs to allow them to be searched for and added to automatically generated genre playlists
     - Import Album to import songs in bulk
     - Discogs integration to import metadata
     - Ability for users to report issues with songs and admins to resolve
@@ -67,7 +72,7 @@ A production demonstration instance of Musare can be found at [demo.musare.com](
 - **News**
     - Admins can add/edit/remove news items
     - Markdown editor
-- **Dark Mode**
+- **Night Mode**
 - **Administration**
     - Admin area to manage instance
     - Configurable data tables

+ 3 - 0
backend/.dockerignore

@@ -0,0 +1,3 @@
+node_modules/
+Dockerfile
+config/default.json

+ 3 - 4
backend/Dockerfile

@@ -2,10 +2,9 @@ FROM node:16
 
 RUN npm install -g nodemon
 
-RUN mkdir -p /opt
-WORKDIR /opt
-ADD package.json /opt/package.json
-ADD package-lock.json /opt/package-lock.json
+RUN mkdir -p /opt/app
+WORKDIR /opt/app
+ADD ./ /opt/app
 
 RUN npm install
 

+ 4 - 4
backend/index.js

@@ -38,12 +38,12 @@ const printVersion = () => {
 	console.log(`Musare version: ${MUSARE_VERSION}.`);
 
 	try {
-		const head_contents = fs.readFileSync("app/.parent_git/HEAD").toString().replaceAll("\n", "");
-		const branch = new RegExp("ref: refs/heads/([\.A-Za-z0-9_-]+)").exec(head_contents)[1];
-		const config_contents = fs.readFileSync("app/.parent_git/config").toString().replaceAll("\t", "").split("\n");
+		const head_contents = fs.readFileSync(".parent_git/HEAD").toString().replaceAll("\n", "");
+		const branch = new RegExp("ref: refs/heads/([A-Za-z0-9_.-]+)").exec(head_contents)[1];
+		const config_contents = fs.readFileSync(".parent_git/config").toString().replaceAll("\t", "").split("\n");
 		const remote = new RegExp("remote = (.+)").exec(config_contents[config_contents.indexOf(`[branch "${branch}"]`) + 1])[1];
 		const remote_url = new RegExp("url = (.+)").exec(config_contents[config_contents.indexOf(`[remote "${remote}"]`) + 1])[1];
-		const latest_commit = fs.readFileSync(`app/.parent_git/refs/heads/${branch}`).toString().replaceAll("\n", "");
+		const latest_commit = fs.readFileSync(`.parent_git/refs/heads/${branch}`).toString().replaceAll("\n", "");
 		const latest_commit_short = latest_commit.substr(0, 7);
 
 		console.log(`Git branch: ${remote}/${branch}. Remote url: ${remote_url}. Latest commit: ${latest_commit} (${latest_commit_short}).`);

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

@@ -1233,7 +1233,7 @@ export default {
 					});
 				}
 
-				StationsModule.runJob("GET_STATIONS_THAT_INCLUDE_OR_EXCLUDE_PLAYLIST", { playlistId })
+				StationsModule.runJob("GET_STATIONS_THAT_AUTOFILL_OR_BLACKLIST_PLAYLIST", { playlistId })
 					.then(response => {
 						response.stationIds.forEach(stationId => {
 							PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
@@ -1518,7 +1518,7 @@ export default {
 				},
 
 				(playlist, next) => {
-					StationsModule.runJob("GET_STATIONS_THAT_INCLUDE_OR_EXCLUDE_PLAYLIST", { playlistId })
+					StationsModule.runJob("GET_STATIONS_THAT_AUTOFILL_OR_BLACKLIST_PLAYLIST", { playlistId })
 						.then(response => {
 							response.stationIds.forEach(stationId => {
 								PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 209 - 1230
backend/logic/actions/stations.js


+ 1 - 1
backend/logic/db/index.js

@@ -13,7 +13,7 @@ const REQUIRED_DOCUMENT_VERSIONS = {
 	queueSong: 1,
 	report: 5,
 	song: 7,
-	station: 7,
+	station: 8,
 	user: 3
 };
 

+ 13 - 6
backend/logic/db/schemas/station.js

@@ -25,7 +25,6 @@ export default {
 	startedAt: { type: Number, default: 0, required: true },
 	playlist: { type: mongoose.Schema.Types.ObjectId, required: true },
 	privacy: { type: String, enum: ["public", "unlisted", "private"], default: "private" },
-	locked: { type: Boolean, default: false },
 	queue: [
 		{
 			_id: { type: mongoose.Schema.Types.ObjectId, required: true },
@@ -41,10 +40,18 @@ export default {
 		}
 	],
 	owner: { type: String },
-	partyMode: { type: Boolean },
-	playMode: { type: String, enum: ["random", "sequential"], default: "random" },
+	requests: {
+		enabled: { type: Boolean, default: true },
+		access: { type: String, enum: ["owner", "user"], default: "owner" },
+		limit: { type: Number, min: 1, max: 50, default: 5 }
+	},
+	autofill: {
+		enabled: { type: Boolean, default: true },
+		playlists: [{ type: mongoose.Schema.Types.ObjectId, ref: "playlists" }],
+		limit: { type: Number, min: 1, max: 50, default: 30 },
+		mode: { type: String, enum: ["random", "sequential"], default: "random" }
+	},
 	theme: { type: String, enum: ["blue", "purple", "teal", "orange", "red"], default: "blue" },
-	includedPlaylists: [{ type: String }],
-	excludedPlaylists: [{ type: String }],
-	documentVersion: { type: Number, default: 7, required: true }
+	blacklist: [{ type: mongoose.Schema.Types.ObjectId, ref: "playlists" }],
+	documentVersion: { type: Number, default: 8, required: true }
 };

+ 87 - 0
backend/logic/migration/migrations/migration20.js

@@ -0,0 +1,87 @@
+import async from "async";
+import mongoose from "mongoose";
+
+/**
+ * Migration 20
+ *
+ * Migration for station overhaul (WIP)
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const stationModel = await MigrationModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 20. Finding stations with document version 7.`);
+					stationModel.find(
+						{
+							documentVersion: 7
+						},
+						(err, stations) => {
+							if (err) next(err);
+							else {
+								async.eachLimit(
+									stations.map(station => station._doc),
+									1,
+									(station, next) => {
+										stationModel.updateOne(
+											{ _id: station._id },
+											{
+												$unset: {
+													includedPlaylists: "",
+													excludedPlaylists: "",
+													playMode: "",
+													partyMode: "",
+													locked: ""
+												},
+												$set: {
+													queue: station.queue.map(song => {
+														if (!song.requestedAt) song.requestedAt = Date.now();
+														return song;
+													}),
+													autofill: {
+														enabled: !station.partyMode,
+														playlists: station.includedPlaylists.map(playlist =>
+															mongoose.Types.ObjectId(playlist)
+														),
+														limit: 30,
+														mode: station.playMode ? station.playMode : "random"
+													},
+													requests: {
+														enabled: !!station.partyMode,
+														access:
+															station.locked || station.type === "official"
+																? "owner"
+																: "user",
+														limit: 5
+													},
+													blacklist: station.excludedPlaylists.map(playlist =>
+														mongoose.Types.ObjectId(playlist)
+													),
+													documentVersion: 8
+												}
+											},
+											next
+										);
+									},
+									err => {
+										this.log("INFO", `Migration 20. Stations found: ${stations.length}.`);
+										next(err);
+									}
+								);
+							}
+						}
+					);
+				}
+			],
+			err => {
+				if (err) reject(new Error(err));
+				else resolve();
+			}
+		);
+	});
+}

+ 18 - 18
backend/logic/playlists.js

@@ -638,7 +638,7 @@ class _PlaylistsModule extends CoreClass {
 					},
 
 					(playlistId, next) => {
-						StationsModule.runJob("GET_STATIONS_THAT_INCLUDE_OR_EXCLUDE_PLAYLIST", { playlistId }, this)
+						StationsModule.runJob("GET_STATIONS_THAT_AUTOFILL_OR_BLACKLIST_PLAYLIST", { playlistId }, this)
 							.then(response => {
 								async.eachLimit(
 									response.stationIds,
@@ -690,7 +690,7 @@ class _PlaylistsModule extends CoreClass {
 								.then(response => {
 									if (response.songs.length === 0) {
 										StationsModule.runJob(
-											"GET_STATIONS_THAT_INCLUDE_OR_EXCLUDE_PLAYLIST",
+											"GET_STATIONS_THAT_AUTOFILL_OR_BLACKLIST_PLAYLIST",
 											{ playlistId: playlist._id },
 											this
 										)
@@ -1038,59 +1038,59 @@ class _PlaylistsModule extends CoreClass {
 					},
 
 					(station, next) => {
-						const includedPlaylists = [];
+						const playlists = [];
 						async.eachLimit(
-							station.includedPlaylists,
+							station.autofill.playlists,
 							1,
 							(playlistId, next) => {
 								PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
 									.then(playlist => {
-										includedPlaylists.push(playlist);
+										playlists.push(playlist);
 										next();
 									})
 									.catch(next);
 							},
 							err => {
-								next(err, station, includedPlaylists);
+								next(err, station, playlists);
 							}
 						);
 					},
 
-					(station, includedPlaylists, next) => {
-						const excludedPlaylists = [];
+					(station, playlists, next) => {
+						const blacklist = [];
 						async.eachLimit(
-							station.excludedPlaylists,
+							station.blacklist,
 							1,
 							(playlistId, next) => {
 								PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
 									.then(playlist => {
-										excludedPlaylists.push(playlist);
+										blacklist.push(playlist);
 										next();
 									})
 									.catch(next);
 							},
 							err => {
-								next(err, station, includedPlaylists, excludedPlaylists);
+								next(err, station, playlists, blacklist);
 							}
 						);
 					},
 
-					(station, includedPlaylists, excludedPlaylists, next) => {
-						const excludedSongs = excludedPlaylists
-							.flatMap(excludedPlaylist => excludedPlaylist.songs)
+					(station, playlists, blacklist, next) => {
+						const blacklistedSongs = blacklist
+							.flatMap(blacklistedPlaylist => blacklistedPlaylist.songs)
 							.reduce(
 								(items, item) =>
 									items.find(x => x.youtubeId === item.youtubeId) ? [...items] : [...items, item],
 								[]
 							);
-						const includedSongs = includedPlaylists
-							.flatMap(includedPlaylist => includedPlaylist.songs)
+						const includedSongs = playlists
+							.flatMap(playlist => playlist.songs)
 							.reduce(
 								(songs, song) =>
 									songs.find(x => x.youtubeId === song.youtubeId) ? [...songs] : [...songs, song],
 								[]
 							)
-							.filter(song => !excludedSongs.find(x => x.youtubeId === song.youtubeId));
+							.filter(song => !blacklistedSongs.find(x => x.youtubeId === song.youtubeId));
 
 						next(null, station, includedSongs);
 					},
@@ -1276,7 +1276,7 @@ class _PlaylistsModule extends CoreClass {
 
 					next => {
 						StationsModule.runJob(
-							"REMOVE_INCLUDED_OR_EXCLUDED_PLAYLIST_FROM_STATIONS",
+							"REMOVE_AUTOFILLED_OR_BLACKLISTED_PLAYLIST_FROM_STATIONS",
 							{ playlistId: payload.playlistId },
 							this
 						)

+ 145 - 154
backend/logic/stations.js

@@ -63,23 +63,16 @@ class _StationsModule extends CoreClass {
 							stationId
 						}).then();
 					}
-				});
-			}
-		});
 
-		CacheModule.runJob("SUB", {
-			channel: "station.newOfficialPlaylist",
-			cb: async stationId => {
-				CacheModule.runJob("HGET", {
-					table: "officialPlaylists",
-					key: stationId
-				}).then(playlistObj => {
-					if (playlistObj) {
-						WSModule.runJob("EMIT_TO_ROOM", {
-							room: `station.${stationId}`,
-							args: ["event:newOfficialPlaylist", { data: { playlist: playlistObj.songs } }]
-						});
-					}
+					WSModule.runJob("EMIT_TO_ROOM", {
+						room: `station.${stationId}`,
+						args: ["event:station.queue.updated", { data: { queue: station.queue } }]
+					});
+
+					WSModule.runJob("EMIT_TO_ROOM", {
+						room: `manage-station.${stationId}`,
+						args: ["event:manageStation.queue.updated", { data: { stationId, queue: station.queue } }]
+					});
 				});
 			}
 		});
@@ -449,14 +442,14 @@ class _StationsModule extends CoreClass {
 	}
 
 	/**
-	 * Fills up the official station playlist queue using the songs from the official station playlist
+	 * Autofill station queue from station playlist
 	 *
 	 * @param {object} payload - object that contains the payload
 	 * @param {string} payload.stationId - the id of the station
 	 * @param {string} payload.ignoreExistingQueue - ignore the existing queue songs, replacing the old queue with a completely fresh one
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
-	FILL_UP_STATION_QUEUE_FROM_STATION_PLAYLIST(payload) {
+	AUTOFILL_STATION(payload) {
 		return new Promise((resolve, reject) => {
 			const { stationId, ignoreExistingQueue } = payload;
 			async.waterfall(
@@ -472,14 +465,22 @@ class _StationsModule extends CoreClass {
 					(playlist, next) => {
 						StationsModule.runJob("GET_STATION", { stationId }, this)
 							.then(station => {
+								if (!station.autofill.enabled) return next("Autofill is disabled in this station");
+								if (
+									!ignoreExistingQueue &&
+									station.autofill.limit <= station.queue.filter(song => !song.requestedBy).length
+								)
+									return next("Autofill limit reached");
+
 								if (ignoreExistingQueue) station.queue = [];
-								next(null, playlist, station);
+
+								return next(null, playlist, station);
 							})
 							.catch(next);
 					},
 
 					(playlist, station, next) => {
-						if (station.playMode === "random") {
+						if (station.autofill.mode === "random") {
 							UtilsModule.runJob("SHUFFLE", { array: playlist.songs }, this)
 								.then(response => {
 									next(null, response.array, station);
@@ -490,13 +491,14 @@ class _StationsModule extends CoreClass {
 
 					(_playlistSongs, station, next) => {
 						let playlistSongs = JSON.parse(JSON.stringify(_playlistSongs));
-						if (station.playMode === "sequential") {
+						if (station.autofill.mode === "sequential") {
 							if (station.currentSongIndex <= playlistSongs.length) {
 								const songsToAddToEnd = playlistSongs.splice(0, station.currentSongIndex);
 								playlistSongs = [...playlistSongs, ...songsToAddToEnd];
 							}
 						}
-						const songsStillNeeded = 50 - station.queue.length;
+						const currentRequests = station.queue.filter(song => !song.requestedBy).length;
+						const songsStillNeeded = station.autofill.limit - currentRequests;
 						const currentSongs = station.queue;
 						const currentYoutubeIds = station.queue.map(song => song.youtubeId);
 						const songsToAdd = [];
@@ -520,7 +522,7 @@ class _StationsModule extends CoreClass {
 
 						let { currentSongIndex } = station;
 
-						if (station.playMode === "sequential" && lastSongAdded) {
+						if (station.autofill.mode === "sequential" && lastSongAdded) {
 							const indexOfLastSong = _playlistSongs
 								.map(song => song.youtubeId)
 								.indexOf(lastSongAdded.youtubeId);
@@ -556,6 +558,7 @@ class _StationsModule extends CoreClass {
 					(currentSongs, songsToAdd, currentSongIndex, next) => {
 						const newPlaylist = [...currentSongs, ...songsToAdd].map(song => {
 							if (!song._id) song._id = null;
+							song.requestedAt = Date.now();
 							return song;
 						});
 						next(null, newPlaylist, currentSongIndex);
@@ -746,85 +749,38 @@ class _StationsModule extends CoreClass {
 							.catch(next);
 					},
 
-					// eslint-disable-next-line consistent-return
 					(station, next) => {
 						if (!station) return next("Station not found.");
 
-						if (station.type === "community" && station.partyMode && station.queue.length === 0)
-							return next(null, null, station); // Community station with party mode enabled and no songs in the queue
-
-						if (station.type === "community" && station.partyMode && station.queue.length > 0) {
-							// Community station with party mode enabled and songs in the queue
-							if (station.paused) return next(null, null, station);
-
-							StationsModule.runJob("GET_NEXT_STATION_SONG", { stationId: station._id }, this)
-								.then(response => {
-									StationsModule.runJob(
-										"REMOVE_FIRST_QUEUE_SONG",
-										{ stationId: station._id },
-										this
-									).then(() => {
-										next(null, response.song, station);
-									});
-								})
+						if (station.autofill.enabled)
+							return StationsModule.runJob("AUTOFILL_STATION", { stationId: station._id }, this)
+								.then(() => next(null, station))
 								.catch(err => {
-									if (err === "No songs available.") next(null, null, station);
-									else next(err);
+									if (
+										err === "Autofill is disabled in this station" ||
+										err === "Autofill limit reached"
+									)
+										return next(null, station);
+									return next(err);
 								});
-						}
-
-						if (station.type === "community" && !station.partyMode) {
-							StationsModule.runJob(
-								"FILL_UP_STATION_QUEUE_FROM_STATION_PLAYLIST",
-								{ stationId: station._id },
-								this
-							)
-								.then(() => {
-									StationsModule.runJob("GET_NEXT_STATION_SONG", { stationId: station._id }, this)
-										.then(response => {
-											StationsModule.runJob(
-												"REMOVE_FIRST_QUEUE_SONG",
-												{ stationId: station._id },
-												this
-											).then(() => {
-												next(null, response.song, station);
-											});
-										})
-										.catch(err => {
-											if (err === "No songs available.") next(null, null, station);
-											else next(err);
-										});
-								})
-								.catch(next);
-						}
+						return next(null, station);
+					},
 
-						if (station.type === "official") {
-							StationsModule.runJob(
-								"FILL_UP_STATION_QUEUE_FROM_STATION_PLAYLIST",
-								{ stationId: station._id },
-								this
-							)
-								.then(() => {
-									StationsModule.runJob("GET_NEXT_STATION_SONG", { stationId: station._id }, this)
-										.then(response => {
-											StationsModule.runJob(
-												"REMOVE_FIRST_QUEUE_SONG",
-												{ stationId: station._id },
-												this
-											)
-												.then(() => {
-													next(null, response.song, station);
-												})
-												.catch(next);
-										})
-										.catch(err => {
-											if (err === "No songs available.") next(null, null, station);
-											else next(err);
-										});
-								})
-								.catch(next);
-						}
+					(station, next) => {
+						StationsModule.runJob("GET_NEXT_STATION_SONG", { stationId: station._id }, this)
+							.then(response => {
+								StationsModule.runJob("REMOVE_FIRST_QUEUE_SONG", { stationId: station._id }, this)
+									.then(() => {
+										next(null, response.song, station);
+									})
+									.catch(next);
+							})
+							.catch(err => {
+								if (err === "No songs available.") next(null, null, station);
+								else next(err);
+							});
 					},
+
 					(song, station, next) => {
 						const $set = {};
 
@@ -856,12 +812,6 @@ class _StationsModule extends CoreClass {
 
 							return StationsModule.runJob("UPDATE_STATION", { stationId: station._id }, this)
 								.then(station => {
-									CacheModule.runJob("PUB", {
-										channel: "station.queueUpdate",
-										value: payload.stationId
-									})
-										.then()
-										.catch();
 									next(null, station, song);
 								})
 								.catch(next);
@@ -875,9 +825,39 @@ class _StationsModule extends CoreClass {
 							station.currentSong.skipVotes = 0;
 						}
 						next(null, station);
-					}
+					},
+
+					(station, next) => {
+						if (station.autofill.enabled)
+							return StationsModule.runJob("AUTOFILL_STATION", { stationId: station._id }, this)
+								.then(() => next(null, station))
+								.catch(err => {
+									if (
+										err === "Autofill is disabled in this station" ||
+										err === "Autofill limit reached"
+									)
+										return next(null, station);
+									return next(err);
+								});
+						return next(null, station);
+					},
+
+					(station, next) =>
+						StationsModule.runJob("UPDATE_STATION", { stationId: station._id }, this)
+							.then(station => {
+								CacheModule.runJob("PUB", {
+									channel: "station.queueUpdate",
+									value: payload.stationId
+								})
+									.then()
+									.catch();
+								next(null, station);
+							})
+							.catch(next)
 				],
 				async (err, station) => {
+					if (err === "Autofill limit reached") return resolve({ station });
+
 					if (err) {
 						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 						StationsModule.log("ERROR", `Skipping station "${payload.stationId}" failed. "${err}"`);
@@ -984,7 +964,6 @@ class _StationsModule extends CoreClass {
 	 * @param {object} payload - object that contains the payload
 	 * @param {object} payload.station - the station object of the station in question
 	 * @param {string} payload.userId - the id of the user in question
-	 * @param {boolean} payload.hideUnlisted - whether the user is allowed to see unlisted stations or not
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	CAN_USER_VIEW_STATION(payload) {
@@ -992,10 +971,8 @@ class _StationsModule extends CoreClass {
 			async.waterfall(
 				[
 					next => {
-						if (payload.station.privacy === "public") return next(true);
-						if (payload.station.privacy === "unlisted")
-							if (payload.hideUnlisted === true) return next();
-							else return next(true);
+						if (payload.station.privacy === "public" || payload.station.privacy === "unlisted")
+							return next(true);
 						if (!payload.userId) return next("Not allowed");
 
 						return next();
@@ -1009,9 +986,8 @@ class _StationsModule extends CoreClass {
 
 					(user, next) => {
 						if (!user) return next("Not allowed");
-						if (user.role === "admin") return next(true);
+						if (user.role === "admin" || payload.station.owner === payload.userId) return next(true);
 						if (payload.station.type === "official") return next("Not allowed");
-						if (payload.station.owner === payload.userId) return next(true);
 
 						return next("Not allowed");
 					}
@@ -1191,14 +1167,14 @@ class _StationsModule extends CoreClass {
 	}
 
 	/**
-	 * Adds a playlist to be included in a station
+	 * Adds a playlist to autofill a station
 	 *
 	 * @param {object} payload - object that contains the payload
-	 * @param {object} payload.stationId - the id of the station to include the playlist in
-	 * @param {object} payload.playlistId - the id of the playlist to be included
+	 * @param {object} payload.stationId - the id of the station
+	 * @param {object} payload.playlistId - the id of the playlist
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
-	INCLUDE_PLAYLIST(payload) {
+	AUTOFILL_PLAYLIST(payload) {
 		return new Promise((resolve, reject) => {
 			async.waterfall(
 				[
@@ -1217,13 +1193,11 @@ class _StationsModule extends CoreClass {
 					},
 
 					(station, next) => {
-						if (station.playlist === payload.playlistId) next("You cannot include the station playlist");
-						else if (station.includedPlaylists.indexOf(payload.playlistId) !== -1)
-							next("This playlist is already included");
-						else if (station.excludedPlaylists.indexOf(payload.playlistId) !== -1)
-							next(
-								"This playlist is currently excluded, please remove it from there before including it"
-							);
+						if (station.playlist === payload.playlistId) next("You cannot autofill the station playlist");
+						else if (station.autofill.playlists.indexOf(payload.playlistId) !== -1)
+							next("This playlist is already autofilling");
+						else if (station.blacklist.indexOf(payload.playlistId) !== -1)
+							next("This playlist is currently blacklisted");
 						else
 							PlaylistsModule.runJob("GET_PLAYLIST", { playlistId: payload.playlistId }, this)
 								.then(() => {
@@ -1242,7 +1216,7 @@ class _StationsModule extends CoreClass {
 						).then(stationModel => {
 							stationModel.updateOne(
 								{ _id: payload.stationId },
-								{ $push: { includedPlaylists: payload.playlistId } },
+								{ $push: { "autofill.playlists": payload.playlistId } },
 								next
 							);
 						});
@@ -1275,14 +1249,14 @@ class _StationsModule extends CoreClass {
 	}
 
 	/**
-	 * Removes a playlist that is included in a station
+	 * Removes a playlist from autofill
 	 *
 	 * @param {object} payload - object that contains the payload
 	 * @param {object} payload.stationId - the id of the station
 	 * @param {object} payload.playlistId - the id of the playlist
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
-	REMOVE_INCLUDED_PLAYLIST(payload) {
+	REMOVE_AUTOFILL_PLAYLIST(payload) {
 		return new Promise((resolve, reject) => {
 			async.waterfall(
 				[
@@ -1301,8 +1275,8 @@ class _StationsModule extends CoreClass {
 					},
 
 					(station, next) => {
-						if (station.includedPlaylists.indexOf(payload.playlistId) === -1)
-							next("This playlist isn't included");
+						if (station.autofill.playlists.indexOf(payload.playlistId) === -1)
+							next("This playlist isn't autofilling");
 						else next();
 					},
 
@@ -1316,7 +1290,7 @@ class _StationsModule extends CoreClass {
 						).then(stationModel => {
 							stationModel.updateOne(
 								{ _id: payload.stationId },
-								{ $pull: { includedPlaylists: payload.playlistId } },
+								{ $pull: { "autofill.playlists": payload.playlistId } },
 								next
 							);
 						});
@@ -1349,14 +1323,14 @@ class _StationsModule extends CoreClass {
 	}
 
 	/**
-	 * Adds a playlist to be excluded in a station
+	 * Add a playlist to station blacklist
 	 *
 	 * @param {object} payload - object that contains the payload
 	 * @param {object} payload.stationId - the id of the station
 	 * @param {object} payload.playlistId - the id of the playlist
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
-	EXCLUDE_PLAYLIST(payload) {
+	BLACKLIST_PLAYLIST(payload) {
 		return new Promise((resolve, reject) => {
 			async.waterfall(
 				[
@@ -1375,12 +1349,12 @@ class _StationsModule extends CoreClass {
 					},
 
 					(station, next) => {
-						if (station.playlist === payload.playlistId) next("You cannot exclude the station playlist");
-						else if (station.excludedPlaylists.indexOf(payload.playlistId) !== -1)
-							next("This playlist is already excluded");
-						else if (station.includedPlaylists.indexOf(payload.playlistId) !== -1)
+						if (station.playlist === payload.playlistId) next("You cannot blacklist the station playlist");
+						else if (station.blacklist.indexOf(payload.playlistId) !== -1)
+							next("This playlist is already blacklisted");
+						else if (station.autofill.playlists.indexOf(payload.playlistId) !== -1)
 							next(
-								"This playlist is currently included, please remove it from there before excluding it"
+								"This playlist is currently autofilling, please remove it from there before blacklisting it"
 							);
 						else
 							PlaylistsModule.runJob("GET_PLAYLIST", { playlistId: payload.playlistId }, this)
@@ -1400,7 +1374,7 @@ class _StationsModule extends CoreClass {
 						).then(stationModel => {
 							stationModel.updateOne(
 								{ _id: payload.stationId },
-								{ $push: { excludedPlaylists: payload.playlistId } },
+								{ $push: { blacklist: payload.playlistId } },
 								next
 							);
 						});
@@ -1433,14 +1407,14 @@ class _StationsModule extends CoreClass {
 	}
 
 	/**
-	 * Removes a playlist that is excluded in a station
+	 * Remove a playlist from station blacklist
 	 *
 	 * @param {object} payload - object that contains the payload
 	 * @param {object} payload.stationId - the id of the station
 	 * @param {object} payload.playlistId - the id of the playlist
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
-	REMOVE_EXCLUDED_PLAYLIST(payload) {
+	REMOVE_BLACKLISTED_PLAYLIST(payload) {
 		return new Promise((resolve, reject) => {
 			async.waterfall(
 				[
@@ -1459,8 +1433,8 @@ class _StationsModule extends CoreClass {
 					},
 
 					(station, next) => {
-						if (station.excludedPlaylists.indexOf(payload.playlistId) === -1)
-							next("This playlist isn't excluded");
+						if (station.blacklist.indexOf(payload.playlistId) === -1)
+							next("This playlist isn't blacklisted");
 						else next();
 					},
 
@@ -1474,7 +1448,7 @@ class _StationsModule extends CoreClass {
 						).then(stationModel => {
 							stationModel.updateOne(
 								{ _id: payload.stationId },
-								{ $pull: { excludedPlaylists: payload.playlistId } },
+								{ $pull: { blacklist: payload.playlistId } },
 								next
 							);
 						});
@@ -1507,13 +1481,13 @@ class _StationsModule extends CoreClass {
 	}
 
 	/**
-	 * Removes included or excluded playlist from a station
+	 * Removes autofilled or blacklisted playlist from a station
 	 *
 	 * @param {object} payload - object that contains the payload
 	 * @param {string} payload.playlistId - the playlist id
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
-	REMOVE_INCLUDED_OR_EXCLUDED_PLAYLIST_FROM_STATIONS(payload) {
+	REMOVE_AUTOFILLED_OR_BLACKLISTED_PLAYLIST_FROM_STATIONS(payload) {
 		return new Promise((resolve, reject) => {
 			async.waterfall(
 				[
@@ -1525,15 +1499,12 @@ class _StationsModule extends CoreClass {
 					next => {
 						StationsModule.stationModel.updateMany(
 							{
-								$or: [
-									{ includedPlaylists: payload.playlistId },
-									{ excludedPlaylists: payload.playlistId }
-								]
+								$or: [{ "autofill.playlists": payload.playlistId }, { blacklist: payload.playlistId }]
 							},
 							{
 								$pull: {
-									includedPlaylists: payload.playlistId,
-									excludedPlaylists: payload.playlistId
+									"autofill.playlists": payload.playlistId,
+									blacklist: payload.playlistId
 								}
 							},
 							err => {
@@ -1556,13 +1527,13 @@ class _StationsModule extends CoreClass {
 	}
 
 	/**
-	 * Gets stations that include or exclude a specific playlist
+	 * Gets stations that autofill or blacklist a specific playlist
 	 *
 	 * @param {object} payload - object that contains the payload
 	 * @param {string} payload.playlistId - the playlist id
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
-	GET_STATIONS_THAT_INCLUDE_OR_EXCLUDE_PLAYLIST(payload) {
+	GET_STATIONS_THAT_AUTOFILL_OR_BLACKLIST_PLAYLIST(payload) {
 		return new Promise((resolve, reject) => {
 			DBModule.runJob(
 				"GET_MODEL",
@@ -1573,7 +1544,7 @@ class _StationsModule extends CoreClass {
 			).then(stationModel => {
 				stationModel.find(
 					{
-						$or: [{ includedPlaylists: payload.playlistId }, { excludedPlaylists: payload.playlistId }]
+						$or: [{ "autofill.playlists": payload.playlistId }, { blacklist: payload.playlistId }]
 					},
 					(err, stations) => {
 						if (err) reject(err);
@@ -1633,19 +1604,19 @@ class _StationsModule extends CoreClass {
 	}
 
 	/**
-	 * Clears and refills a station queue
+	 * Resets a station queue
 	 *
 	 * @param {object} payload - object that contains the payload
 	 * @param {string} payload.stationId - the station id
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
-	CLEAR_AND_REFILL_STATION_QUEUE(payload) {
+	RESET_QUEUE(payload) {
 		return new Promise((resolve, reject) => {
 			async.waterfall(
 				[
 					next => {
 						StationsModule.runJob(
-							"FILL_UP_STATION_QUEUE_FROM_STATION_PLAYLIST",
+							"AUTOFILL_STATION",
 							{ stationId: payload.stationId, ignoreExistingQueue: true },
 							this
 						)
@@ -1659,8 +1630,28 @@ class _StationsModule extends CoreClass {
 								next();
 							})
 							.catch(err => {
-								next(err);
+								if (err === "Autofill is disabled in this station" || err === "Autofill limit reached")
+									StationsModule.stationModel
+										.updateOne({ _id: payload.stationId }, { $set: { queue: [] } }, this)
+										.then(() => next())
+										.catch(next);
+								else next(err);
 							});
+					},
+
+					next => {
+						StationsModule.runJob("UPDATE_STATION", { stationId: payload.stationId }, this)
+							.then(() => next())
+							.catch(next);
+					},
+
+					next => {
+						CacheModule.runJob("PUB", {
+							channel: "station.queueUpdate",
+							value: payload.stationId
+						})
+							.then(() => next())
+							.catch(next);
 					}
 				],
 				err => {

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 316 - 268
backend/package-lock.json


+ 14 - 14
backend/package.json

@@ -1,7 +1,7 @@
 {
   "name": "musare-backend",
   "private": true,
-  "version": "3.4.0",
+  "version": "3.5.0-rc1",
   "type": "module",
   "description": "An open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.",
   "main": "index.js",
@@ -16,33 +16,33 @@
   },
   "dependencies": {
     "async": "^3.2.3",
-    "axios": "^0.25.0",
+    "axios": "^0.26.1",
     "bcrypt": "^5.0.1",
     "bluebird": "^3.7.2",
-    "body-parser": "^1.19.1",
+    "body-parser": "^1.20.0",
     "config": "^3.3.7",
     "cookie-parser": "^1.4.6",
     "cors": "^2.8.5",
-    "express": "^4.17.2",
-    "moment": "^2.29.1",
-    "mongoose": "^6.2.3",
-    "nodemailer": "^6.7.2",
+    "express": "^4.17.3",
+    "moment": "^2.29.2",
+    "mongoose": "^6.2.10",
+    "nodemailer": "^6.7.3",
     "oauth": "^0.9.15",
     "redis": "^3.1.2",
     "retry-axios": "^2.6.0",
     "sha256": "^0.2.0",
     "socks": "^2.6.2",
-    "underscore": "^1.13.1",
-    "ws": "^8.2.3"
+    "underscore": "^1.13.2",
+    "ws": "^8.5.0"
   },
   "devDependencies": {
-    "eslint": "^8.8.0",
+    "eslint": "^8.13.0",
     "eslint-config-airbnb-base": "^15.0.0",
-    "eslint-config-prettier": "^8.3.0",
-    "eslint-plugin-import": "^2.25.4",
-    "eslint-plugin-jsdoc": "^37.8.2",
+    "eslint-config-prettier": "^8.5.0",
+    "eslint-plugin-import": "^2.26.0",
+    "eslint-plugin-jsdoc": "^39.2.0",
     "eslint-plugin-prettier": "^4.0.0",
-    "prettier": "2.5.1",
+    "prettier": "2.6.2",
     "trace-unhandled": "^2.0.1"
   }
 }

+ 20 - 0
docker-compose.dev.yml

@@ -0,0 +1,20 @@
+services:
+  backend:
+    ports:
+      - "${BACKEND_HOST}:${BACKEND_PORT}:8080"
+    volumes:
+      - ./backend:/opt/app
+
+  frontend:
+    volumes:
+      - ./frontend:/opt/app
+
+  mongo:
+    ports:
+      - "${MONGO_HOST}:${MONGO_PORT}:${MONGO_PORT}"
+
+  redis:
+    ports:
+      - "${REDIS_HOST}:${REDIS_PORT}:6379"
+    volumes:
+      - ${REDIS_DATA_LOCATION}:/data

+ 8 - 16
docker-compose.yml

@@ -1,15 +1,13 @@
-version: '3'
-services:
+version: "3.8"
 
+services:
   backend:
     build: ./backend
     restart: ${RESTART_POLICY}
-    ports:
-      - "${BACKEND_HOST}:${BACKEND_PORT}:8080"
     volumes:
-      - ./backend:/opt/app
-      - ./log:/opt/log
       - ./.git:/opt/app/.parent_git:ro
+      - /opt/app/node_modules
+      - ./backend/config/default.json:/opt/app/config/default.json
     links:
       - mongo
       - redis
@@ -22,19 +20,17 @@ services:
     ports:
       - "${FRONTEND_HOST}:${FRONTEND_PORT}:80"
     volumes:
-      - ./frontend:/opt/app
-      - /opt/app/node_modules/
       - ./.git:/opt/app/.parent_git:ro
+      - /opt/app/node_modules
+      - ./frontend/dist/config/default.json:/opt/app/dist/config/default.json
     environment:
       - FRONTEND_MODE=${FRONTEND_MODE}
     links:
       - backend
 
   mongo:
-    image: mongo:${MONGO_VERSION}
+    image: docker.io/mongo:${MONGO_VERSION}
     restart: ${RESTART_POLICY}
-    ports:
-      - "${MONGO_HOST}:${MONGO_PORT}:${MONGO_PORT}"
     environment:
       - MONGO_INITDB_ROOT_USERNAME=admin
       - MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD}
@@ -48,10 +44,6 @@ services:
       - ${MONGO_DATA_LOCATION}:/data/db
 
   redis:
-    image: redis:6.2
+    image: docker.io/redis:6.2
     restart: ${RESTART_POLICY}
-    ports:
-      - "${REDIS_HOST}:${REDIS_PORT}:6379"
     command: "--notify-keyspace-events Ex --requirepass ${REDIS_PASSWORD} --appendonly yes"
-    volumes:
-      - ${REDIS_DATA_LOCATION}:/data

+ 3 - 0
frontend/.dockerignore

@@ -0,0 +1,3 @@
+node_modules/
+Dockerfile
+dist/config/default.json

+ 6 - 9
frontend/Dockerfile

@@ -3,18 +3,15 @@ FROM node:16
 RUN apt-get update
 RUN apt-get install nginx -y
 
-RUN mkdir -p /opt
-WORKDIR /opt
-ADD package.json /opt/package.json
-ADD package-lock.json /opt/package-lock.json
-
-RUN npm install -g webpack@5.68.0 webpack-cli@4.9.2
+RUN mkdir -p /opt/app
+WORKDIR /opt/app
+ADD ./ /opt/app
 
+RUN npm install -g webpack@5.72.0 webpack-cli@4.9.2
 RUN npm install
 
 RUN mkdir -p /run/nginx
 
-COPY bootstrap.sh /opt/
-RUN chmod u+x /opt/bootstrap.sh
+RUN chmod u+x bootstrap.sh
 
-CMD bash /opt/bootstrap.sh
+CMD bash /opt/app/bootstrap.sh

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

@@ -7,9 +7,9 @@
 	<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, <%= htmlWebpackPlugin.options.title %>, listen, station, station, radio, edm, chill, community, official, rooms, room, party, good, mus, pop'>
-	<meta name='description' content='On <%= htmlWebpackPlugin.options.title %> 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 <%= htmlWebpackPlugin.options.title %> 2015-2022 All Right Reserved'>
+	<meta name='keywords' content='music, <%= htmlWebpackPlugin.options.title %>, musare, songs, song catalogue, listen, station, station, radio, open source'>
+	<meta name='description' content='<%= htmlWebpackPlugin.options.title %> is an open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.'>
+	<meta name='copyright' content='© Copyright Musare 2015-2022 All Right Reserved'>
 
 	<link rel='apple-touch-icon' sizes='57x57' href='/assets/favicon/apple-touch-icon-57x57.png?v=06042016'>
 	<link rel='apple-touch-icon' sizes='60x60' href='/assets/favicon/apple-touch-icon-60x60.png?v=06042016'>

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 8557 - 7
frontend/package-lock.json


+ 27 - 27
frontend/package.json

@@ -5,7 +5,7 @@
     "*.vue"
   ],
   "private": true,
-  "version": "3.4.0",
+  "version": "3.5.0-rc1",
   "description": "An open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.",
   "main": "main.js",
   "author": "Musare Team",
@@ -17,51 +17,51 @@
     "prod": "npx webpack --config webpack.prod.js"
   },
   "devDependencies": {
-    "@babel/core": "^7.17.0",
+    "@babel/core": "^7.17.9",
     "@babel/eslint-parser": "^7.17.0",
-    "@babel/plugin-proposal-object-rest-spread": "^7.15.6",
+    "@babel/plugin-proposal-object-rest-spread": "^7.17.3",
     "@babel/plugin-syntax-dynamic-import": "^7.8.3",
     "@babel/plugin-transform-runtime": "^7.17.0",
-    "@babel/preset-env": "^7.16.8",
-    "@vue/compiler-sfc": "^3.2.20",
-    "babel-loader": "^8.2.2",
-    "css-loader": "^6.6.0",
-    "eslint": "^8.8.0",
-    "eslint-config-prettier": "^8.3.0",
-    "eslint-plugin-import": "^2.24.2",
+    "@babel/preset-env": "^7.16.11",
+    "@vue/compiler-sfc": "^3.2.32",
+    "babel-loader": "^8.2.4",
+    "css-loader": "^6.7.1",
+    "eslint": "^8.13.0",
+    "eslint-config-prettier": "^8.5.0",
+    "eslint-plugin-import": "^2.26.0",
     "eslint-plugin-prettier": "^4.0.0",
-    "eslint-plugin-vue": "^8.4.0",
-    "eslint-webpack-plugin": "^3.0.1",
+    "eslint-plugin-vue": "^8.6.0",
+    "eslint-webpack-plugin": "^3.1.1",
     "fetch": "^1.1.0",
     "less": "^4.1.2",
     "less-loader": "^10.2.0",
-    "prettier": "^2.4.1",
+    "prettier": "^2.6.2",
     "style-resources-loader": "^1.5.0",
     "vue-style-loader": "^4.1.3",
     "webpack-cli": "^4.9.2",
-    "webpack-dev-server": "^4.7.4"
+    "webpack-dev-server": "^4.8.1"
   },
   "dependencies": {
-    "@babel/runtime": "^7.17.0",
+    "@babel/runtime": "^7.17.9",
     "can-autoplay": "^3.0.2",
-    "config": "^3.3.6",
-    "date-fns": "^2.25.0",
-    "dompurify": "^2.3.3",
+    "config": "^3.3.7",
+    "date-fns": "^2.28.0",
+    "dompurify": "^2.3.6",
     "eslint-config-airbnb-base": "^15.0.0",
-    "html-webpack-plugin": "^5.3.2",
+    "html-webpack-plugin": "^5.5.0",
     "lofig": "^1.3.4",
-    "marked": "^4.0.10",
+    "marked": "^4.0.14",
     "normalize.css": "^8.0.1",
     "toasters": "^2.3.1",
-    "vue": "^3.2.29",
-    "vue-content-loader": "^2.0.0",
-    "vue-loader": "^16.8.3",
-    "vue-router": "^4.0.12",
-    "vue-tippy": "^6.0.0-alpha.45",
+    "vue": "3.2.31",
+    "vue-content-loader": "^2.0.1",
+    "vue-loader": "^17.0.0",
+    "vue-router": "^4.0.14",
+    "vue-tippy": "^6.0.0-alpha.57",
     "vuedraggable": "^4.1.0",
     "vuex": "^4.0.2",
-    "webpack": "5.68.0",
-    "webpack-bundle-analyzer": "^4.4.2",
+    "webpack": "5.72.0",
+    "webpack-bundle-analyzer": "^4.5.0",
     "webpack-merge": "^5.8.0"
   }
 }

+ 50 - 21
frontend/src/App.vue

@@ -7,12 +7,9 @@
 				class="main-container"
 				:class="{ 'main-container-modal-active': aModalIsOpen2 }"
 			/>
-			<what-is-new v-show="modals.whatIsNew" />
-			<login-modal v-if="modals.login" />
-			<register-modal v-if="modals.register" />
-			<create-playlist-modal v-if="modals.createPlaylist" />
 		</div>
 		<falling-snow v-if="christmas" />
+		<modal-manager />
 	</div>
 </template>
 
@@ -27,17 +24,8 @@ import keyboardShortcuts from "./keyboardShortcuts";
 
 export default {
 	components: {
-		WhatIsNew: defineAsyncComponent(() =>
-			import("@/components/modals/WhatIsNew.vue")
-		),
-		LoginModal: defineAsyncComponent(() =>
-			import("@/components/modals/Login.vue")
-		),
-		RegisterModal: defineAsyncComponent(() =>
-			import("@/components/modals/Register.vue")
-		),
-		CreatePlaylistModal: defineAsyncComponent(() =>
-			import("@/components/modals/CreatePlaylist.vue")
+		ModalManager: defineAsyncComponent(() =>
+			import("@/components/ModalManager.vue")
 		),
 		Banned: defineAsyncComponent(() => import("@/pages/Banned.vue")),
 		FallingSnow: defineAsyncComponent(() =>
@@ -64,7 +52,7 @@ export default {
 			userId: state => state.user.auth.userId,
 			banned: state => state.user.auth.banned,
 			modals: state => state.modalVisibility.modals,
-			currentlyActive: state => state.modalVisibility.currentlyActive,
+			activeModals: state => state.modalVisibility.activeModals,
 			nightmode: state => state.user.preferences.nightmode,
 			activityWatch: state => state.user.preferences.activityWatch
 		}),
@@ -72,7 +60,7 @@ export default {
 			socket: "websockets/getSocket"
 		}),
 		aModalIsOpen() {
-			return Object.keys(this.currentlyActive).length > 0;
+			return Object.keys(this.activeModals).length > 0;
 		}
 	},
 	watch: {
@@ -160,9 +148,13 @@ export default {
 			ctrl: false,
 			handler: () => {
 				if (
-					Object.keys(this.currentlyActive).length !== 0 &&
-					this.currentlyActive[0] !== "editSong" &&
-					this.currentlyActive[0] !== "editSongs"
+					Object.keys(this.activeModals).length !== 0 &&
+					this.modals[
+						this.activeModals[this.activeModals.length - 1]
+					] !== "editSong" &&
+					this.modals[
+						this.activeModals[this.activeModals.length - 1]
+					] !== "editSongs"
 				)
 					this.closeCurrentModal();
 			}
@@ -199,6 +191,43 @@ export default {
 			this.socket.on("keep.event:user.session.deleted", () =>
 				window.location.reload()
 			);
+
+			const newUser = !localStorage.getItem("firstVisited");
+			this.socket.dispatch("news.newest", newUser, res => {
+				if (res.status !== "success") return;
+
+				const { news } = res.data;
+
+				if (news) {
+					if (newUser) {
+						this.openModal({ modal: "whatIsNew", data: { news } });
+					} else if (localStorage.getItem("whatIsNew")) {
+						if (
+							parseInt(localStorage.getItem("whatIsNew")) <
+							news.createdAt
+						) {
+							this.openModal({
+								modal: "whatIsNew",
+								data: { news }
+							});
+							localStorage.setItem("whatIsNew", news.createdAt);
+						}
+					} else {
+						if (
+							parseInt(localStorage.getItem("firstVisited")) <
+							news.createdAt
+						)
+							this.openModal({
+								modal: "whatIsNew",
+								data: { news }
+							});
+						localStorage.setItem("whatIsNew", news.createdAt);
+					}
+				}
+
+				if (!localStorage.getItem("firstVisited"))
+					localStorage.setItem("firstVisited", Date.now());
+			});
 		});
 
 		ws.onDisconnect(true, () => {
@@ -271,7 +300,7 @@ export default {
 				.getElementsByTagName("html")[0]
 				.classList.add("christmas-mode");
 		},
-		...mapActions("modalVisibility", ["closeCurrentModal"]),
+		...mapActions("modalVisibility", ["closeCurrentModal", "openModal"]),
 		...mapActions("user/preferences", [
 			"changeNightmode",
 			"changeAutoSkipDisliked",

+ 11 - 6
frontend/src/aw.js

@@ -68,12 +68,17 @@ export default {
 		if (!enabled) {
 			uuid = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
 				/[xy]/g,
-				c => {
-					// eslint-disable-next-line
-					const r = (Math.random() * 16) | 0;
-					// eslint-disable-next-line
-					const v = c == "x" ? r : (r & 0x3) | 0x8;
-					return v.toString(16);
+				symbol => {
+					let array;
+
+					if (symbol === "y") {
+						array = ["8", "9", "a", "b"];
+						return array[Math.floor(Math.random() * array.length)];
+					}
+
+					array = new Uint8Array(1);
+					window.crypto.getRandomValues(array);
+					return (array[0] % 16).toString(16);
 				}
 			);
 

+ 4 - 15
frontend/src/components/ActivityItem.vue

@@ -61,14 +61,14 @@ export default {
 			if (reportId) {
 				message = message.replace(
 					/<reportId>(.*)<\/reportId>/g,
-					`<a href='#' class='activity-item-link' @click='showReport("${reportId}")'>report</a>`
+					`<a href='#' class='activity-item-link' @click='openModal({ modal: "viewReport", data: { reportId: "${reportId}" } })'>report</a>`
 				);
 			}
 
 			if (playlistId) {
 				message = message.replace(
 					/<playlistId>(.*)<\/playlistId>/g,
-					`<a href='#' class='activity-item-link' @click='showPlaylist("${playlistId}")'>$1</a>`
+					`<a href='#' class='activity-item-link' @click='openModal({ modal: "editPlaylist", data: { playlistId: "${playlistId}" } })'>$1</a>`
 				);
 			}
 
@@ -82,8 +82,7 @@ export default {
 			return {
 				template: `<p>${message}</p>`,
 				methods: {
-					showPlaylist: this.showPlaylist,
-					showReport: this.showReport
+					openModal: this.openModal
 				}
 			};
 		},
@@ -174,19 +173,9 @@ export default {
 
 			return icons[this.activity.type];
 		},
-		showReport(reportId) {
-			this.viewReport(reportId);
-			this.openModal("viewReport");
-		},
-		showPlaylist(playlistId) {
-			this.editPlaylist(playlistId);
-			this.openModal("editPlaylist");
-		},
-		...mapActions("user/playlists", ["editPlaylist"]),
 		formatDistance,
 		parseISO,
-		...mapActions("modalVisibility", ["openModal"]),
-		...mapActions("modals/viewReport", ["viewReport"])
+		...mapActions("modalVisibility", ["openModal"])
 	}
 };
 </script>

+ 6 - 4
frontend/src/components/AdvancedTable.vue

@@ -367,7 +367,7 @@
 											<label class="switch">
 												<input
 													type="checkbox"
-													:id="index"
+													:id="`column-dropdown-checkbox-${column.name}`"
 													:checked="
 														shownColumns.indexOf(
 															column.name
@@ -388,7 +388,9 @@
 													}"
 												></span>
 											</label>
-											<label :for="index">
+											<label
+												:for="`column-dropdown-checkbox-${column.name}`"
+											>
 												<span></span>
 												<p>{{ column.displayName }}</p>
 											</label>
@@ -919,10 +921,10 @@ export default {
 			);
 		},
 		aModalIsOpen() {
-			return Object.keys(this.currentlyActive).length > 0;
+			return Object.keys(this.activeModals).length > 0;
 		},
 		...mapState({
-			currentlyActive: state => state.modalVisibility.currentlyActive
+			activeModals: state => state.modalVisibility.activeModals
 		}),
 		...mapGetters({
 			socket: "websockets/getSocket"

+ 46 - 0
frontend/src/components/ModalManager.vue

@@ -0,0 +1,46 @@
+<template>
+	<div>
+		<div v-for="activeModalUuid in activeModals" :key="activeModalUuid">
+			<component
+				:is="this[modals[activeModalUuid]]"
+				:modal-uuid="activeModalUuid"
+			/>
+		</div>
+	</div>
+</template>
+
+<script>
+import { mapState } from "vuex";
+
+import { mapModalComponents } from "@/vuex_helpers";
+
+export default {
+	computed: {
+		...mapModalComponents("./components/modals", {
+			editUser: "EditUser.vue",
+			login: "Login.vue",
+			register: "Register.vue",
+			whatIsNew: "WhatIsNew.vue",
+			createStation: "CreateStation.vue",
+			editNews: "EditNews.vue",
+			manageStation: "ManageStation/index.vue",
+			importPlaylist: "ImportPlaylist.vue",
+			editPlaylist: "EditPlaylist/index.vue",
+			createPlaylist: "CreatePlaylist.vue",
+			report: "Report.vue",
+			viewReport: "ViewReport.vue",
+			bulkActions: "BulkActions.vue",
+			viewPunishment: "ViewPunishment.vue",
+			removeAccount: "RemoveAccount.vue",
+			importAlbum: "ImportAlbum.vue",
+			confirm: "Confirm.vue",
+			editSongs: "EditSongs.vue",
+			editSong: "EditSong/index.vue"
+		}),
+		...mapState("modalVisibility", {
+			activeModals: state => state.activeModals,
+			modals: state => state.modals
+		})
+	}
+};
+</script>

+ 9 - 5
frontend/src/components/PlaylistItem.vue

@@ -16,8 +16,10 @@
 			</p>
 			<p class="item-description">
 				<span v-if="showOwner"
-					><a v-if="playlist.createdBy === 'Musare'" title="Musare"
-						>Musare</a
+					><a
+						v-if="playlist.createdBy === 'Musare'"
+						:title="sitename"
+						>{{ sitename }}</a
 					><user-id-to-username
 						v-else
 						:user-id="playlist.createdBy"
@@ -39,18 +41,17 @@
 </template>
 
 <script>
-import UserIdToUsername from "@/components/UserIdToUsername.vue";
 import utils from "../../js/utils";
 
 export default {
-	components: { UserIdToUsername },
 	props: {
 		playlist: { type: Object, default: () => {} },
 		showOwner: { type: Boolean, default: false }
 	},
 	data() {
 		return {
-			utils
+			utils,
+			sitename: "Musare"
 		};
 	},
 	computed: {
@@ -60,6 +61,9 @@ export default {
 			} ${this.playlist.songs.length === 1 ? "song" : "songs"}`;
 		}
 	},
+	async mounted() {
+		this.sitename = await lofig.get("siteSettings.sitename");
+	},
 	methods: {
 		totalLength(playlist) {
 			let length = 0;

+ 1067 - 0
frontend/src/components/PlaylistTabBase.vue

@@ -0,0 +1,1067 @@
+<template>
+	<div class="playlist-tab-base">
+		<div v-if="$slots.info" class="top-info has-text-centered">
+			<slot name="info" />
+		</div>
+		<div class="tabs-container">
+			<div class="tab-selection">
+				<button
+					class="button is-default"
+					ref="search-tab"
+					:class="{ selected: tab === 'search' }"
+					@click="showTab('search')"
+				>
+					Search
+				</button>
+				<button
+					class="button is-default"
+					ref="current-tab"
+					:class="{ selected: tab === 'current' }"
+					@click="showTab('current')"
+				>
+					Current
+				</button>
+				<button
+					v-if="
+						type === 'autorequest' || station.type === 'community'
+					"
+					class="button is-default"
+					ref="my-playlists-tab"
+					:class="{ selected: tab === 'my-playlists' }"
+					@click="showTab('my-playlists')"
+				>
+					My Playlists
+				</button>
+			</div>
+			<div class="tab" v-show="tab === 'search'">
+				<div v-if="featuredPlaylists.length > 0">
+					<label class="label"> Featured playlists </label>
+					<playlist-item
+						v-for="featuredPlaylist in featuredPlaylists"
+						:key="`featuredKey-${featuredPlaylist._id}`"
+						:playlist="featuredPlaylist"
+						:show-owner="true"
+					>
+						<template #item-icon>
+							<i
+								class="material-icons blacklisted-icon"
+								v-if="
+									isSelected(
+										featuredPlaylist._id,
+										'blacklist'
+									)
+								"
+								:content="`This playlist is currently ${label(
+									'past',
+									'blacklist'
+								)}`"
+								v-tippy
+							>
+								block
+							</i>
+							<i
+								class="material-icons"
+								v-else-if="isSelected(featuredPlaylist._id)"
+								:content="`This playlist is currently ${label(
+									'past'
+								)}`"
+								v-tippy
+							>
+								play_arrow
+							</i>
+							<i
+								class="material-icons"
+								v-else
+								:content="`This playlist is currently not ${label(
+									'past'
+								)}`"
+								v-tippy
+							>
+								{{
+									type === "blacklist"
+										? "block"
+										: "play_disabled"
+								}}
+							</i>
+						</template>
+
+						<template #actions>
+							<i
+								v-if="
+									type !== 'blacklist' &&
+									isSelected(
+										featuredPlaylist._id,
+										'blacklist'
+									)
+								"
+								class="material-icons stop-icon"
+								:content="`This playlist is ${label(
+									'past',
+									'blacklist'
+								)} in this station`"
+								v-tippy="{ theme: 'info' }"
+								>play_disabled</i
+							>
+							<quick-confirm
+								v-if="
+									type !== 'blacklist' &&
+									isSelected(featuredPlaylist._id)
+								"
+								@confirm="
+									deselectPlaylist(featuredPlaylist._id)
+								"
+							>
+								<i
+									class="material-icons stop-icon"
+									:content="`Stop ${label(
+										'present'
+									)} songs from this playlist`"
+									v-tippy
+								>
+									stop
+								</i>
+							</quick-confirm>
+							<i
+								v-if="
+									type !== 'blacklist' &&
+									!isSelected(featuredPlaylist._id) &&
+									!isSelected(
+										featuredPlaylist._id,
+										'blacklist'
+									)
+								"
+								@click="selectPlaylist(featuredPlaylist)"
+								class="material-icons play-icon"
+								:content="`${label(
+									'future',
+									null,
+									true
+								)} songs from this playlist`"
+								v-tippy
+								>play_arrow</i
+							>
+							<quick-confirm
+								v-if="
+									type === 'blacklist' &&
+									!isSelected(
+										featuredPlaylist._id,
+										'blacklist'
+									)
+								"
+								@confirm="
+									selectPlaylist(
+										featuredPlaylist,
+										'blacklist'
+									)
+								"
+							>
+								<i
+									class="material-icons stop-icon"
+									:content="`${label(
+										'future',
+										null,
+										true
+									)} Playlist`"
+									v-tippy
+									>block</i
+								>
+							</quick-confirm>
+							<quick-confirm
+								v-if="
+									type === 'blacklist' &&
+									isSelected(
+										featuredPlaylist._id,
+										'blacklist'
+									)
+								"
+								@confirm="
+									deselectPlaylist(featuredPlaylist._id)
+								"
+							>
+								<i
+									class="material-icons stop-icon"
+									:content="`Stop ${label(
+										'present'
+									)} songs from this playlist`"
+									v-tippy
+								>
+									stop
+								</i>
+							</quick-confirm>
+							<i
+								v-if="featuredPlaylist.createdBy === myUserId"
+								@click="
+									openModal({
+										modal: 'editPlaylist',
+										data: {
+											playlistId: featuredPlaylist._id
+										}
+									})
+								"
+								class="material-icons edit-icon"
+								content="Edit Playlist"
+								v-tippy
+								>edit</i
+							>
+							<i
+								v-if="
+									featuredPlaylist.createdBy !== myUserId &&
+									(featuredPlaylist.privacy === 'public' ||
+										isAdmin())
+								"
+								@click="
+									openModal({
+										modal: 'editPlaylist',
+										data: {
+											playlistId: featuredPlaylist._id
+										}
+									})
+								"
+								class="material-icons edit-icon"
+								content="View Playlist"
+								v-tippy
+								>visibility</i
+							>
+						</template>
+					</playlist-item>
+					<br />
+				</div>
+				<label class="label">Search for a playlist</label>
+				<div class="control is-grouped input-with-button">
+					<p class="control is-expanded">
+						<input
+							class="input"
+							type="text"
+							placeholder="Enter your playlist query here..."
+							v-model="search.query"
+							@keyup.enter="searchForPlaylists(1)"
+						/>
+					</p>
+					<p class="control">
+						<a class="button is-info" @click="searchForPlaylists(1)"
+							><i class="material-icons icon-with-button"
+								>search</i
+							>Search</a
+						>
+					</p>
+				</div>
+				<div v-if="search.results.length > 0">
+					<playlist-item
+						v-for="playlist in search.results"
+						:key="`searchKey-${playlist._id}`"
+						:playlist="playlist"
+						:show-owner="true"
+					>
+						<template #item-icon>
+							<i
+								class="material-icons blacklisted-icon"
+								v-if="isSelected(playlist._id, 'blacklist')"
+								:content="`This playlist is currently ${label(
+									'past',
+									'blacklist'
+								)}`"
+								v-tippy
+							>
+								block
+							</i>
+							<i
+								class="material-icons"
+								v-else-if="isSelected(playlist._id)"
+								:content="`This playlist is currently ${label(
+									'past'
+								)}`"
+								v-tippy
+							>
+								play_arrow
+							</i>
+							<i
+								class="material-icons"
+								v-else
+								:content="`This playlist is currently not ${label(
+									'past'
+								)}`"
+								v-tippy
+							>
+								{{
+									type === "blacklist"
+										? "block"
+										: "play_disabled"
+								}}
+							</i>
+						</template>
+
+						<template #actions>
+							<i
+								v-if="
+									type !== 'blacklist' &&
+									isSelected(playlist._id, 'blacklist')
+								"
+								class="material-icons stop-icon"
+								:content="`This playlist is ${label(
+									'past',
+									'blacklist'
+								)} in this station`"
+								v-tippy="{ theme: 'info' }"
+								>play_disabled</i
+							>
+							<quick-confirm
+								v-if="
+									type !== 'blacklist' &&
+									isSelected(playlist._id)
+								"
+								@confirm="deselectPlaylist(playlist._id)"
+							>
+								<i
+									class="material-icons stop-icon"
+									:content="`Stop ${label(
+										'present'
+									)} songs from this playlist`"
+									v-tippy
+								>
+									stop
+								</i>
+							</quick-confirm>
+							<i
+								v-if="
+									type !== 'blacklist' &&
+									!isSelected(playlist._id) &&
+									!isSelected(playlist._id, 'blacklist')
+								"
+								@click="selectPlaylist(playlist)"
+								class="material-icons play-icon"
+								:content="`${label(
+									'future',
+									null,
+									true
+								)} songs from this playlist`"
+								v-tippy
+								>play_arrow</i
+							>
+							<quick-confirm
+								v-if="
+									type === 'blacklist' &&
+									!isSelected(playlist._id, 'blacklist')
+								"
+								@confirm="selectPlaylist(playlist, 'blacklist')"
+							>
+								<i
+									class="material-icons stop-icon"
+									:content="`${label(
+										'future',
+										null,
+										true
+									)} Playlist`"
+									v-tippy
+									>block</i
+								>
+							</quick-confirm>
+							<quick-confirm
+								v-if="
+									type === 'blacklist' &&
+									isSelected(playlist._id, 'blacklist')
+								"
+								@confirm="deselectPlaylist(playlist._id)"
+							>
+								<i
+									class="material-icons stop-icon"
+									:content="`Stop ${label(
+										'present'
+									)} songs from this playlist`"
+									v-tippy
+								>
+									stop
+								</i>
+							</quick-confirm>
+							<i
+								v-if="playlist.createdBy === myUserId"
+								@click="
+									openModal({
+										modal: 'editPlaylist',
+										data: { playlistId: playlist._id }
+									})
+								"
+								class="material-icons edit-icon"
+								content="Edit Playlist"
+								v-tippy
+								>edit</i
+							>
+							<i
+								v-if="
+									playlist.createdBy !== myUserId &&
+									(playlist.privacy === 'public' || isAdmin())
+								"
+								@click="
+									openModal({
+										modal: 'editPlaylist',
+										data: { playlistId: playlist._id }
+									})
+								"
+								class="material-icons edit-icon"
+								content="View Playlist"
+								v-tippy
+								>visibility</i
+							>
+						</template>
+					</playlist-item>
+					<button
+						v-if="resultsLeftCount > 0"
+						class="button is-primary load-more-button"
+						@click="searchForPlaylists(search.page + 1)"
+					>
+						Load {{ nextPageResultsCount }} more results
+					</button>
+				</div>
+			</div>
+			<div class="tab" v-show="tab === 'current'">
+				<div v-if="selectedPlaylists().length > 0">
+					<playlist-item
+						v-for="playlist in selectedPlaylists()"
+						:key="`key-${playlist._id}`"
+						:playlist="playlist"
+						:show-owner="true"
+					>
+						<template #item-icon>
+							<i
+								class="material-icons"
+								:class="{
+									'blacklisted-icon': type === 'blacklist'
+								}"
+								:content="`This playlist is currently ${label(
+									'past'
+								)}`"
+								v-tippy
+							>
+								{{
+									type === "blacklist"
+										? "block"
+										: "play_arrow"
+								}}
+							</i>
+						</template>
+
+						<template #actions>
+							<quick-confirm
+								v-if="isOwnerOrAdmin()"
+								@confirm="deselectPlaylist(playlist._id)"
+							>
+								<i
+									class="material-icons stop-icon"
+									:content="`Stop ${label(
+										'present'
+									)} songs from this playlist`"
+									v-tippy
+								>
+									stop
+								</i>
+							</quick-confirm>
+							<i
+								v-if="playlist.createdBy === myUserId"
+								@click="
+									openModal({
+										modal: 'editPlaylist',
+										data: { playlistId: playlist._id }
+									})
+								"
+								class="material-icons edit-icon"
+								content="Edit Playlist"
+								v-tippy
+								>edit</i
+							>
+							<i
+								v-if="
+									playlist.createdBy !== myUserId &&
+									(playlist.privacy === 'public' || isAdmin())
+								"
+								@click="
+									openModal({
+										modal: 'editPlaylist',
+										data: { playlistId: playlist._id }
+									})
+								"
+								class="material-icons edit-icon"
+								content="View Playlist"
+								v-tippy
+								>visibility</i
+							>
+						</template>
+					</playlist-item>
+				</div>
+				<p v-else class="has-text-centered scrollable-list">
+					No playlists currently {{ label("present") }}.
+				</p>
+			</div>
+			<div
+				v-if="type === 'autorequest' || station.type === 'community'"
+				class="tab"
+				v-show="tab === 'my-playlists'"
+			>
+				<button
+					class="button is-primary"
+					id="create-new-playlist-button"
+					@click="openModal('createPlaylist')"
+				>
+					Create new playlist
+				</button>
+				<div
+					class="menu-list scrollable-list"
+					v-if="playlists.length > 0"
+				>
+					<draggable
+						tag="transition-group"
+						:component-data="{
+							name: !drag ? 'draggable-list-transition' : null
+						}"
+						item-key="_id"
+						v-model="playlists"
+						v-bind="dragOptions"
+						@start="drag = true"
+						@end="drag = false"
+						@change="savePlaylistOrder"
+					>
+						<template #item="{ element }">
+							<playlist-item
+								class="item-draggable"
+								:playlist="element"
+							>
+								<template #item-icon>
+									<i
+										class="material-icons blacklisted-icon"
+										v-if="
+											isSelected(element._id, 'blacklist')
+										"
+										:content="`This playlist is currently ${label(
+											'past',
+											'blacklist'
+										)}`"
+										v-tippy
+									>
+										block
+									</i>
+									<i
+										class="material-icons"
+										v-else-if="isSelected(element._id)"
+										:content="`This playlist is currently ${label(
+											'past'
+										)}`"
+										v-tippy
+									>
+										play_arrow
+									</i>
+									<i
+										class="material-icons"
+										v-else
+										:content="`This playlist is currently not ${label(
+											'past'
+										)}`"
+										v-tippy
+									>
+										{{
+											type === "blacklist"
+												? "block"
+												: "play_disabled"
+										}}
+									</i>
+								</template>
+
+								<template #actions>
+									<i
+										v-if="
+											type !== 'blacklist' &&
+											isSelected(element._id, 'blacklist')
+										"
+										class="material-icons stop-icon"
+										:content="`This playlist is ${label(
+											'past',
+											'blacklist'
+										)} in this station`"
+										v-tippy="{ theme: 'info' }"
+										>play_disabled</i
+									>
+									<quick-confirm
+										v-if="
+											type !== 'blacklist' &&
+											isSelected(element._id)
+										"
+										@confirm="deselectPlaylist(element._id)"
+									>
+										<i
+											class="material-icons stop-icon"
+											:content="`Stop ${label(
+												'present'
+											)} songs from this playlist`"
+											v-tippy
+										>
+											stop
+										</i>
+									</quick-confirm>
+									<i
+										v-if="
+											type !== 'blacklist' &&
+											!isSelected(element._id) &&
+											!isSelected(
+												element._id,
+												'blacklist'
+											)
+										"
+										@click="selectPlaylist(element)"
+										class="material-icons play-icon"
+										:content="`${label(
+											'future',
+											null,
+											true
+										)} songs from this playlist`"
+										v-tippy
+										>play_arrow</i
+									>
+									<quick-confirm
+										v-if="
+											type === 'blacklist' &&
+											!isSelected(
+												element._id,
+												'blacklist'
+											)
+										"
+										@confirm="
+											selectPlaylist(element, 'blacklist')
+										"
+									>
+										<i
+											class="material-icons stop-icon"
+											:content="`${label(
+												'future',
+												null,
+												true
+											)} Playlist`"
+											v-tippy
+											>block</i
+										>
+									</quick-confirm>
+									<quick-confirm
+										v-if="
+											type === 'blacklist' &&
+											isSelected(element._id, 'blacklist')
+										"
+										@confirm="deselectPlaylist(element._id)"
+									>
+										<i
+											class="material-icons stop-icon"
+											:content="`Stop ${label(
+												'present'
+											)} songs from this playlist`"
+											v-tippy
+										>
+											stop
+										</i>
+									</quick-confirm>
+									<i
+										@click="
+											openModal({
+												modal: 'editPlaylist',
+												data: {
+													playlistId: element._id
+												}
+											})
+										"
+										class="material-icons edit-icon"
+										content="Edit Playlist"
+										v-tippy
+										>edit</i
+									>
+								</template>
+							</playlist-item>
+						</template>
+					</draggable>
+				</div>
+
+				<p v-else class="has-text-centered scrollable-list">
+					You don't have any playlists!
+				</p>
+			</div>
+		</div>
+	</div>
+</template>
+<script>
+import { mapActions, mapState, mapGetters } from "vuex";
+import Toast from "toasters";
+import ws from "@/ws";
+
+import { mapModalState } from "@/vuex_helpers";
+
+import PlaylistItem from "@/components/PlaylistItem.vue";
+
+import SortablePlaylists from "@/mixins/SortablePlaylists.vue";
+
+export default {
+	components: {
+		PlaylistItem
+	},
+	mixins: [SortablePlaylists],
+	props: {
+		modalUuid: { type: String, default: "" },
+		type: {
+			type: String,
+			default: ""
+		},
+		sector: {
+			type: String,
+			default: "manageStation"
+		}
+	},
+	emits: ["selected"],
+	data() {
+		return {
+			tab: "current",
+			search: {
+				query: "",
+				searchedQuery: "",
+				page: 0,
+				count: 0,
+				resultsLeft: 0,
+				results: []
+			},
+			featuredPlaylists: []
+		};
+	},
+	computed: {
+		station: {
+			get() {
+				if (this.sector === "manageStation")
+					return this.$store.state.modals.manageStation[
+						this.modalUuid
+					].station;
+				return this.$store.state.station.station;
+			},
+			set(station) {
+				if (this.sector === "manageStation")
+					this.$store.commit(
+						`modals/manageStation/${this.modalUuid}/updateStation`,
+						station
+					);
+				else this.$store.commit("station/updateStation", station);
+			}
+		},
+		blacklist: {
+			get() {
+				if (this.sector === "manageStation")
+					return this.$store.state.modals.manageStation[
+						this.modalUuid
+					].blacklist;
+				return this.$store.state.station.blacklist;
+			},
+			set(blacklist) {
+				if (this.sector === "manageStation")
+					this.$store.commit(
+						`modals/manageStation/${this.modalUuid}/setBlacklist`,
+						blacklist
+					);
+				else this.$store.commit("station/setBlacklist", blacklist);
+			}
+		},
+		resultsLeftCount() {
+			return this.search.count - this.search.results.length;
+		},
+		nextPageResultsCount() {
+			return Math.min(this.search.pageSize, this.resultsLeftCount);
+		},
+		...mapState({
+			loggedIn: state => state.user.auth.loggedIn,
+			role: state => state.user.auth.role,
+			userId: state => state.user.auth.userId
+		}),
+		...mapModalState("modals/manageStation/MODAL_UUID", {
+			autofill: state => state.autofill
+		}),
+		...mapState("station", {
+			autoRequest: state => state.autoRequest
+		}),
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	mounted() {
+		this.showTab("search");
+
+		ws.onConnect(this.init);
+	},
+	methods: {
+		init() {
+			this.socket.dispatch("playlists.indexMyPlaylists", res => {
+				if (res.status === "success")
+					this.setPlaylists(res.data.playlists);
+				this.orderOfPlaylists = this.calculatePlaylistOrder(); // order in regards to the database
+			});
+
+			this.socket.dispatch("playlists.indexFeaturedPlaylists", res => {
+				if (res.status === "success")
+					this.featuredPlaylists = res.data.playlists;
+			});
+
+			if (this.type === "autofill")
+				this.socket.dispatch(
+					`stations.getStationAutofillPlaylistsById`,
+					this.station._id,
+					res => {
+						if (res.status === "success") {
+							this.station.autofill.playlists =
+								res.data.playlists;
+						}
+					}
+				);
+
+			this.socket.dispatch(
+				`stations.getStationBlacklistById`,
+				this.station._id,
+				res => {
+					if (res.status === "success") {
+						this.station.blacklist = res.data.playlists;
+					}
+				}
+			);
+		},
+		showTab(tab) {
+			this.$refs[`${tab}-tab`].scrollIntoView({ block: "nearest" });
+			this.tab = tab;
+		},
+		isOwner() {
+			return (
+				this.loggedIn &&
+				this.station &&
+				this.userId === this.station.owner
+			);
+		},
+		isAdmin() {
+			return this.loggedIn && this.role === "admin";
+		},
+		isOwnerOrAdmin() {
+			return this.isOwner() || this.isAdmin();
+		},
+		label(tense = "future", typeOverwrite = null, capitalize = false) {
+			let label = typeOverwrite || this.type;
+
+			if (tense === "past") label = `${label}ed`;
+			if (tense === "present") label = `${label}ing`;
+
+			if (capitalize)
+				label = `${label.charAt(0).toUpperCase()}${label.slice(1)}`;
+
+			return label;
+		},
+		selectedPlaylists(typeOverwrite) {
+			const type = typeOverwrite || this.type;
+
+			if (type === "autofill") return this.autofill;
+			if (type === "blacklist") return this.blacklist;
+			if (type === "autorequest") return this.autoRequest;
+			return [];
+		},
+		async selectPlaylist(playlist, typeOverwrite) {
+			const type = typeOverwrite || this.type;
+
+			if (this.isSelected(playlist._id, type))
+				return new Toast(
+					`Error: Playlist already ${this.label("past", type)}.`
+				);
+
+			if (type === "autofill")
+				return new Promise(resolve => {
+					this.socket.dispatch(
+						"stations.autofillPlaylist",
+						this.station._id,
+						playlist._id,
+						res => {
+							new Toast(res.message);
+							this.$emit("selected");
+							resolve();
+						}
+					);
+				});
+			if (type === "blacklist") {
+				if (this.type !== "blacklist" && this.isSelected(playlist._id))
+					await this.deselectPlaylist(playlist._id);
+
+				return new Promise(resolve => {
+					this.socket.dispatch(
+						"stations.blacklistPlaylist",
+						this.station._id,
+						playlist._id,
+						res => {
+							new Toast(res.message);
+							this.$emit("selected");
+							resolve();
+						}
+					);
+				});
+			}
+			if (type === "autorequest")
+				return new Promise(resolve => {
+					this.addPlaylistToAutoRequest(playlist);
+					new Toast(
+						"Successfully selected playlist to auto request songs."
+					);
+					this.$emit("selected");
+					resolve();
+				});
+			return false;
+		},
+		deselectPlaylist(playlistId, typeOverwrite) {
+			const type = typeOverwrite || this.type;
+
+			if (type === "autofill")
+				return new Promise(resolve => {
+					this.socket.dispatch(
+						"stations.removeAutofillPlaylist",
+						this.station._id,
+						playlistId,
+						res => {
+							new Toast(res.message);
+							resolve();
+						}
+					);
+				});
+			if (type === "blacklist")
+				return new Promise(resolve => {
+					this.socket.dispatch(
+						"stations.removeBlacklistedPlaylist",
+						this.station._id,
+						playlistId,
+						res => {
+							new Toast(res.message);
+							resolve();
+						}
+					);
+				});
+			if (type === "autorequest")
+				return new Promise(resolve => {
+					this.removePlaylistFromAutoRequest(playlistId);
+					new Toast("Successfully deselected playlist.");
+					resolve();
+				});
+			return false;
+		},
+		isSelected(playlistId, typeOverwrite) {
+			const type = typeOverwrite || this.type;
+			let selected = false;
+
+			this.selectedPlaylists(type).forEach(playlist => {
+				if (playlist._id === playlistId) selected = true;
+			});
+			return selected;
+		},
+		searchForPlaylists(page) {
+			if (
+				this.search.page >= page ||
+				this.search.searchedQuery !== this.search.query
+			) {
+				this.search.results = [];
+				this.search.page = 0;
+				this.search.count = 0;
+				this.search.resultsLeft = 0;
+				this.search.pageSize = 0;
+			}
+
+			const { query } = this.search;
+			const action =
+				this.station.type === "official" && this.type !== "autorequest"
+					? "playlists.searchOfficial"
+					: "playlists.searchCommunity";
+
+			this.search.searchedQuery = this.search.query;
+			this.socket.dispatch(action, query, page, res => {
+				const { data } = res;
+				if (res.status === "success") {
+					const { count, pageSize, playlists } = data;
+					this.search.results = [
+						...this.search.results,
+						...playlists
+					];
+					this.search.page = page;
+					this.search.count = count;
+					this.search.resultsLeft =
+						count - this.search.results.length;
+					this.search.pageSize = pageSize;
+				} else if (res.status === "error") {
+					this.search.results = [];
+					this.search.page = 0;
+					this.search.count = 0;
+					this.search.resultsLeft = 0;
+					this.search.pageSize = 0;
+					new Toast(res.message);
+				}
+			});
+		},
+		...mapActions("modalVisibility", ["openModal"]),
+		...mapActions("user/playlists", ["setPlaylists"]),
+		...mapActions("station", [
+			"addPlaylistToAutoRequest",
+			"removePlaylistFromAutoRequest"
+		])
+	}
+};
+</script>
+
+<style lang="less" scoped>
+.night-mode {
+	.tabs-container .tab-selection .button {
+		background: var(--dark-grey) !important;
+		color: var(--white) !important;
+	}
+}
+
+.blacklisted-icon {
+	color: var(--dark-red);
+}
+
+.playlist-tab-base {
+	.top-info {
+		font-size: 15px;
+		margin-bottom: 15px;
+	}
+
+	.tabs-container {
+		.tab-selection {
+			display: flex;
+			overflow-x: auto;
+			.button {
+				border-radius: 0;
+				border: 0;
+				text-transform: uppercase;
+				font-size: 14px;
+				color: var(--dark-grey-3);
+				background-color: var(--light-grey-2);
+				flex-grow: 1;
+				height: 32px;
+
+				&:not(:first-of-type) {
+					margin-left: 5px;
+				}
+			}
+
+			.selected {
+				background-color: var(--primary-color) !important;
+				color: var(--white) !important;
+				font-weight: 600;
+			}
+		}
+		.tab {
+			padding: 15px 0;
+			border-radius: 0;
+			.playlist-item:not(:last-of-type),
+			.item.item-draggable:not(:last-of-type) {
+				margin-bottom: 10px;
+			}
+			.load-more-button {
+				width: 100%;
+				margin-top: 10px;
+			}
+		}
+	}
+}
+.draggable-list-transition-move {
+	transition: transform 0.5s;
+}
+
+.draggable-list-ghost {
+	opacity: 0.5;
+	filter: brightness(95%);
+}
+</style>

+ 0 - 3
frontend/src/components/PunishmentItem.vue

@@ -75,10 +75,7 @@
 import { mapActions } from "vuex";
 import { format, formatDistance, parseISO } from "date-fns";
 
-import UserIdToUsername from "@/components/UserIdToUsername.vue";
-
 export default {
-	components: { UserIdToUsername },
 	props: {
 		punishment: { type: Object, default: () => {} }
 	},

+ 26 - 71
frontend/src/components/Queue.vue

@@ -22,10 +22,7 @@
 				<template #item="{ element, index }">
 					<song-item
 						:song="element"
-						:requested-by="
-							station.type === 'community' &&
-							station.partyMode === true
-						"
+						:requested-by="true"
 						:class="{
 							'item-draggable': isAdminOnly() || isOwnerOnly()
 						}"
@@ -69,59 +66,9 @@
 				</template>
 			</draggable>
 		</div>
-		<p class="nothing-here-text" v-else>
+		<p class="nothing-here-text has-text-centered" v-else>
 			There are no songs currently queued
 		</p>
-		<button
-			class="button is-primary tab-actionable-button"
-			v-if="
-				sector === 'station' &&
-				loggedIn &&
-				station.type === 'community' &&
-				station.partyMode &&
-				((station.locked && isOwnerOnly()) ||
-					!station.locked ||
-					(station.locked && isAdminOnly() && dismissedWarning))
-			"
-			@click="openModal('manageStation') & showManageStationTab('songs')"
-		>
-			<i class="material-icons icon-with-button">queue</i>
-			<span> Add Song To Queue </span>
-		</button>
-		<button
-			class="button is-primary tab-actionable-button disabled"
-			v-if="
-				sector === 'station' &&
-				!loggedIn &&
-				((station.type === 'community' &&
-					station.partyMode &&
-					!station.locked) ||
-					station.type === 'official')
-			"
-			content="Login to add songs to queue"
-			v-tippy="{ theme: 'info' }"
-		>
-			<i class="material-icons icon-with-button">queue</i>
-			<span> Add Song To Queue </span>
-		</button>
-		<div
-			id="queue-locked"
-			v-if="station.type === 'community' && station.locked"
-		>
-			<button
-				v-if="isAdminOnly() && !isOwnerOnly() && !dismissedWarning"
-				class="button tab-actionable-button"
-				@click="dismissedWarning = true"
-			>
-				THIS STATION'S QUEUE IS LOCKED.
-			</button>
-			<button
-				v-if="!isAdminOnly() && !isOwnerOnly()"
-				class="button tab-actionable-button"
-			>
-				THIS STATION'S QUEUE IS LOCKED.
-			</button>
-		</div>
 	</div>
 </template>
 
@@ -131,11 +78,11 @@ import draggable from "vuedraggable";
 import Toast from "toasters";
 
 import SongItem from "@/components/SongItem.vue";
-import QuickConfirm from "@/components/QuickConfirm.vue";
 
 export default {
-	components: { draggable, SongItem, QuickConfirm },
+	components: { draggable, SongItem },
 	props: {
+		modalUuid: { type: String, default: "" },
 		sector: {
 			type: String,
 			default: "station"
@@ -143,22 +90,40 @@ export default {
 	},
 	data() {
 		return {
-			dismissedWarning: false,
 			actionableButtonVisible: false,
 			drag: false
 		};
 	},
 	computed: {
+		station: {
+			get() {
+				if (this.sector === "manageStation")
+					return this.$store.state.modals.manageStation[
+						this.modalUuid
+					].station;
+				return this.$store.state.station.station;
+			},
+			set(station) {
+				if (this.sector === "manageStation")
+					this.$store.commit(
+						`modals/manageStation/${this.modalUuid}/updateStation`,
+						station
+					);
+				else this.$store.commit("station/updateStation", station);
+			}
+		},
 		queue: {
 			get() {
 				if (this.sector === "manageStation")
-					return this.$store.state.modals.manageStation.songsList;
+					return this.$store.state.modals.manageStation[
+						this.modalUuid
+					].songsList;
 				return this.$store.state.station.songsList;
 			},
 			set(queue) {
 				if (this.sector === "manageStation")
 					this.$store.commit(
-						"modals/manageStation/updateSongsList",
+						`modals/manageStation/${this.modalUuid}/updateSongsList`,
 						queue
 					);
 				else this.$store.commit("station/updateSongsList", queue);
@@ -176,16 +141,6 @@ export default {
 			loggedIn: state => state.user.auth.loggedIn,
 			userId: state => state.user.auth.userId,
 			userRole: state => state.user.auth.role,
-			station(state) {
-				return this.sector === "station"
-					? state.station.station
-					: state.modals.manageStation.station;
-			},
-			songsList(state) {
-				return this.sector === "station"
-					? state.station.songsList
-					: state.modals.manageStation.songsList;
-			},
 			noSong: state => state.station.noSong
 		}),
 		...mapGetters({
@@ -261,7 +216,7 @@ export default {
 				moved: {
 					element: song,
 					oldIndex: index,
-					newIndex: this.songsList.length
+					newIndex: this.queue.length
 				}
 			});
 		},

+ 395 - 0
frontend/src/components/Request.vue

@@ -0,0 +1,395 @@
+<template>
+	<div class="station-playlists">
+		<p class="top-info has-text-centered">
+			Add songs to the queue or automatically request songs from playlists
+		</p>
+		<div class="tabs-container">
+			<div class="tab-selection">
+				<button
+					class="button is-default"
+					ref="songs-tab"
+					:class="{ selected: tab === 'songs' }"
+					@click="showTab('songs')"
+				>
+					Songs
+				</button>
+				<button
+					v-if="!disableAutoRequest"
+					class="button is-default"
+					ref="autorequest-tab"
+					:class="{ selected: tab === 'autorequest' }"
+					@click="showTab('autorequest')"
+				>
+					Autorequest
+				</button>
+				<button
+					v-else
+					class="button is-default disabled"
+					content="Only available on station pages"
+					v-tippy
+				>
+					Autorequest
+				</button>
+			</div>
+			<div class="tab" v-show="tab === 'songs'">
+				<div class="musare-songs">
+					<label class="label">
+						Search for a song on {{ sitename }}
+					</label>
+					<div class="control is-grouped input-with-button">
+						<p class="control is-expanded">
+							<input
+								class="input"
+								type="text"
+								placeholder="Enter your song query here..."
+								v-model="musareSearch.query"
+								@keyup.enter="searchForMusareSongs(1)"
+							/>
+						</p>
+						<p class="control">
+							<a
+								class="button is-info"
+								@click="searchForMusareSongs(1)"
+								><i class="material-icons icon-with-button"
+									>search</i
+								>Search</a
+							>
+						</p>
+					</div>
+					<div v-if="musareSearch.results.length > 0">
+						<song-item
+							v-for="song in musareSearch.results"
+							:key="song._id"
+							:song="song"
+						>
+							<template #actions>
+								<transition
+									name="musare-search-query-actions"
+									mode="out-in"
+								>
+									<i
+										v-if="
+											songsInQueue.indexOf(
+												song.youtubeId
+											) !== -1
+										"
+										class="material-icons added-to-playlist-icon"
+										content="Song is already in queue"
+										v-tippy
+										>done</i
+									>
+									<i
+										v-else
+										class="material-icons add-to-queue-icon"
+										@click="addSongToQueue(song.youtubeId)"
+										content="Add Song to Queue"
+										v-tippy
+										>queue</i
+									>
+								</transition>
+							</template>
+						</song-item>
+						<button
+							v-if="musareResultsLeftCount > 0"
+							class="button is-primary load-more-button"
+							@click="searchForMusareSongs(musareSearch.page + 1)"
+						>
+							Load {{ nextPageMusareResultsCount }} more results
+						</button>
+					</div>
+				</div>
+
+				<div class="youtube-search">
+					<label class="label"> Search for a song on YouTube </label>
+					<div class="control is-grouped input-with-button">
+						<p class="control is-expanded">
+							<input
+								class="input"
+								type="text"
+								placeholder="Enter your YouTube query here..."
+								v-model="youtubeSearch.songs.query"
+								autofocus
+								@keyup.enter="searchForSongs()"
+							/>
+						</p>
+						<p class="control">
+							<a
+								class="button is-info"
+								@click.prevent="searchForSongs()"
+								><i class="material-icons icon-with-button"
+									>search</i
+								>Search</a
+							>
+						</p>
+					</div>
+
+					<div
+						v-if="youtubeSearch.songs.results.length > 0"
+						id="song-query-results"
+					>
+						<search-query-item
+							v-for="(result, index) in youtubeSearch.songs
+								.results"
+							:key="result.id"
+							:result="result"
+						>
+							<template #actions>
+								<transition
+									name="youtube-search-query-actions"
+									mode="out-in"
+								>
+									<i
+										v-if="
+											songsInQueue.indexOf(result.id) !==
+											-1
+										"
+										class="material-icons added-to-playlist-icon"
+										content="Song is already in queue"
+										v-tippy
+										>done</i
+									>
+									<i
+										v-else
+										class="material-icons add-to-queue-icon"
+										@click="
+											addSongToQueue(result.id, index)
+										"
+										content="Add Song to Queue"
+										v-tippy
+										>queue</i
+									>
+								</transition>
+							</template>
+						</search-query-item>
+
+						<a
+							class="button is-primary load-more-button"
+							@click.prevent="loadMoreSongs()"
+						>
+							Load more...
+						</a>
+					</div>
+				</div>
+			</div>
+			<playlist-tab-base
+				v-if="!disableAutoRequest"
+				class="tab"
+				v-show="tab === 'autorequest'"
+				:type="'autorequest'"
+				:sector="sector"
+				:modal-uuid="modalUuid"
+			/>
+		</div>
+	</div>
+</template>
+<script>
+import { mapState, mapGetters } from "vuex";
+
+import Toast from "toasters";
+
+import SongItem from "@/components/SongItem.vue";
+import SearchQueryItem from "@/components/SearchQueryItem.vue";
+import PlaylistTabBase from "@/components/PlaylistTabBase.vue";
+
+import SearchYoutube from "@/mixins/SearchYoutube.vue";
+import SearchMusare from "@/mixins/SearchMusare.vue";
+
+export default {
+	components: {
+		SongItem,
+		SearchQueryItem,
+		PlaylistTabBase
+	},
+	mixins: [SearchYoutube, SearchMusare],
+	props: {
+		modalUuid: { type: String, default: "" },
+		sector: { type: String, default: "station" },
+		disableAutoRequest: { type: Boolean, default: false }
+	},
+	data() {
+		return {
+			tab: "songs",
+			sitename: "Musare"
+		};
+	},
+	computed: {
+		station: {
+			get() {
+				if (this.sector === "manageStation")
+					return this.$store.state.modals.manageStation[
+						this.modalUuid
+					].station;
+				return this.$store.state.station.station;
+			},
+			set(station) {
+				if (this.sector === "manageStation")
+					this.$store.commit(
+						`modals/manageStation/${this.modalUuid}/updateStation`,
+						station
+					);
+				else this.$store.commit("station/updateStation", station);
+			}
+		},
+		blacklist: {
+			get() {
+				if (this.sector === "manageStation")
+					return this.$store.state.modals.manageStation[
+						this.modalUuid
+					].blacklist;
+				return this.$store.state.station.blacklist;
+			},
+			set(blacklist) {
+				if (this.sector === "manageStation")
+					this.$store.commit(
+						`modals/manageStation/${this.modalUuid}/setBlacklist`,
+						blacklist
+					);
+				else this.$store.commit("station/setBlacklist", blacklist);
+			}
+		},
+		songsList: {
+			get() {
+				if (this.sector === "manageStation")
+					return this.$store.state.modals.manageStation[
+						this.modalUuid
+					].songsList;
+				return this.$store.state.station.songsList;
+			},
+			set(songsList) {
+				if (this.sector === "manageStation")
+					this.$store.commit(
+						`modals/manageStation/${this.modalUuid}/updateSongsList`,
+						songsList
+					);
+				else this.$store.commit("station/updateSongsList", songsList);
+			}
+		},
+		musareResultsLeftCount() {
+			return this.musareSearch.count - this.musareSearch.results.length;
+		},
+		nextPageMusareResultsCount() {
+			return Math.min(
+				this.musareSearch.pageSize,
+				this.musareResultsLeftCount
+			);
+		},
+		songsInQueue() {
+			if (this.station.currentSong)
+				return this.songsList
+					.map(song => song.youtubeId)
+					.concat(this.station.currentSong.youtubeId);
+			return this.songsList.map(song => song.youtubeId);
+		},
+		currentUserQueueSongs() {
+			return this.songsList.filter(
+				queueSong => queueSong.requestedBy === this.userId
+			).length;
+		},
+		...mapState("user", {
+			loggedIn: state => state.auth.loggedIn,
+			role: state => state.auth.role,
+			userId: state => state.auth.userId
+		}),
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	async mounted() {
+		this.sitename = await lofig.get("siteSettings.sitename");
+
+		this.showTab("songs");
+	},
+	methods: {
+		showTab(tab) {
+			this.$refs[`${tab}-tab`].scrollIntoView({ block: "nearest" });
+			this.tab = tab;
+		},
+		addSongToQueue(youtubeId, index) {
+			this.socket.dispatch(
+				"stations.addToQueue",
+				this.station._id,
+				youtubeId,
+				res => {
+					if (res.status !== "success")
+						new Toast(`Error: ${res.message}`);
+					else {
+						if (index)
+							this.youtubeSearch.songs.results[
+								index
+							].isAddedToQueue = true;
+
+						new Toast(res.message);
+					}
+				}
+			);
+		}
+	}
+};
+</script>
+
+<style lang="less" scoped>
+.night-mode {
+	.tabs-container .tab-selection .button {
+		background: var(--dark-grey) !important;
+		color: var(--white) !important;
+	}
+}
+
+:deep(#create-new-playlist-button) {
+	width: 100%;
+}
+
+.station-playlists {
+	.top-info {
+		font-size: 15px;
+		margin-bottom: 15px;
+	}
+
+	.tabs-container {
+		.tab-selection {
+			display: flex;
+			overflow-x: auto;
+
+			.button {
+				border-radius: 0;
+				border: 0;
+				text-transform: uppercase;
+				font-size: 14px;
+				color: var(--dark-grey-3);
+				background-color: var(--light-grey-2);
+				flex-grow: 1;
+				height: 32px;
+
+				&:not(:first-of-type) {
+					margin-left: 5px;
+				}
+			}
+
+			.selected {
+				background-color: var(--primary-color) !important;
+				color: var(--white) !important;
+				font-weight: 600;
+			}
+		}
+		.tab {
+			padding: 10px 0;
+			border-radius: 0;
+			.item.item-draggable:not(:last-of-type) {
+				margin-bottom: 10px;
+			}
+			.load-more-button {
+				width: 100%;
+				margin-top: 10px;
+			}
+		}
+	}
+}
+
+.youtube-search {
+	margin-top: 10px;
+
+	.search-query-item:not(:last-of-type) {
+		margin-bottom: 10px;
+	}
+}
+</style>

+ 0 - 4
frontend/src/components/RunJobDropdown.vue

@@ -56,12 +56,8 @@
 import { mapGetters } from "vuex";
 
 import Toast from "toasters";
-import QuickConfirm from "@/components/QuickConfirm.vue";
 
 export default {
-	components: {
-		QuickConfirm
-	},
 	props: {
 		jobs: {
 			type: Array,

+ 26 - 23
frontend/src/components/SongItem.vue

@@ -37,17 +37,16 @@
 				>
 					{{ formatArtists() }}
 				</h5>
-				<p
-					class="song-request-time"
-					v-if="requestedBy && song.requestedBy"
-				>
+				<p class="song-request-time" v-if="requestedBy">
 					Requested by
 					<strong>
 						<user-id-to-username
+							v-if="song.requestedBy"
 							:key="song._id"
 							:user-id="song.requestedBy"
 							:link="true"
 						/>
+						<span v-else>station</span>
 						{{ formatedRequestedAt }}
 						ago
 					</strong>
@@ -163,12 +162,11 @@ import { mapActions, mapState } from "vuex";
 import { formatDistance, parseISO } from "date-fns";
 
 import AddToPlaylistDropdown from "./AddToPlaylistDropdown.vue";
-import UserIdToUsername from "./UserIdToUsername.vue";
 import SongThumbnail from "./SongThumbnail.vue";
 import utils from "../../js/utils";
 
 export default {
-	components: { UserIdToUsername, AddToPlaylistDropdown, SongThumbnail },
+	components: { AddToPlaylistDropdown, SongThumbnail },
 	props: {
 		song: {
 			type: Object,
@@ -222,11 +220,7 @@ export default {
 	},
 	methods: {
 		formatRequestedAt() {
-			if (
-				this.requestedBy &&
-				this.song.requestedBy &&
-				this.song.requestedAt
-			)
+			if (this.requestedBy && this.song.requestedAt)
 				this.formatedRequestedAt = this.formatDistance(
 					parseISO(this.song.requestedAt),
 					new Date()
@@ -262,16 +256,15 @@ export default {
 		},
 		report(song) {
 			this.hideTippyElements();
-			this.reportSong(song);
-			this.openModal("report");
+			this.openModal({ modal: "report", data: { song } });
 		},
 		edit(song) {
 			this.hideTippyElements();
-			this.editSong({ songId: song._id });
-			this.openModal("editSong");
+			this.openModal({
+				modal: "editSong",
+				data: { song: { songId: song._id } }
+			});
 		},
-		...mapActions("modals/editSong", ["editSong"]),
-		...mapActions("modals/report", ["reportSong"]),
 		...mapActions("modalVisibility", ["openModal"]),
 		formatDistance,
 		parseISO
@@ -301,7 +294,7 @@ export default {
 }
 
 .song-item {
-	min-height: 65px;
+	min-height: 70px;
 
 	&:not(:last-of-type) {
 		margin-bottom: 10px;
@@ -326,9 +319,9 @@ export default {
 	}
 
 	.thumbnail {
-		min-width: 65px;
-		width: 65px;
-		height: 65px;
+		min-width: 70px;
+		width: 70px;
+		height: 70px;
 		margin: -7.5px;
 		margin-right: calc(20px - 7.5px);
 	}
@@ -356,6 +349,10 @@ export default {
 			display: flex;
 			flex-direction: row;
 
+			.item-title {
+				font-size: 18px;
+			}
+
 			.verified-song {
 				margin-left: 5px;
 			}
@@ -369,9 +366,15 @@ export default {
 			}
 		}
 
+		.item-description {
+			line-height: 120%;
+		}
+
 		.song-request-time {
-			font-size: 12px;
-			margin-top: 7px;
+			font-size: 11px;
+			overflow: hidden;
+			text-overflow: ellipsis;
+			white-space: nowrap;
 		}
 	}
 

+ 253 - 0
frontend/src/components/StationInfoBox.vue

@@ -0,0 +1,253 @@
+<template>
+	<div class="about-station-container">
+		<div class="station-info">
+			<div class="row 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>
+				<a>
+					<!-- Favorite Station Button -->
+					<i
+						v-if="loggedIn && station.isFavorited"
+						@click.prevent="unfavoriteStation()"
+						content="Unfavorite Station"
+						v-tippy
+						class="material-icons"
+						>star</i
+					>
+					<i
+						v-if="loggedIn && !station.isFavorited"
+						@click.prevent="favoriteStation()"
+						class="material-icons"
+						content="Favorite Station"
+						v-tippy
+						>star_border</i
+					>
+				</a>
+			</div>
+			<p>{{ station.description }}</p>
+		</div>
+
+		<div class="admin-buttons">
+			<!-- (Admin) Pause/Resume Button -->
+			<button
+				class="button is-danger"
+				v-if="isOwnerOrAdmin() && 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-if="isOwnerOrAdmin() && !stationPaused"
+			>
+				<i class="material-icons icon-with-button">pause</i>
+				<span> Pause Station </span>
+			</button>
+
+			<!-- (Admin) Skip Button -->
+			<button
+				class="button is-danger"
+				@click="skipStation()"
+				v-if="isOwnerOrAdmin()"
+			>
+				<i class="material-icons icon-with-button">skip_next</i>
+				<span> Force Skip </span>
+			</button>
+
+			<!-- (Admin) Station Settings Button -->
+			<button
+				class="button is-primary"
+				@click="
+					openModal({
+						modal: 'manageStation',
+						data: {
+							stationId: station._id,
+							sector: 'station'
+						}
+					})
+				"
+				v-if="isOwnerOrAdmin() && showManageStation"
+			>
+				<i class="material-icons icon-with-button">settings</i>
+				<span> Manage Station </span>
+			</button>
+			<router-link
+				v-if="showGoToStation"
+				:to="{
+					name: 'station',
+					params: { id: station.name }
+				}"
+				class="button is-primary"
+			>
+				Go To Station
+			</router-link>
+		</div>
+	</div>
+</template>
+
+<script>
+import { mapGetters, mapState, mapActions } from "vuex";
+import Toast from "toasters";
+
+export default {
+	props: {
+		station: { type: Object, default: null },
+		stationPaused: { type: Boolean, default: null },
+		showManageStation: { type: Boolean, default: false },
+		showGoToStation: { type: Boolean, default: false }
+	},
+	data() {
+		return {};
+	},
+	computed: {
+		...mapState({
+			loggedIn: state => state.user.auth.loggedIn,
+			userId: state => state.user.auth.userId,
+			role: state => state.user.auth.role
+		}),
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	mounted() {},
+	methods: {
+		isOwnerOnly() {
+			return this.loggedIn && this.userId === this.station.owner;
+		},
+		isAdminOnly() {
+			return this.loggedIn && this.role === "admin";
+		},
+		isOwnerOrAdmin() {
+			return this.isOwnerOnly() || this.isAdminOnly();
+		},
+		resumeStation() {
+			this.socket.dispatch("stations.resume", this.station._id, data => {
+				if (data.status !== "success")
+					new Toast(`Error: ${data.message}`);
+				else new Toast("Successfully resumed the station.");
+			});
+		},
+		pauseStation() {
+			this.socket.dispatch("stations.pause", this.station._id, data => {
+				if (data.status !== "success")
+					new Toast(`Error: ${data.message}`);
+				else new Toast("Successfully paused the station.");
+			});
+		},
+		skipStation() {
+			this.socket.dispatch(
+				"stations.forceSkip",
+				this.station._id,
+				data => {
+					if (data.status !== "success")
+						new Toast(`Error: ${data.message}`);
+					else
+						new Toast(
+							"Successfully skipped the station's current song."
+						);
+				}
+			);
+		},
+		favoriteStation() {
+			this.socket.dispatch(
+				"stations.favoriteStation",
+				this.station._id,
+				res => {
+					if (res.status === "success") {
+						new Toast("Successfully favorited station.");
+					} else new Toast(res.message);
+				}
+			);
+		},
+		unfavoriteStation() {
+			this.socket.dispatch(
+				"stations.unfavoriteStation",
+				this.station._id,
+				res => {
+					if (res.status === "success") {
+						new Toast("Successfully unfavorited station.");
+					} else new Toast(res.message);
+				}
+			);
+		},
+		...mapActions("modalVisibility", ["openModal"])
+	}
+};
+</script>
+
+<style lang="less">
+.night-mode {
+	.about-station-container {
+		background-color: var(--dark-grey-3) !important;
+	}
+}
+
+.about-station-container {
+	padding: 20px;
+	display: flex;
+	flex-direction: column;
+	flex-grow: unset;
+
+	.row {
+		display: flex;
+		flex-direction: row;
+		max-width: 100%;
+	}
+
+	.station-info {
+		.station-name {
+			flex-direction: row !important;
+
+			h1 {
+				margin: 0;
+				font-size: 36px;
+				line-height: 0.8;
+				text-overflow: ellipsis;
+				overflow: hidden;
+			}
+
+			i {
+				margin-left: 10px;
+				font-size: 30px;
+				color: var(--yellow);
+				&.stationMode {
+					padding-left: 10px;
+					margin-left: auto;
+					color: var(--primary-color);
+				}
+			}
+
+			.verified-station {
+				color: var(--primary-color);
+			}
+		}
+
+		p {
+			display: -webkit-box;
+			max-width: 700px;
+			margin-bottom: 10px;
+			overflow: hidden;
+			text-overflow: ellipsis;
+			-webkit-box-orient: vertical;
+			-webkit-line-clamp: 3;
+		}
+	}
+
+	.admin-buttons {
+		display: flex;
+
+		.button {
+			margin: 3px;
+		}
+	}
+}
+</style>

+ 24 - 0
frontend/src/components/global/InfoIcon.vue

@@ -0,0 +1,24 @@
+<template>
+	<span class="material-icons info-icon" :content="tooltip" v-tippy>
+		info
+	</span>
+</template>
+
+<script>
+export default {
+	props: {
+		tooltip: {
+			type: String,
+			required: true
+		}
+	}
+};
+</script>
+
+<style lang="less" scoped>
+.material-icons.info-icon {
+	font-size: 14px;
+	margin: auto 5px;
+	cursor: help;
+}
+</style>

+ 6 - 18
frontend/src/components/layout/MainFooter.vue → frontend/src/components/global/MainFooter.vue

@@ -3,7 +3,7 @@
 		<div class="container">
 			<div class="footer-content">
 				<div id="footer-copyright">
-					<p>© Copyright {{ siteSettings.sitename }} 2015 - 2022</p>
+					<p>© Copyright Musare 2015 - 2022</p>
 				</div>
 				<router-link id="footer-logo" to="/">
 					<img
@@ -48,15 +48,12 @@
 </template>
 
 <script>
-import { mapState, mapGetters } from "vuex";
-
 export default {
 	data() {
 		return {
 			siteSettings: {
-				logo: "",
+				logo_blue: "/assets/blue_wordmark.png",
 				sitename: "Musare",
-				github: "",
 				footerLinks: {}
 			}
 		};
@@ -73,16 +70,9 @@ export default {
 						)
 				)
 			);
-		},
-		...mapState({
-			loggedIn: state => state.user.auth.loggedIn
-		}),
-		...mapGetters({
-			socket: "websockets/getSocket"
-		})
+		}
 	},
 	async mounted() {
-		this.frontendDomain = await lofig.get("frontendDomain");
 		lofig.get("siteSettings").then(siteSettings => {
 			this.siteSettings = {
 				...siteSettings,
@@ -120,6 +110,7 @@ export default {
 	position: relative;
 	bottom: 0;
 	flex-shrink: 0;
+	height: auto;
 	padding: 20px;
 	box-shadow: @box-shadow;
 	background-color: var(--white);
@@ -160,7 +151,6 @@ export default {
 		white-space: nowrap;
 
 		img {
-			max-height: 38px;
 			max-width: 100%;
 			color: var(--primary-color);
 			user-select: none;
@@ -169,7 +159,7 @@ export default {
 	}
 
 	#footer-links {
-		order: 3;
+		order: 2;
 
 		:not(:last-child) {
 			border-right: solid 1px var(--primary-color);
@@ -195,7 +185,7 @@ export default {
 	}
 
 	#footer-copyright {
-		order: 4;
+		order: 3;
 	}
 }
 
@@ -204,7 +194,6 @@ export default {
 		height: 100px;
 
 		#footer-copyright {
-			order: 3;
 			left: 0;
 			top: 0;
 			position: absolute;
@@ -212,7 +201,6 @@ export default {
 		}
 
 		#footer-links {
-			order: 2;
 			right: 0;
 			top: 0;
 			position: absolute;

+ 0 - 0
frontend/src/components/layout/MainHeader.vue → frontend/src/components/global/MainHeader.vue


+ 0 - 0
frontend/src/components/Modal.vue → frontend/src/components/global/Modal.vue


+ 0 - 0
frontend/src/components/QuickConfirm.vue → frontend/src/components/global/QuickConfirm.vue


+ 0 - 0
frontend/src/components/UserIdToUsername.vue → frontend/src/components/global/UserIdToUsername.vue


+ 8 - 6
frontend/src/components/modals/BulkActions.vue

@@ -69,18 +69,15 @@ import { mapGetters, mapActions } from "vuex";
 
 import Toast from "toasters";
 
-import Modal from "../Modal.vue";
 import AutoSuggest from "@/components/AutoSuggest.vue";
 
 import ws from "@/ws";
+import { mapModalState } from "@/vuex_helpers";
 
 export default {
-	components: { Modal, AutoSuggest },
+	components: { AutoSuggest },
 	props: {
-		type: {
-			type: Object,
-			default: () => {}
-		}
+		modalUuid: { type: String, default: "" }
 	},
 	data() {
 		return {
@@ -91,6 +88,9 @@ export default {
 		};
 	},
 	computed: {
+		...mapModalState("modals/bulkActions/MODAL_UUID", {
+			type: state => state.type
+		}),
 		...mapGetters({
 			socket: "websockets/getSocket"
 		})
@@ -98,6 +98,8 @@ export default {
 	beforeUnmount() {
 		this.itemInput = null;
 		this.items = [];
+		// Delete the VueX module that was created for this modal, after all other cleanup tasks are performed
+		this.$store.unregisterModule(["modals", "bulkActions", this.modalUuid]);
 	},
 	mounted() {
 		ws.onConnect(this.init);

+ 13 - 20
frontend/src/components/modals/Confirm.vue

@@ -17,37 +17,30 @@
 </template>
 
 <script>
-import { mapState, mapActions } from "vuex";
-import Modal from "../Modal.vue";
+import { mapActions } from "vuex";
+
+import { mapModalState, mapModalActions } from "@/vuex_helpers";
 
 export default {
-	components: { Modal },
-	emits: ["confirmed"],
-	data() {
-		return {
-			modalName: ""
-		};
+	props: {
+		modalUuid: { type: String, default: "" }
 	},
 	computed: {
-		...mapState("modalVisibility", {
-			currentlyActive: state => state.currentlyActive
-		}),
-		...mapState("modals/confirm", {
+		...mapModalState("modals/confirm/MODAL_UUID", {
 			message: state => state.message
 		})
 	},
-	mounted() {
-		// eslint-disable-next-line
-		this.modalName = this.currentlyActive[0];
+	beforeUnmount() {
+		// Delete the VueX module that was created for this modal, after all other cleanup tasks are performed
+		this.$store.unregisterModule(["modals", "confirm", this.modalUuid]);
 	},
 	methods: {
 		confirmAction() {
-			this.updateConfirmMessage("");
-			this.$emit("confirmed");
-			this.closeModal(this.modalName);
+			this.confirm();
+			this.closeCurrentModal();
 		},
-		...mapActions("modals/confirm", ["updateConfirmMessage"]),
-		...mapActions("modalVisibility", ["closeModal"])
+		...mapModalActions("modals/confirm/MODAL_UUID", ["confirm"]),
+		...mapActions("modalVisibility", ["closeCurrentModal"])
 	}
 };
 </script>

+ 8 - 6
frontend/src/components/modals/CreatePlaylist.vue

@@ -37,10 +37,11 @@ import { mapActions, mapGetters } from "vuex";
 
 import Toast from "toasters";
 import validation from "@/validation";
-import Modal from "../Modal.vue";
 
 export default {
-	components: { Modal },
+	props: {
+		modalUuid: { type: String, default: "" }
+	},
 	data() {
 		return {
 			playlist: {
@@ -85,15 +86,16 @@ export default {
 						this.closeModal("createPlaylist");
 
 						if (!window.addToPlaylistDropdown) {
-							this.editPlaylist(res.data.playlistId);
-							this.openModal("editPlaylist");
+							this.openModal({
+								modal: "editPlaylist",
+								data: { playlistId: res.data.playlistId }
+							});
 						}
 					}
 				}
 			);
 		},
-		...mapActions("modalVisibility", ["closeModal", "openModal"]),
-		...mapActions("user/playlists", ["editPlaylist"])
+		...mapActions("modalVisibility", ["closeModal", "openModal"])
 	}
 };
 </script>

+ 19 - 6
frontend/src/components/modals/CreateStation.vue

@@ -45,13 +45,13 @@
 import { mapGetters, mapActions } from "vuex";
 
 import Toast from "toasters";
+import { mapModalState } from "@/vuex_helpers";
+
 import validation from "@/validation";
-import Modal from "../Modal.vue";
 
 export default {
-	components: { Modal },
 	props: {
-		official: { type: Boolean, default: false }
+		modalUuid: { type: String, default: "" }
 	},
 	data() {
 		return {
@@ -62,9 +62,22 @@ export default {
 			}
 		};
 	},
-	computed: mapGetters({
-		socket: "websockets/getSocket"
-	}),
+	computed: {
+		...mapModalState("modals/createStation/MODAL_UUID", {
+			official: state => state.official
+		}),
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	beforeUnmount() {
+		// Delete the VueX module that was created for this modal, after all other cleanup tasks are performed
+		this.$store.unregisterModule([
+			"modals",
+			"createStation",
+			this.modalUuid
+		]);
+	},
 	methods: {
 		submitModal() {
 			this.newStation.name = this.newStation.name.toLowerCase();

+ 20 - 18
frontend/src/components/modals/EditNews.vue

@@ -1,7 +1,7 @@
 <template>
 	<modal
 		class="edit-news-modal"
-		:title="newsId ? 'Edit News' : 'Create News'"
+		:title="createNews ? 'Create News' : 'Edit News'"
 		:size="'wide'"
 		:split="true"
 	>
@@ -45,14 +45,14 @@
 
 				<save-button
 					ref="saveButton"
-					v-if="newsId"
-					@clicked="newsId ? update(false) : create(false)"
+					v-if="createNews"
+					@clicked="createNews ? create(false) : update(false)"
 				/>
 
 				<save-button
 					ref="saveAndCloseButton"
 					default-message="Save and close"
-					@clicked="newsId ? update(true) : create(true)"
+					@clicked="createNews ? create(true) : update(true)"
 				/>
 				<div class="right" v-if="createdAt > 0">
 					<span>
@@ -76,22 +76,21 @@
 </template>
 
 <script>
-import { mapActions, mapGetters, mapState } from "vuex";
+import { mapActions, mapGetters } from "vuex";
 import { marked } from "marked";
 import { sanitize } from "dompurify";
 import Toast from "toasters";
 import { formatDistance } from "date-fns";
 
-import UserIdToUsername from "@/components/UserIdToUsername.vue";
 import ws from "@/ws";
 import SaveButton from "../SaveButton.vue";
-import Modal from "../Modal.vue";
+
+import { mapModalState } from "@/vuex_helpers";
 
 export default {
-	components: { Modal, SaveButton, UserIdToUsername },
+	components: { SaveButton },
 	props: {
-		newsId: { type: String, default: "" },
-		sector: { type: String, default: "admin" }
+		modalUuid: { type: String, default: "" }
 	},
 	data() {
 		return {
@@ -104,7 +103,11 @@ export default {
 		};
 	},
 	computed: {
-		...mapState("modals/editNews", { news: state => state.news }),
+		...mapModalState("modals/editNews/MODAL_UUID", {
+			createNews: state => state.createNews,
+			newsId: state => state.newsId,
+			sector: state => state.sector
+		}),
 		...mapGetters({ socket: "websockets/getSocket" })
 	},
 	mounted() {
@@ -121,9 +124,13 @@ export default {
 
 		ws.onConnect(this.init);
 	},
+	beforeUnmount() {
+		// Delete the VueX module that was created for this modal, after all other cleanup tasks are performed
+		this.$store.unregisterModule(["modals", "editNews", this.modalUuid]);
+	},
 	methods: {
 		init() {
-			if (this.newsId) {
+			if (this.newsId && !this.createNews) {
 				this.socket.dispatch(`news.getNewsFromId`, this.newsId, res => {
 					if (res.status === "success") {
 						const {
@@ -224,12 +231,7 @@ export default {
 			);
 		},
 		formatDistance,
-		...mapActions("modalVisibility", ["closeModal"]),
-		...mapActions("modals/editNews", [
-			"editNews",
-			"addChange",
-			"removeChange"
-		])
+		...mapActions("modalVisibility", ["closeModal"])
 	}
 };
 </script>

+ 16 - 3
frontend/src/components/modals/EditPlaylist/Tabs/AddSongs.vue

@@ -1,7 +1,7 @@
 <template>
 	<div class="youtube-tab section">
 		<div>
-			<label class="label"> Search for a song on Musare </label>
+			<label class="label"> Search for a song on {{ sitename }}</label>
 			<div class="control is-grouped input-with-button">
 				<p class="control is-expanded">
 					<input
@@ -140,7 +140,9 @@
 </template>
 
 <script>
-import { mapState, mapGetters } from "vuex";
+import { mapGetters } from "vuex";
+
+import { mapModalState } from "@/vuex_helpers";
 
 import SearchYoutube from "@/mixins/SearchYoutube.vue";
 import SearchMusare from "@/mixins/SearchMusare.vue";
@@ -151,8 +153,16 @@ import SearchQueryItem from "@/components/SearchQueryItem.vue";
 export default {
 	components: { SearchQueryItem, SongItem },
 	mixins: [SearchYoutube, SearchMusare],
+	props: {
+		modalUuid: { type: String, default: "" }
+	},
+	data() {
+		return {
+			sitename: "Musare"
+		};
+	},
 	computed: {
-		...mapState("modals/editPlaylist", {
+		...mapModalState("modals/editPlaylist/MODAL_UUID", {
 			playlist: state => state.playlist
 		}),
 		...mapGetters({
@@ -207,6 +217,9 @@ export default {
 				})
 			);
 		}
+	},
+	async mounted() {
+		this.sitename = await lofig.get("siteSettings.sitename");
 	}
 };
 </script>

+ 6 - 2
frontend/src/components/modals/EditPlaylist/Tabs/ImportPlaylists.vue

@@ -32,18 +32,22 @@
 </template>
 
 <script>
-import { mapState, mapGetters } from "vuex";
+import { mapGetters } from "vuex";
 import Toast from "toasters";
 
+import { mapModalState } from "@/vuex_helpers";
 import SearchYoutube from "@/mixins/SearchYoutube.vue";
 
 export default {
 	mixins: [SearchYoutube],
+	props: {
+		modalUuid: { type: String, default: "" }
+	},
 	data() {
 		return {};
 	},
 	computed: {
-		...mapState("modals/editPlaylist", {
+		...mapModalState("modals/editPlaylist/MODAL_UUID", {
 			playlist: state => state.playlist
 		}),
 		...mapGetters({

+ 5 - 1
frontend/src/components/modals/EditPlaylist/Tabs/Settings.vue

@@ -63,14 +63,18 @@
 import { mapState, mapGetters /* , mapActions */ } from "vuex";
 import Toast from "toasters";
 
+import { mapModalState } from "@/vuex_helpers";
 import validation from "@/validation";
 
 export default {
+	props: {
+		modalUuid: { type: String, default: "" }
+	},
 	data() {
 		return {};
 	},
 	computed: {
-		...mapState("modals/editPlaylist", {
+		...mapModalState("modals/editPlaylist/MODAL_UUID", {
 			playlist: state => state.playlist
 		}),
 		...mapGetters({

+ 49 - 24
frontend/src/components/modals/EditPlaylist/index.vue

@@ -66,16 +66,19 @@
 								playlist.type === 'artist') &&
 								isAdmin())
 						"
+						:modal-uuid="modalUuid"
 					/>
 					<add-songs
 						class="tab"
 						v-show="tab === 'add-songs'"
 						v-if="isEditable()"
+						:modal-uuid="modalUuid"
 					/>
 					<import-playlists
 						class="tab"
 						v-show="tab === 'import-playlists'"
 						v-if="isEditable()"
+						:modal-uuid="modalUuid"
 					/>
 				</div>
 			</div>
@@ -119,8 +122,18 @@
 											<i
 												class="material-icons add-to-queue-icon"
 												v-if="
-													station.partyMode &&
-													!station.locked
+													station &&
+													station.requests &&
+													station.requests.enabled &&
+													(station.requests.access ===
+														'user' ||
+														(station.requests
+															.access ===
+															'owner' &&
+															(userRole ===
+																'admin' ||
+																station.owner ===
+																	userId)))
 												"
 												@click="
 													addSongToQueue(
@@ -251,9 +264,8 @@ import { mapState, mapGetters, mapActions } from "vuex";
 import draggable from "vuedraggable";
 import Toast from "toasters";
 
+import { mapModalState, mapModalActions } from "@/vuex_helpers";
 import ws from "@/ws";
-import QuickConfirm from "@/components/QuickConfirm.vue";
-import Modal from "../../Modal.vue";
 import SongItem from "../../SongItem.vue";
 
 import Settings from "./Tabs/Settings.vue";
@@ -264,14 +276,15 @@ import utils from "../../../../js/utils";
 
 export default {
 	components: {
-		Modal,
 		draggable,
-		QuickConfirm,
 		SongItem,
 		Settings,
 		AddSongs,
 		ImportPlaylists
 	},
+	props: {
+		modalUuid: { type: String, default: "" }
+	},
 	data() {
 		return {
 			utils,
@@ -284,20 +297,19 @@ export default {
 		...mapState("station", {
 			station: state => state.station
 		}),
-		...mapState("user/playlists", {
-			editing: state => state.editing
-		}),
-		...mapState("modals/editPlaylist", {
+		...mapModalState("modals/editPlaylist/MODAL_UUID", {
+			playlistId: state => state.playlistId,
 			tab: state => state.tab,
 			playlist: state => state.playlist
 		}),
 		playlistSongs: {
 			get() {
-				return this.$store.state.modals.editPlaylist.playlist.songs;
+				return this.$store.state.modals.editPlaylist[this.modalUuid]
+					.playlist.songs;
 			},
 			set(value) {
 				this.$store.commit(
-					"modals/editPlaylist/updatePlaylistSongs",
+					`modals/editPlaylist/${this.modalUuid}/updatePlaylistSongs`,
 					value
 				);
 			}
@@ -328,7 +340,7 @@ export default {
 				if (this.playlist._id === res.data.playlistId)
 					this.addSong(res.data.song);
 			},
-			{ modal: "editPlaylist" }
+			{ modalUuid: this.modalUuid }
 		);
 
 		this.socket.on(
@@ -339,7 +351,7 @@ export default {
 					this.removeSong(res.data.youtubeId);
 				}
 			},
-			{ modal: "editPlaylist" }
+			{ modalUuid: this.modalUuid }
 		);
 
 		this.socket.on(
@@ -353,7 +365,7 @@ export default {
 					this.setPlaylist(playlist);
 				}
 			},
-			{ modal: "editPlaylist" }
+			{ modalUuid: this.modalUuid }
 		);
 
 		this.socket.on(
@@ -367,21 +379,31 @@ export default {
 					}
 				}
 			},
-			{ modal: "editPlaylist" }
+			{ modalUuid: this.modalUuid }
 		);
 	},
 	beforeUnmount() {
 		this.clearPlaylist();
+		// Delete the VueX module that was created for this modal, after all other cleanup tasks are performed
+		this.$store.unregisterModule([
+			"modals",
+			"editPlaylist",
+			this.modalUuid
+		]);
 	},
 	methods: {
 		init() {
 			this.gettingSongs = true;
-			this.socket.dispatch("playlists.getPlaylist", this.editing, res => {
-				if (res.status === "success") {
-					this.setPlaylist(res.data.playlist);
-				} else new Toast(res.message);
-				this.gettingSongs = false;
-			});
+			this.socket.dispatch(
+				"playlists.getPlaylist",
+				this.playlistId,
+				res => {
+					if (res.status === "success") {
+						this.setPlaylist(res.data.playlist);
+					} else new Toast(res.message);
+					this.gettingSongs = false;
+				}
+			);
 		},
 		isEditable() {
 			return (
@@ -588,10 +610,13 @@ export default {
 				this.$refs[`${payload}-tab`].scrollIntoView({
 					block: "nearest"
 				});
-				return dispatch("modals/editPlaylist/showTab", payload);
+				return dispatch(
+					`modals/editPlaylist/${this.modalUuid}/showTab`,
+					payload
+				);
 			}
 		}),
-		...mapActions("modals/editPlaylist", [
+		...mapModalActions("modals/editPlaylist/MODAL_UUID", [
 			"setPlaylist",
 			"clearPlaylist",
 			"addSong",

+ 9 - 3
frontend/src/components/modals/EditSong/Tabs/Discogs.vue

@@ -151,14 +151,20 @@
 </template>
 
 <script>
-import { mapState, mapGetters, mapActions } from "vuex";
+import { mapGetters } from "vuex";
 
 import Toast from "toasters";
+import { mapModalState, mapModalActions } from "@/vuex_helpers";
 
 import keyboardShortcuts from "@/keyboardShortcuts";
 
 export default {
 	props: {
+		modalUuid: { type: String, default: "" },
+		modalModulePath: {
+			type: String,
+			default: "modals/editSong/MODAL_UUID"
+		},
 		bulk: { type: Boolean, default: false }
 	},
 	data() {
@@ -173,7 +179,7 @@ export default {
 		};
 	},
 	computed: {
-		...mapState("modals/editSong", {
+		...mapModalState("MODAL_MODULE_PATH", {
 			song: state => state.song
 		}),
 		...mapGetters({
@@ -288,7 +294,7 @@ export default {
 
 			this.selectDiscogsInfo(apiResult);
 		},
-		...mapActions("modals/editSong", ["selectDiscogsInfo"])
+		...mapModalActions("MODAL_MODULE_PATH", ["selectDiscogsInfo"])
 	}
 };
 </script>

+ 12 - 6
frontend/src/components/modals/EditSong/Tabs/Reports.vue

@@ -188,13 +188,19 @@
 </template>
 
 <script>
-import { mapState, mapGetters, mapActions } from "vuex";
+import { mapGetters, mapActions } from "vuex";
 
 import Toast from "toasters";
+import { mapModalState, mapModalActions } from "@/vuex_helpers";
+
 import ReportInfoItem from "@/components/ReportInfoItem.vue";
 
 export default {
 	components: { ReportInfoItem },
+	props: {
+		modalUuid: { type: String, default: "" },
+		modalModulePath: { type: String, default: "modals/editSong/MODAL_UUID" }
+	},
 	data() {
 		return {
 			tab: "sort-by-report",
@@ -209,7 +215,7 @@ export default {
 		};
 	},
 	computed: {
-		...mapState("modals/editSong", {
+		...mapModalState("MODAL_MODULE_PATH", {
 			reports: state => state.reports
 		}),
 		...mapGetters({
@@ -239,13 +245,13 @@ export default {
 		this.socket.on(
 			"event:admin.report.created",
 			res => this.reports.unshift(res.data.report),
-			{ modal: "editSong" }
+			{ modalUuid: this.modalUuid }
 		);
 
 		this.socket.on(
 			"event:admin.report.resolved",
 			res => this.resolveReport(res.data.reportId),
-			{ modal: "editSong" }
+			{ modalUuid: this.modalUuid }
 		);
 
 		this.socket.on(
@@ -261,7 +267,7 @@ export default {
 					}
 				});
 			},
-			{ modal: "editSong" }
+			{ modalUuid: this.modalUuid }
 		);
 	},
 	methods: {
@@ -286,7 +292,7 @@ export default {
 				}
 			);
 		},
-		...mapActions("modals/editSong", ["resolveReport"]),
+		...mapModalActions("MODAL_MODULE_PATH", ["resolveReport"]),
 		...mapActions("modalVisibility", ["closeModal"])
 	}
 };

+ 15 - 5
frontend/src/components/modals/EditSong/Tabs/Songs.vue

@@ -1,6 +1,6 @@
 <template>
 	<div class="musare-songs-tab">
-		<label class="label"> Search for a song on Musare </label>
+		<label class="label"> Search for a song on {{ sitename }} </label>
 		<div class="control is-grouped input-with-button">
 			<p class="control is-expanded">
 				<input
@@ -37,7 +37,9 @@
 </template>
 
 <script>
-import { mapGetters, mapState } from "vuex";
+import { mapGetters } from "vuex";
+
+import { mapModalState } from "@/vuex_helpers";
 
 import SearchMusare from "@/mixins/SearchMusare.vue";
 
@@ -48,18 +50,26 @@ export default {
 		SongItem
 	},
 	mixins: [SearchMusare],
+	props: {
+		modalUuid: { type: String, default: "" },
+		modalModulePath: { type: String, default: "modals/editSong/MODAL_UUID" }
+	},
 	data() {
-		return {};
+		return {
+			sitename: "Musare"
+		};
 	},
 	computed: {
-		...mapState("modals/editSong", {
+		...mapModalState("MODAL_MODULE_PATH", {
 			song: state => state.song
 		}),
 		...mapGetters({
 			socket: "websockets/getSocket"
 		})
 	},
-	mounted() {
+	async mounted() {
+		this.sitename = await lofig.get("siteSettings.sitename");
+
 		this.musareSearch.query = this.song.title;
 		this.searchForMusareSongs(1, false);
 	}

+ 9 - 3
frontend/src/components/modals/EditSong/Tabs/Youtube.vue

@@ -59,7 +59,9 @@
 </template>
 
 <script>
-import { mapGetters, mapState, mapActions } from "vuex";
+import { mapGetters } from "vuex";
+
+import { mapModalState, mapModalActions } from "@/vuex_helpers";
 
 import SearchYoutube from "@/mixins/SearchYoutube.vue";
 
@@ -68,11 +70,15 @@ import SearchQueryItem from "../../../SearchQueryItem.vue";
 export default {
 	components: { SearchQueryItem },
 	mixins: [SearchYoutube],
+	props: {
+		modalUuid: { type: String, default: "" },
+		modalModulePath: { type: String, default: "modals/editSong/MODAL_UUID" }
+	},
 	data() {
 		return {};
 	},
 	computed: {
-		...mapState("modals/editSong", {
+		...mapModalState("MODAL_MODULE_PATH", {
 			song: state => state.song,
 			newSong: state => state.newSong
 		}),
@@ -89,7 +95,7 @@ export default {
 				this.updateThumbnail(result.thumbnail);
 			}
 		},
-		...mapActions("modals/editSong", [
+		...mapModalActions("MODAL_MODULE_PATH", [
 			"updateYoutubeId",
 			"updateTitle",
 			"updateThumbnail"

+ 84 - 37
frontend/src/components/modals/EditSong/index.vue

@@ -326,6 +326,12 @@
 											getAlbumData('albumArt')
 										"
 									/>
+									<button
+										class="button youtube-get-button"
+										@click="getYouTubeData('albumArt')"
+									>
+										<div class="youtube-icon"></div>
+									</button>
 									<button
 										class="button album-get-button"
 										@click="getAlbumData('albumArt')"
@@ -554,16 +560,27 @@
 							class="tab"
 							v-show="tab === 'discogs'"
 							:bulk="bulk"
+							:modal-uuid="modalUuid"
+							:modal-module-path="modalModulePath"
 						/>
 						<reports
 							v-if="!newSong"
 							class="tab"
 							v-show="tab === 'reports'"
+							:modal-uuid="modalUuid"
+							:modal-module-path="modalModulePath"
+						/>
+						<youtube
+							class="tab"
+							v-show="tab === 'youtube'"
+							:modal-uuid="modalUuid"
+							:modal-module-path="modalModulePath"
 						/>
-						<youtube class="tab" v-show="tab === 'youtube'" />
 						<musare-songs
 							class="tab"
 							v-show="tab === 'musare-songs'"
+							:modal-uuid="modalUuid"
+							:modal-module-path="modalModulePath"
 						/>
 					</div>
 				</div>
@@ -631,21 +648,20 @@
 				</span>
 			</template>
 		</floating-box>
-		<confirm v-if="modals.editSongConfirm" @confirmed="handleConfirmed()" />
 	</div>
 </template>
 
 <script>
 import { mapState, mapGetters, mapActions } from "vuex";
-import { defineAsyncComponent } from "vue";
 import Toast from "toasters";
 
+import { mapModalState, mapModalActions } from "@/vuex_helpers";
+
 import aw from "@/aw";
 import ws from "@/ws";
 import validation from "@/validation";
 import keyboardShortcuts from "@/keyboardShortcuts";
 
-import Modal from "../../Modal.vue";
 import FloatingBox from "../../FloatingBox.vue";
 import SaveButton from "../../SaveButton.vue";
 import AutoSuggest from "@/components/AutoSuggest.vue";
@@ -657,22 +673,22 @@ import MusareSongs from "./Tabs/Songs.vue";
 
 export default {
 	components: {
-		Modal,
 		FloatingBox,
 		SaveButton,
 		AutoSuggest,
 		Discogs,
 		Reports,
 		Youtube,
-		MusareSongs,
-		Confirm: defineAsyncComponent(() =>
-			import("@/components/modals/Confirm.vue")
-		)
+		MusareSongs
 	},
 	props: {
 		// songId: { type: String, default: null },
+		modalUuid: { type: String, default: "" },
+		modalModulePath: {
+			type: String,
+			default: "modals/editSong/MODAL_UUID"
+		},
 		discogsAlbum: { type: Object, default: null },
-		sector: { type: String, default: "admin" },
 		bulk: { type: Boolean, default: false },
 		flagged: { type: Boolean, default: false }
 	},
@@ -703,11 +719,6 @@ export default {
 			activityWatchVideoDataInterval: null,
 			activityWatchVideoLastStatus: "",
 			activityWatchVideoLastStartDuration: "",
-			confirm: {
-				message: "",
-				action: "",
-				params: null
-			},
 			recommendedGenres: [
 				"Blues",
 				"Country",
@@ -759,10 +770,10 @@ export default {
 			return (
 				this.songDataLoaded &&
 				this.song.thumbnail &&
-				this.song.thumbnail.startsWith("https://i.ytimg.com")
+				this.song.thumbnail.startsWith("https://i.ytimg.com/")
 			);
 		},
-		...mapState("modals/editSong", {
+		...mapModalState("MODAL_MODULE_PATH", {
 			tab: state => state.tab,
 			video: state => state.video,
 			song: state => state.song,
@@ -773,8 +784,7 @@ export default {
 			newSong: state => state.newSong
 		}),
 		...mapState("modalVisibility", {
-			modals: state => state.modals,
-			currentlyActive: state => state.currentlyActive
+			activeModals: state => state.activeModals
 		}),
 		...mapGetters({
 			socket: "websockets/getSocket"
@@ -819,7 +829,7 @@ export default {
 						this.songDeleted = true;
 					}
 				},
-				{ modal: this.bulk ? "editSongs" : "editSong" }
+				{ modalUuid: this.modalUuid }
 			);
 		}
 
@@ -953,8 +963,12 @@ export default {
 			keyCode: 27,
 			handler: () => {
 				if (
-					this.currentlyActive[0] === "editSong" ||
-					this.currentlyActive[0] === "editSongs"
+					this.modals[
+						this.activeModals[this.activeModals.length - 1]
+					] === "editSong" ||
+					this.modals[
+						this.activeModals[this.activeModals.length - 1]
+					] === "editSongs"
 				) {
 					this.onCloseModal();
 				}
@@ -1015,6 +1029,15 @@ export default {
 		shortcutNames.forEach(shortcutName => {
 			keyboardShortcuts.unregisterShortcut(shortcutName);
 		});
+
+		if (!this.bulk) {
+			// Delete the VueX module that was created for this modal, after all other cleanup tasks are performed
+			this.$store.unregisterModule([
+				"modals",
+				"editSong",
+				this.modalUuid
+			]);
+		}
 	},
 	methods: {
 		onThumbnailLoad() {
@@ -1545,6 +1568,13 @@ export default {
 					)
 				});
 		},
+		getYouTubeData(type) {
+			if (type === "albumArt")
+				this.updateSongField({
+					field: "thumbnail",
+					value: `https://img.youtube.com/vi/${this.song.youtubeId}/mqdefault.jpg`
+				});
+		},
 		fillDuration() {
 			this.song.duration =
 				this.youtubeVideoDuration - this.song.skipDuration;
@@ -1771,22 +1801,22 @@ export default {
 				new Toast(res.message);
 			});
 		},
-		confirmAction(confirm) {
-			this.confirm = confirm;
-			this.updateConfirmMessage(confirm.message);
-			this.openModal("editSongConfirm");
+		confirmAction({ message, action, params }) {
+			this.openModal({
+				modal: "confirm",
+				data: {
+					message,
+					action,
+					params,
+					onCompleted: this.handleConfirmed
+				}
+			});
 		},
-		handleConfirmed() {
-			const { action, params } = this.confirm;
+		handleConfirmed({ action, params }) {
 			if (typeof this[action] === "function") {
 				if (params) this[action](params);
 				else this[action]();
 			}
-			this.confirm = {
-				message: "",
-				action: "",
-				params: null
-			};
 		},
 		onCloseModal() {
 			const songStringified = JSON.stringify({
@@ -1819,10 +1849,16 @@ export default {
 					this.$refs[`${payload}-tab`].scrollIntoView({
 						block: "nearest"
 					});
-				return dispatch("modals/editSong/showTab", payload);
+				return dispatch(
+					`${this.modalModulePath.replace(
+						"MODAL_UUID",
+						this.modalUuid
+					)}/showTab`,
+					payload
+				);
 			}
 		}),
-		...mapActions("modals/editSong", [
+		...mapModalActions("MODAL_MODULE_PATH", [
 			"stopVideo",
 			"hardStopVideo",
 			"loadVideoById",
@@ -1835,7 +1871,6 @@ export default {
 			"updateReports",
 			"setPlaybackRate"
 		]),
-		...mapActions("modals/confirm", ["updateConfirmMessage"]),
 		...mapActions("modalVisibility", ["closeModal", "openModal"])
 	}
 };
@@ -1862,6 +1897,7 @@ export default {
 		.edit-section {
 			.album-get-button,
 			.duration-fill-button,
+			.youtube-get-button,
 			.add-button {
 				&:focus,
 				&:hover {
@@ -2122,7 +2158,8 @@ export default {
 			border-width: 0;
 		}
 
-		.duration-fill-button {
+		.duration-fill-button,
+		.youtube-get-button {
 			background-color: var(--dark-red);
 			color: var(--white);
 			width: 32px;
@@ -2141,6 +2178,7 @@ export default {
 
 		.album-get-button,
 		.duration-fill-button,
+		.youtube-get-button,
 		.add-button {
 			&:focus,
 			&:hover {
@@ -2149,6 +2187,15 @@ export default {
 			}
 		}
 
+		.youtube-get-button {
+			padding-left: 4px;
+			padding-right: 4px;
+
+			.youtube-icon {
+				background: var(--white);
+			}
+		}
+
 		> div {
 			margin: 16px !important;
 		}

+ 37 - 36
frontend/src/components/modals/EditSongs.vue

@@ -1,6 +1,8 @@
 <template>
 	<div>
 		<edit-song
+			:modal-module-path="`modals/editSongs/${this.modalUuid}/editSong`"
+			:modal-uuid="this.modalUuid"
 			:bulk="true"
 			:flagged="currentSongFlagged"
 			v-if="currentSong"
@@ -164,43 +166,36 @@
 				></div>
 			</template>
 		</edit-song>
-		<confirm
-			v-if="modals.editSongsConfirm"
-			@confirmed="handleConfirmed()"
-		/>
 	</div>
 </template>
 
 <script>
-import { mapState, mapActions, mapGetters } from "vuex";
+import { mapActions, mapGetters } from "vuex";
 import { defineAsyncComponent } from "vue";
 
 import Toast from "toasters";
+import { mapModalState, mapModalActions } from "@/vuex_helpers";
 
 import SongItem from "@/components/SongItem.vue";
 
+import editSong from "@/store/modules/modals/editSong";
+
 export default {
 	components: {
 		EditSong: defineAsyncComponent(() =>
 			import("@/components/modals/EditSong")
 		),
-		Confirm: defineAsyncComponent(() =>
-			import("@/components/modals/Confirm.vue")
-		),
 		SongItem
 	},
-	props: {},
+	props: {
+		modalUuid: { type: String, default: "" }
+	},
 	data() {
 		return {
 			items: [],
 			currentSong: {},
 			flagFilter: false,
-			sidebarMobileActive: false,
-			confirm: {
-				message: "",
-				action: "",
-				params: null
-			}
+			sidebarMobileActive: false
 		};
 	},
 	computed: {
@@ -232,13 +227,10 @@ export default {
 				item => item.song._id === this.currentSong._id
 			)?.flagged;
 		},
-		...mapState("modals/editSongs", {
+		...mapModalState("modals/editSongs/MODAL_UUID", {
 			songIds: state => state.songIds,
 			songPrefillData: state => state.songPrefillData
 		}),
-		...mapState("modalVisibility", {
-			modals: state => state.modals
-		}),
 		...mapGetters({
 			socket: "websockets/getSocket"
 		})
@@ -246,6 +238,11 @@ export default {
 	async mounted() {
 		this.socket.dispatch("apis.joinRoom", "edit-songs");
 
+		this.$store.registerModule(
+			["modals", "editSongs", this.modalUuid, "editSong"],
+			editSong
+		);
+
 		this.socket.dispatch("songs.getSongsFromSongIds", this.songIds, res => {
 			res.data.songs.forEach(song => {
 				this.items.push({
@@ -281,7 +278,10 @@ export default {
 	},
 	beforeUnmount() {
 		this.socket.dispatch("apis.leaveRoom", "edit-songs");
-		this.resetSongs();
+	},
+	unmounted() {
+		// Delete the VueX module that was created for this modal, after all other cleanup tasks are performed
+		this.$store.unregisterModule(["modals", "editSongs", this.modalUuid]);
 	},
 	methods: {
 		pickSong(song) {
@@ -375,22 +375,22 @@ export default {
 		toggleMobileSidebar() {
 			this.sidebarMobileActive = !this.sidebarMobileActive;
 		},
-		confirmAction(confirm) {
-			this.confirm = confirm;
-			this.updateConfirmMessage(confirm.message);
-			this.openModal("editSongsConfirm");
+		confirmAction({ message, action, params }) {
+			this.openModal({
+				modal: "confirm",
+				data: {
+					message,
+					action,
+					params,
+					onCompleted: this.handleConfirmed
+				}
+			});
 		},
-		handleConfirmed() {
-			const { action, params } = this.confirm;
+		handleConfirmed({ action, params }) {
 			if (typeof this[action] === "function") {
 				if (params) this[action](params);
 				else this[action]();
 			}
-			this.confirm = {
-				message: "",
-				action: "",
-				params: null
-			};
 		},
 		onClose() {
 			const doneItems = this.items.filter(
@@ -416,12 +416,13 @@ export default {
 			else this.closeThisModal();
 		},
 		closeThisModal() {
-			this.closeModal("editSongs");
+			this.closeCurrentModal();
 		},
-		...mapActions("modals/confirm", ["updateConfirmMessage"]),
-		...mapActions("modalVisibility", ["openModal", "closeModal"]),
-		...mapActions("modals/editSong", ["editSong"]),
-		...mapActions("modals/editSongs", ["resetSongs"])
+		...mapActions("modalVisibility", ["openModal", "closeCurrentModal"]),
+		...mapModalActions("modals/editSongs/MODAL_UUID/editSong", [
+			"editSong"
+		]),
+		...mapModalActions("modals/editSongs/MODAL_UUID", ["resetSongs"])
 	}
 };
 </script>

+ 42 - 29
frontend/src/components/modals/EditUser.vue

@@ -106,19 +106,17 @@
 </template>
 
 <script>
-import { mapState, mapGetters, mapActions } from "vuex";
+import { mapGetters, mapActions } from "vuex";
 
 import Toast from "toasters";
 import validation from "@/validation";
 import ws from "@/ws";
-import Modal from "../Modal.vue";
-import QuickConfirm from "@/components/QuickConfirm.vue";
+
+import { mapModalState, mapModalActions } from "@/vuex_helpers";
 
 export default {
-	components: { Modal, QuickConfirm },
 	props: {
-		userId: { type: String, default: "" },
-		sector: { type: String, default: "admin" }
+		modalUuid: { type: String, default: "" }
 	},
 	data() {
 		return {
@@ -128,13 +126,21 @@ export default {
 		};
 	},
 	computed: {
-		...mapState("modals/editUser", {
+		...mapModalState("modals/editUser/MODAL_UUID", {
+			userId: state => state.userId,
 			user: state => state.user
 		}),
 		...mapGetters({
 			socket: "websockets/getSocket"
 		})
 	},
+	watch: {
+		// When the userId changes, run init. There can be a delay between the modal opening and the required data (userId) being available
+		userId() {
+			// Note: is it possible for this to run before the socket is ready?
+			this.init();
+		}
+	},
 	mounted() {
 		ws.onConnect(this.init);
 	},
@@ -144,32 +150,39 @@ export default {
 			`edit-user.${this.userId}`,
 			() => {}
 		);
+		// Delete the VueX module that was created for this modal, after all other cleanup tasks are performed
+		this.$store.unregisterModule(["modals", "editUser", this.modalUuid]);
 	},
 	methods: {
 		init() {
-			this.socket.dispatch(`users.getUserFromId`, this.userId, res => {
-				if (res.status === "success") {
-					const user = res.data;
-					this.editUser(user);
+			if (this.userId)
+				this.socket.dispatch(
+					`users.getUserFromId`,
+					this.userId,
+					res => {
+						if (res.status === "success") {
+							const user = res.data;
+							this.setUser(user);
 
-					this.socket.dispatch(
-						"apis.joinRoom",
-						`edit-user.${this.userId}`
-					);
+							this.socket.dispatch(
+								"apis.joinRoom",
+								`edit-user.${this.userId}`
+							);
 
-					this.socket.on(
-						"event:user.removed",
-						res => {
-							if (res.data.userId === this.userId)
-								this.closeModal("editUser");
-						},
-						{ modal: "editUser" }
-					);
-				} else {
-					new Toast("User with that ID not found");
-					this.closeModal("editUser");
-				}
-			});
+							this.socket.on(
+								"event:user.removed",
+								res => {
+									if (res.data.userId === this.userId)
+										this.closeModal("editUser");
+								},
+								{ modalUuid: this.modalUuid }
+							);
+						} else {
+							new Toast("User with that ID not found");
+							this.closeModal("editUser");
+						}
+					}
+				);
 		},
 		updateUsername() {
 			const { username } = this.user;
@@ -278,7 +291,7 @@ export default {
 				new Toast(res.message);
 			});
 		},
-		...mapActions("modals/editUser", ["editUser"]),
+		...mapModalActions("modals/editUser/MODAL_UUID", ["setUser"]),
 		...mapActions("modalVisibility", ["closeModal"])
 	}
 };

+ 22 - 17
frontend/src/components/modals/ImportAlbum.vue

@@ -324,20 +324,19 @@
 </template>
 
 <script>
-import { mapState, mapGetters, mapActions } from "vuex";
+import { mapGetters, mapActions } from "vuex";
 
 import draggable from "vuedraggable";
 import Toast from "toasters";
 import ws from "@/ws";
-
-import Modal from "../Modal.vue";
+import { mapModalState, mapModalActions } from "@/vuex_helpers";
 
 import SongItem from "../SongItem.vue";
 
 export default {
-	components: { Modal, SongItem, draggable },
+	components: { SongItem, draggable },
 	props: {
-		sector: { type: String, default: "admin" }
+		modalUuid: { type: String, default: "" }
 	},
 	data() {
 		return {
@@ -362,35 +361,34 @@ export default {
 	computed: {
 		playlistSongs: {
 			get() {
-				return this.$store.state.modals.importAlbum.playlistSongs;
+				return this.$store.state.modals.importAlbum[this.modalUuid]
+					.playlistSongs;
 			},
 			set(playlistSongs) {
 				this.$store.commit(
-					"modals/importAlbum/updatePlaylistSongs",
+					`modals/importAlbum/${this.modalUuid}/updatePlaylistSongs`,
 					playlistSongs
 				);
 			}
 		},
 		localPrefillDiscogs: {
 			get() {
-				return this.$store.state.modals.importAlbum.prefillDiscogs;
+				return this.$store.state.modals.importAlbum[this.modalUuid]
+					.prefillDiscogs;
 			},
 			set(prefillDiscogs) {
 				this.$store.commit(
-					"modals/importAlbum/updatePrefillDiscogs",
+					`modals/importAlbum/${this.modalUuid}/updatePrefillDiscogs`,
 					prefillDiscogs
 				);
 			}
 		},
-		...mapState("modals/importAlbum", {
+		...mapModalState("modals/importAlbum/MODAL_UUID", {
 			discogsTab: state => state.discogsTab,
 			discogsAlbum: state => state.discogsAlbum,
 			editingSongs: state => state.editingSongs,
 			prefillDiscogs: state => state.prefillDiscogs
 		}),
-		...mapState("modalVisibility", {
-			modals: state => state.modals
-		}),
 		...mapGetters({
 			socket: "websockets/getSocket"
 		})
@@ -407,6 +405,8 @@ export default {
 		this.setPlaylistSongs([]);
 		this.showDiscogsTab("search");
 		this.socket.dispatch("apis.leaveRoom", "import-album");
+		// Delete the VueX module that was created for this modal, after all other cleanup tasks are performed
+		this.$store.unregisterModule(["modals", "importAlbum", this.modalUuid]);
 	},
 	methods: {
 		init() {
@@ -450,8 +450,10 @@ export default {
 			if (this.songsToEdit.length === 0)
 				new Toast("You can't edit 0 songs.");
 			else {
-				this.editSongs(this.songsToEdit);
-				this.openModal("editSongs");
+				this.openModal({
+					modal: "editSongs",
+					data: { songs: this.songsToEdit }
+				});
 			}
 		},
 		log(evt) {
@@ -653,10 +655,13 @@ export default {
 					this.$refs[`discogs-${payload}-tab`].scrollIntoView({
 						block: "nearest"
 					});
-				return dispatch("modals/importAlbum/showDiscogsTab", payload);
+				return dispatch(
+					`modals/importAlbum/${this.modalUuid}/showDiscogsTab`,
+					payload
+				);
 			}
 		}),
-		...mapActions("modals/importAlbum", [
+		...mapModalActions("modals/importAlbum/MODAL_UUID", [
 			"toggleDiscogsAlbum",
 			"setPlaylistSongs",
 			"updatePlaylistSongs",

+ 25 - 17
frontend/src/components/modals/ImportPlaylist.vue

@@ -65,32 +65,39 @@ import Toast from "toasters";
 
 import SearchYoutube from "@/mixins/SearchYoutube.vue";
 
-import Modal from "../Modal.vue";
-
 export default {
-	components: { Modal },
 	mixins: [SearchYoutube],
+	props: {
+		modalUuid: { type: String, default: "" }
+	},
 	computed: {
 		localEditSongs: {
 			get() {
-				return this.$store.state.modals.importPlaylist
+				return this.$store.state.modals.importPlaylist[this.modalUuid]
 					.editImportedSongs;
 			},
 			set(editImportedSongs) {
 				this.$store.commit(
-					"modals/importPlaylist/updateEditImportedSongs",
+					`modals/importPlaylist/${this.modalUuid}/updateEditImportedSongs`,
 					editImportedSongs
 				);
 			}
 		},
 		...mapState({
-			loggedIn: state => state.user.auth.loggedIn,
-			station: state => state.station.station
+			loggedIn: state => state.user.auth.loggedIn
 		}),
 		...mapGetters({
 			socket: "websockets/getSocket"
 		})
 	},
+	beforeUnmount() {
+		// Delete the VueX module that was created for this modal, after all other cleanup tasks are performed
+		this.$store.unregisterModule([
+			"modals",
+			"importPlaylist",
+			this.modalUuid
+		]);
+	},
 	methods: {
 		importPlaylist() {
 			let isImportingPlaylist = true;
@@ -132,16 +139,18 @@ export default {
 						res.songs &&
 						res.songs.length > 0
 					) {
-						this.editSongs(
-							res.songs.map(song => ({
-								...song,
-								songId: song._id
-							}))
-						);
-						this.openModal("editSongs");
+						this.openModal({
+							modal: "editSongs",
+							data: {
+								songs: res.songs.map(song => ({
+									...song,
+									songId: song._id
+								}))
+							}
+						});
 					}
 
-					this.closeModal("importPlaylist");
+					this.closeCurrentModal();
 					return new Toast({
 						content: res.message,
 						timeout: 20000
@@ -149,8 +158,7 @@ export default {
 				}
 			);
 		},
-		...mapActions("modals/editSongs", ["editSongs"]),
-		...mapActions("modalVisibility", ["openModal", "closeModal"])
+		...mapActions("modalVisibility", ["openModal", "closeCurrentModal"])
 	}
 };
 </script>

+ 0 - 4
frontend/src/components/modals/Login.vue

@@ -106,12 +106,8 @@
 import { mapActions } from "vuex";
 
 import Toast from "toasters";
-import Modal from "../Modal.vue";
 
 export default {
-	components: {
-		Modal
-	},
 	data() {
 		return {
 			email: "",

+ 379 - 0
frontend/src/components/modals/ManageStation/Settings.vue

@@ -0,0 +1,379 @@
+<template>
+	<div class="station-settings">
+		<label class="label">Name</label>
+		<div class="control is-expanded">
+			<input class="input" type="text" v-model="localStation.name" />
+		</div>
+
+		<label class="label">Display Name</label>
+		<div class="control is-expanded">
+			<input
+				class="input"
+				type="text"
+				v-model="localStation.displayName"
+			/>
+		</div>
+
+		<label class="label">Description</label>
+		<div class="control is-expanded">
+			<input
+				class="input"
+				type="text"
+				v-model="localStation.description"
+			/>
+		</div>
+
+		<div class="settings-buttons">
+			<div class="small-section">
+				<label class="label">Theme</label>
+				<div class="control is-expanded select">
+					<select v-model="localStation.theme">
+						<option value="blue" selected>Blue</option>
+						<option value="purple">Purple</option>
+						<option value="teal">Teal</option>
+						<option value="orange">Orange</option>
+						<option value="red">Red</option>
+					</select>
+				</div>
+			</div>
+
+			<div class="small-section">
+				<label class="label">Privacy</label>
+				<div class="control is-expanded select">
+					<select v-model="localStation.privacy">
+						<option value="public">Public</option>
+						<option value="unlisted">Unlisted</option>
+						<option value="private" selected>Private</option>
+					</select>
+				</div>
+			</div>
+
+			<div
+				v-if="localStation.requests"
+				class="requests-settings"
+				:class="{ enabled: localStation.requests.enabled }"
+			>
+				<div class="toggle-row">
+					<label class="label">
+						Requests
+						<info-icon
+							tooltip="Allow users to add songs to the queue"
+						/>
+					</label>
+					<p class="is-expanded checkbox-control">
+						<label class="switch">
+							<input
+								type="checkbox"
+								id="toggle-requests"
+								v-model="localStation.requests.enabled"
+							/>
+							<span class="slider round"></span>
+						</label>
+
+						<label for="toggle-requests">
+							<p>
+								{{
+									localStation.requests.enabled
+										? "Enabled"
+										: "Disabled"
+								}}
+							</p>
+						</label>
+					</p>
+				</div>
+
+				<div v-if="localStation.requests.enabled" class="small-section">
+					<label class="label">Minimum access</label>
+					<div class="control is-expanded select">
+						<select v-model="localStation.requests.access">
+							<option value="owner" selected>Owner</option>
+							<option value="user">User</option>
+						</select>
+					</div>
+				</div>
+
+				<div v-if="localStation.requests.enabled" class="small-section">
+					<label class="label">Per user request limit</label>
+					<div class="control is-expanded">
+						<input
+							class="input"
+							type="number"
+							min="1"
+							max="50"
+							v-model="localStation.requests.limit"
+						/>
+					</div>
+				</div>
+			</div>
+
+			<div
+				v-if="localStation.autofill"
+				class="autofill-settings"
+				:class="{ enabled: localStation.autofill.enabled }"
+			>
+				<div class="toggle-row">
+					<label class="label">
+						Autofill
+						<info-icon
+							tooltip="Automatically fill the queue with songs"
+						/>
+					</label>
+					<p class="is-expanded checkbox-control">
+						<label class="switch">
+							<input
+								type="checkbox"
+								id="toggle-autofill"
+								v-model="localStation.autofill.enabled"
+							/>
+							<span class="slider round"></span>
+						</label>
+
+						<label for="toggle-autofill">
+							<p>
+								{{
+									localStation.autofill.enabled
+										? "Enabled"
+										: "Disabled"
+								}}
+							</p>
+						</label>
+					</p>
+				</div>
+
+				<div v-if="localStation.autofill.enabled" class="small-section">
+					<label class="label">Song limit</label>
+					<div class="control is-expanded">
+						<input
+							class="input"
+							type="number"
+							min="1"
+							max="50"
+							v-model="localStation.autofill.limit"
+						/>
+					</div>
+				</div>
+
+				<div v-if="localStation.autofill.enabled" class="small-section">
+					<label class="label">Play mode</label>
+					<div class="control is-expanded select">
+						<select v-model="localStation.autofill.mode">
+							<option value="random" selected>Random</option>
+							<option value="sequential">Sequential</option>
+						</select>
+					</div>
+				</div>
+			</div>
+		</div>
+
+		<button class="control is-expanded button is-primary" @click="update()">
+			Save Changes
+		</button>
+	</div>
+</template>
+
+<script>
+import { mapGetters, mapActions } from "vuex";
+
+import Toast from "toasters";
+
+import { mapModalState, mapModalActions } from "@/vuex_helpers";
+import validation from "@/validation";
+
+export default {
+	props: {
+		modalUuid: { type: String, default: "" }
+	},
+	data() {
+		return {
+			localStation: {
+				name: "",
+				displayName: "",
+				description: "",
+				theme: "blue",
+				privacy: "private",
+				requests: {
+					enabled: true,
+					access: "owner",
+					limit: 3
+				},
+				autofill: {
+					enabled: true,
+					limit: 30,
+					mode: "random"
+				}
+			}
+		};
+	},
+	computed: {
+		...mapModalState("modals/manageStation/MODAL_UUID", {
+			station: state => state.station
+		}),
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	mounted() {
+		this.localStation = JSON.parse(JSON.stringify(this.station));
+	},
+	methods: {
+		update() {
+			if (
+				JSON.stringify({
+					name: this.localStation.name,
+					displayName: this.localStation.displayName,
+					description: this.localStation.description,
+					theme: this.localStation.theme,
+					privacy: this.localStation.privacy,
+					requests: {
+						enabled: this.localStation.requests.enabled,
+						access: this.localStation.requests.access,
+						limit: this.localStation.requests.limit
+					},
+					autofill: {
+						enabled: this.localStation.autofill.enabled,
+						limit: this.localStation.autofill.limit,
+						mode: this.localStation.autofill.mode
+					}
+				}) !==
+				JSON.stringify({
+					name: this.station.name,
+					displayName: this.station.displayName,
+					description: this.station.description,
+					theme: this.station.theme,
+					privacy: this.station.privacy,
+					requests: {
+						enabled: this.station.requests.enabled,
+						access: this.station.requests.access,
+						limit: this.station.requests.limit
+					},
+					autofill: {
+						enabled: this.station.autofill.enabled,
+						limit: this.station.autofill.limit,
+						mode: this.station.autofill.mode
+					}
+				})
+			) {
+				const { name, displayName, description } = this.localStation;
+
+				if (!validation.isLength(name, 2, 16))
+					new Toast("Name must have between 2 and 16 characters.");
+				else if (!validation.regex.az09_.test(name))
+					new Toast(
+						"Invalid name format. Allowed characters: a-z, 0-9 and _."
+					);
+				else if (!validation.isLength(displayName, 2, 32))
+					new Toast(
+						"Display name must have between 2 and 32 characters."
+					);
+				else if (!validation.regex.ascii.test(displayName))
+					new Toast(
+						"Invalid display name format. Only ASCII characters are allowed."
+					);
+				else if (!validation.isLength(description, 2, 200))
+					new Toast(
+						"Description must have between 2 and 200 characters."
+					);
+				else if (
+					description
+						.split("")
+						.filter(character => character.charCodeAt(0) === 21328)
+						.length !== 0
+				)
+					new Toast("Invalid description format.");
+				else
+					this.socket.dispatch(
+						"stations.update",
+						this.station._id,
+						this.localStation,
+						res => {
+							new Toast(res.message);
+
+							if (res.status === "success") {
+								this.editStation(this.localStation);
+							}
+						}
+					);
+			} else {
+				new Toast("Please make a change before saving.");
+			}
+		},
+		onCloseModal() {
+			this.closeModal("manageStation");
+		},
+		...mapModalActions("modals/manageStation/MODAL_UUID", ["editStation"]),
+		...mapActions("modalVisibility", ["closeModal"])
+	}
+};
+</script>
+
+<style lang="less" scoped>
+.night-mode {
+	.requests-settings,
+	.autofill-settings {
+		background-color: var(--dark-grey-2) !important;
+	}
+}
+
+.station-settings {
+	.settings-buttons {
+		display: flex;
+		justify-content: center;
+		flex-wrap: wrap;
+
+		.small-section {
+			width: calc(50% - 10px);
+			min-width: 150px;
+			margin: 5px auto;
+
+			&:nth-child(odd) {
+				margin-left: 0;
+			}
+			&:nth-child(even) {
+				margin-right: 0;
+			}
+		}
+	}
+
+	.requests-settings,
+	.autofill-settings {
+		display: flex;
+		flex-wrap: wrap;
+		width: 100%;
+		margin: 10px 0;
+		padding: 10px;
+		border-radius: @border-radius;
+		box-shadow: @box-shadow;
+
+		.toggle-row {
+			display: flex;
+			width: 100%;
+			line-height: 36px;
+
+			.label {
+				font-size: 18px;
+				margin: 0;
+			}
+		}
+
+		.label {
+			display: flex;
+			flex-grow: 1;
+		}
+
+		.checkbox-control {
+			justify-content: end;
+		}
+
+		.small-section {
+			&:nth-child(even) {
+				margin-left: 0;
+				margin-right: auto;
+			}
+			&:nth-child(odd) {
+				margin-left: auto;
+				margin-right: 0;
+			}
+		}
+	}
+}
+</style>

+ 0 - 171
frontend/src/components/modals/ManageStation/Tabs/Blacklist.vue

@@ -1,171 +0,0 @@
-<template>
-	<div class="station-blacklist">
-		<p class="has-text-centered">
-			Blacklist a playlist to prevent all of its songs playing in this
-			station.
-		</p>
-		<div class="tabs-container">
-			<!-- <div class="tab-selection">
-				<button
-					class="button is-default"
-					:class="{ selected: tab === 'playlists' }"
-					@click="showTab('playlists')"
-				>
-					Playlists
-				</button>
-				<button
-					class="button is-default"
-					:class="{ selected: tab === 'songs' }"
-					@click="showTab('songs')"
-				>
-					Songs
-				</button>
-			</div> -->
-			<div class="tab" v-show="tab === 'playlists'">
-				<div v-if="excludedPlaylists.length > 0">
-					<playlist-item
-						v-for="playlist in excludedPlaylists"
-						:key="`key-${playlist._id}`"
-						:playlist="playlist"
-						:show-owner="true"
-					>
-						<template #actions>
-							<quick-confirm
-								@confirm="deselectPlaylist(playlist._id)"
-							>
-								<i
-									class="material-icons stop-icon"
-									content="Stop blacklisting songs from this playlist
-							"
-									v-tippy
-									>stop</i
-								>
-							</quick-confirm>
-							<i
-								v-if="playlist.createdBy === userId"
-								@click="showPlaylist(playlist._id)"
-								class="material-icons edit-icon"
-								content="Edit Playlist"
-								v-tippy
-								>edit</i
-							>
-							<i
-								v-else
-								@click="showPlaylist(playlist._id)"
-								class="material-icons edit-icon"
-								content="View Playlist"
-								v-tippy
-								>visibility</i
-							>
-						</template>
-					</playlist-item>
-				</div>
-				<p v-else class="has-text-centered scrollable-list">
-					No playlists currently blacklisted.
-				</p>
-			</div>
-			<!-- <div class="tab" v-show="tab === 'songs'">
-				Blacklisting songs has yet to be added.
-			</div> -->
-		</div>
-	</div>
-</template>
-<script>
-import { mapActions, mapState, mapGetters } from "vuex";
-
-import Toast from "toasters";
-import PlaylistItem from "@/components/PlaylistItem.vue";
-import QuickConfirm from "@/components/QuickConfirm.vue";
-
-export default {
-	components: {
-		PlaylistItem,
-		QuickConfirm
-	},
-	data() {
-		return {
-			tab: "playlists"
-		};
-	},
-	computed: {
-		...mapState({
-			userId: state => state.user.auth.userId
-		}),
-		...mapState("modals/manageStation", {
-			station: state => state.station,
-			originalStation: state => state.originalStation,
-			excludedPlaylists: state => state.excludedPlaylists
-		}),
-		...mapGetters({
-			socket: "websockets/getSocket"
-		})
-	},
-	methods: {
-		showTab(tab) {
-			this.tab = tab;
-		},
-		showPlaylist(playlistId) {
-			this.editPlaylist(playlistId);
-			this.openModal("editPlaylist");
-		},
-		deselectPlaylist(id) {
-			this.socket.dispatch(
-				"stations.removeExcludedPlaylist",
-				this.station._id,
-				id,
-				res => {
-					new Toast(res.message);
-				}
-			);
-		},
-		...mapActions("modalVisibility", ["openModal"]),
-		...mapActions("user/playlists", ["editPlaylist"])
-	}
-};
-</script>
-
-<style lang="less" scoped>
-.night-mode {
-	.tabs-container .tab-selection .button {
-		background: var(--dark-grey) !important;
-		color: var(--white) !important;
-	}
-}
-
-.station-blacklist {
-	.tabs-container {
-		margin-top: 10px;
-		.tab-selection {
-			display: flex;
-			overflow-x: auto;
-			.button {
-				border-radius: 0;
-				border: 0;
-				text-transform: uppercase;
-				font-size: 14px;
-				color: var(--dark-grey-3);
-				background-color: var(--light-grey-2);
-				flex-grow: 1;
-				height: 32px;
-
-				&:not(:first-of-type) {
-					margin-left: 5px;
-				}
-			}
-
-			.selected {
-				background-color: var(--primary-color) !important;
-				color: var(--white) !important;
-				font-weight: 600;
-			}
-		}
-		.tab {
-			padding: 15px 0;
-			border-radius: 0;
-			.playlist-item:not(:last-of-type) {
-				margin-bottom: 10px;
-			}
-		}
-	}
-}
-</style>

+ 0 - 763
frontend/src/components/modals/ManageStation/Tabs/Playlists.vue

@@ -1,763 +0,0 @@
-<template>
-	<div class="station-playlists">
-		<div class="tabs-container">
-			<div class="tab-selection">
-				<button
-					class="button is-default"
-					ref="current-tab"
-					:class="{ selected: tab === 'current' }"
-					@click="showTab('current')"
-				>
-					Current
-				</button>
-				<button
-					class="button is-default"
-					ref="search-tab"
-					:class="{ selected: tab === 'search' }"
-					@click="showTab('search')"
-				>
-					Search
-				</button>
-				<button
-					v-if="station.type === 'community'"
-					class="button is-default"
-					ref="my-playlists-tab"
-					:class="{ selected: tab === 'my-playlists' }"
-					@click="showTab('my-playlists')"
-				>
-					My Playlists
-				</button>
-			</div>
-			<div class="tab" v-show="tab === 'current'">
-				<div v-if="currentPlaylists.length > 0">
-					<playlist-item
-						v-for="playlist in currentPlaylists"
-						:key="`key-${playlist._id}`"
-						:playlist="playlist"
-						:show-owner="true"
-					>
-						<template #actions>
-							<quick-confirm
-								v-if="isOwnerOrAdmin()"
-								@confirm="deselectPlaylist(playlist._id)"
-							>
-								<i
-									class="material-icons stop-icon"
-									content="Stop playing songs from this playlist"
-									v-tippy
-								>
-									stop
-								</i>
-							</quick-confirm>
-							<quick-confirm
-								v-if="isOwnerOrAdmin()"
-								@confirm="blacklistPlaylist(playlist._id)"
-							>
-								<i
-									class="material-icons stop-icon"
-									content="Blacklist Playlist"
-									v-tippy
-									>block</i
-								>
-							</quick-confirm>
-							<i
-								v-if="playlist.createdBy === myUserId"
-								@click="showPlaylist(playlist._id)"
-								class="material-icons edit-icon"
-								content="Edit Playlist"
-								v-tippy
-								>edit</i
-							>
-							<i
-								v-if="
-									playlist.createdBy !== myUserId &&
-									(playlist.privacy === 'public' || isAdmin())
-								"
-								@click="showPlaylist(playlist._id)"
-								class="material-icons edit-icon"
-								content="View Playlist"
-								v-tippy
-								>visibility</i
-							>
-						</template>
-					</playlist-item>
-				</div>
-				<p v-else class="has-text-centered scrollable-list">
-					No playlists currently selected.
-				</p>
-			</div>
-			<div class="tab" v-show="tab === 'search'">
-				<div v-if="featuredPlaylists.length > 0">
-					<label class="label"> Featured playlists </label>
-					<playlist-item
-						v-for="featuredPlaylist in featuredPlaylists"
-						:key="`featuredKey-${featuredPlaylist._id}`"
-						:playlist="featuredPlaylist"
-						:show-owner="true"
-					>
-						<template #actions>
-							<i
-								v-if="isExcluded(featuredPlaylist._id)"
-								class="material-icons stop-icon"
-								content="This playlist is blacklisted in this station"
-								v-tippy="{ theme: 'info' }"
-								>play_disabled</i
-							>
-							<quick-confirm
-								v-if="
-									(isOwnerOrAdmin() ||
-										(station.type === 'community' &&
-											station.partyMode)) &&
-									isSelected(featuredPlaylist._id)
-								"
-								@confirm="
-									deselectPlaylist(featuredPlaylist._id)
-								"
-							>
-								<i
-									class="material-icons stop-icon"
-									content="Stop playing songs from this playlist"
-									v-tippy
-								>
-									stop
-								</i>
-							</quick-confirm>
-							<i
-								v-if="
-									(isOwnerOrAdmin() ||
-										(station.type === 'community' &&
-											station.partyMode)) &&
-									!isSelected(featuredPlaylist._id) &&
-									!isExcluded(featuredPlaylist._id)
-								"
-								@click="selectPlaylist(featuredPlaylist)"
-								class="material-icons play-icon"
-								:content="
-									station.partyMode
-										? 'Request songs from this playlist'
-										: 'Play songs from this playlist'
-								"
-								v-tippy
-								>play_arrow</i
-							>
-							<quick-confirm
-								v-if="
-									isOwnerOrAdmin() &&
-									!isExcluded(featuredPlaylist._id)
-								"
-								@confirm="
-									blacklistPlaylist(featuredPlaylist._id)
-								"
-							>
-								<i
-									class="material-icons stop-icon"
-									content="Blacklist Playlist"
-									v-tippy
-									>block</i
-								>
-							</quick-confirm>
-							<i
-								v-if="featuredPlaylist.createdBy === myUserId"
-								@click="showPlaylist(featuredPlaylist._id)"
-								class="material-icons edit-icon"
-								content="Edit Playlist"
-								v-tippy
-								>edit</i
-							>
-							<i
-								v-if="
-									featuredPlaylist.createdBy !== myUserId &&
-									(featuredPlaylist.privacy === 'public' ||
-										isAdmin())
-								"
-								@click="showPlaylist(featuredPlaylist._id)"
-								class="material-icons edit-icon"
-								content="View Playlist"
-								v-tippy
-								>visibility</i
-							>
-						</template>
-					</playlist-item>
-					<br />
-				</div>
-				<label class="label"> Search for a public playlist </label>
-				<div class="control is-grouped input-with-button">
-					<p class="control is-expanded">
-						<input
-							class="input"
-							type="text"
-							placeholder="Enter your playlist query here..."
-							v-model="search.query"
-							@keyup.enter="searchForPlaylists(1)"
-						/>
-					</p>
-					<p class="control">
-						<a class="button is-info" @click="searchForPlaylists(1)"
-							><i class="material-icons icon-with-button"
-								>search</i
-							>Search</a
-						>
-					</p>
-				</div>
-				<div v-if="search.results.length > 0">
-					<playlist-item
-						v-for="playlist in search.results"
-						:key="`searchKey-${playlist._id}`"
-						:playlist="playlist"
-						:show-owner="true"
-					>
-						<template #actions>
-							<i
-								v-if="isExcluded(playlist._id)"
-								class="material-icons stop-icon"
-								content="This playlist is blacklisted in this station"
-								v-tippy="{ theme: 'info' }"
-								>play_disabled</i
-							>
-							<quick-confirm
-								v-if="
-									(isOwnerOrAdmin() ||
-										(station.type === 'community' &&
-											station.partyMode)) &&
-									isSelected(playlist._id)
-								"
-								@confirm="deselectPlaylist(playlist._id)"
-							>
-								<i
-									class="material-icons stop-icon"
-									content="Stop playing songs from this playlist"
-									v-tippy
-								>
-									stop
-								</i>
-							</quick-confirm>
-							<i
-								v-if="
-									(isOwnerOrAdmin() ||
-										(station.type === 'community' &&
-											station.partyMode)) &&
-									!isSelected(playlist._id) &&
-									!isExcluded(playlist._id)
-								"
-								@click="selectPlaylist(playlist)"
-								class="material-icons play-icon"
-								:content="
-									station.partyMode
-										? 'Request songs from this playlist'
-										: 'Play songs from this playlist'
-								"
-								v-tippy
-								>play_arrow</i
-							>
-							<quick-confirm
-								v-if="
-									isOwnerOrAdmin() &&
-									!isExcluded(playlist._id)
-								"
-								@confirm="blacklistPlaylist(playlist._id)"
-							>
-								<i
-									class="material-icons stop-icon"
-									content="Blacklist Playlist"
-									v-tippy
-									>block</i
-								>
-							</quick-confirm>
-							<i
-								v-if="playlist.createdBy === myUserId"
-								@click="showPlaylist(playlist._id)"
-								class="material-icons edit-icon"
-								content="Edit Playlist"
-								v-tippy
-								>edit</i
-							>
-							<i
-								v-if="
-									playlist.createdBy !== myUserId &&
-									(playlist.privacy === 'public' || isAdmin())
-								"
-								@click="showPlaylist(playlist._id)"
-								class="material-icons edit-icon"
-								content="View Playlist"
-								v-tippy
-								>visibility</i
-							>
-						</template>
-					</playlist-item>
-					<button
-						v-if="resultsLeftCount > 0"
-						class="button is-primary load-more-button"
-						@click="searchForPlaylists(search.page + 1)"
-					>
-						Load {{ nextPageResultsCount }} more results
-					</button>
-				</div>
-			</div>
-			<div
-				v-if="station.type === 'community'"
-				class="tab"
-				v-show="tab === 'my-playlists'"
-			>
-				<button
-					class="button is-primary"
-					id="create-new-playlist-button"
-					@click="openModal('createPlaylist')"
-				>
-					Create new playlist
-				</button>
-				<div
-					class="menu-list scrollable-list"
-					v-if="playlists.length > 0"
-				>
-					<draggable
-						tag="transition-group"
-						:component-data="{
-							name: !drag ? 'draggable-list-transition' : null
-						}"
-						item-key="_id"
-						v-model="playlists"
-						v-bind="dragOptions"
-						@start="drag = true"
-						@end="drag = false"
-						@change="savePlaylistOrder"
-					>
-						<template #item="{ element }">
-							<playlist-item
-								class="item-draggable"
-								:playlist="element"
-							>
-								<template #actions>
-									<i
-										v-if="isExcluded(element._id)"
-										class="material-icons stop-icon"
-										content="This playlist is blacklisted in this station"
-										v-tippy="{ theme: 'info' }"
-										>play_disabled</i
-									>
-									<i
-										v-if="
-											station.type === 'community' &&
-											(isOwnerOrAdmin() ||
-												station.partyMode) &&
-											!isSelected(element._id) &&
-											!isExcluded(element._id)
-										"
-										@click="selectPlaylist(element)"
-										class="material-icons play-icon"
-										:content="
-											station.partyMode
-												? 'Request songs from this playlist'
-												: 'Play songs from this playlist'
-										"
-										v-tippy
-										>play_arrow</i
-									>
-									<quick-confirm
-										v-if="
-											station.type === 'community' &&
-											(isOwnerOrAdmin() ||
-												station.partyMode) &&
-											isSelected(element._id)
-										"
-										@confirm="deselectPlaylist(element._id)"
-									>
-										<i
-											class="material-icons stop-icon"
-											:content="
-												station.partyMode
-													? 'Stop requesting songs from this playlist'
-													: 'Stop playing songs from this playlist'
-											"
-											v-tippy
-											>stop</i
-										>
-									</quick-confirm>
-									<quick-confirm
-										v-if="
-											isOwnerOrAdmin() &&
-											!isExcluded(element._id)
-										"
-										@confirm="
-											blacklistPlaylist(element._id)
-										"
-									>
-										<i
-											class="material-icons stop-icon"
-											content="Blacklist Playlist"
-											v-tippy
-											>block</i
-										>
-									</quick-confirm>
-									<i
-										@click="showPlaylist(element._id)"
-										class="material-icons edit-icon"
-										content="Edit Playlist"
-										v-tippy
-										>edit</i
-									>
-								</template>
-							</playlist-item>
-						</template>
-					</draggable>
-				</div>
-				<p v-else class="has-text-centered scrollable-list">
-					You don't have any playlists!
-				</p>
-			</div>
-		</div>
-	</div>
-</template>
-<script>
-import { mapActions, mapState, mapGetters } from "vuex";
-import Toast from "toasters";
-import ws from "@/ws";
-
-import PlaylistItem from "@/components/PlaylistItem.vue";
-import QuickConfirm from "@/components/QuickConfirm.vue";
-
-import SortablePlaylists from "@/mixins/SortablePlaylists.vue";
-
-export default {
-	components: {
-		PlaylistItem,
-		QuickConfirm
-	},
-	mixins: [SortablePlaylists],
-	data() {
-		return {
-			tab: "current",
-			search: {
-				query: "",
-				searchedQuery: "",
-				page: 0,
-				count: 0,
-				resultsLeft: 0,
-				results: []
-			},
-			featuredPlaylists: []
-		};
-	},
-	computed: {
-		currentPlaylists() {
-			if (this.station.type === "community" && this.station.partyMode) {
-				return this.partyPlaylists;
-			}
-			return this.includedPlaylists;
-		},
-		resultsLeftCount() {
-			return this.search.count - this.search.results.length;
-		},
-		nextPageResultsCount() {
-			return Math.min(this.search.pageSize, this.resultsLeftCount);
-		},
-		...mapState({
-			loggedIn: state => state.user.auth.loggedIn,
-			role: state => state.user.auth.role,
-			userId: state => state.user.auth.userId,
-			partyPlaylists: state => state.station.partyPlaylists
-		}),
-		...mapState("modals/manageStation", {
-			originalStation: state => state.originalStation,
-			station: state => state.station,
-			includedPlaylists: state => state.includedPlaylists,
-			excludedPlaylists: state => state.excludedPlaylists,
-			songsList: state => state.songsList
-		}),
-		...mapGetters({
-			socket: "websockets/getSocket"
-		})
-	},
-	mounted() {
-		if (this.station.type === "community" && this.station.partyMode)
-			this.showTab("search");
-
-		ws.onConnect(this.init);
-	},
-	methods: {
-		init() {
-			this.socket.dispatch("playlists.indexMyPlaylists", res => {
-				if (res.status === "success")
-					this.setPlaylists(res.data.playlists);
-				this.orderOfPlaylists = this.calculatePlaylistOrder(); // order in regards to the database
-			});
-
-			this.socket.dispatch("playlists.indexFeaturedPlaylists", res => {
-				if (res.status === "success")
-					this.featuredPlaylists = res.data.playlists;
-			});
-
-			this.socket.dispatch(
-				`stations.getStationIncludedPlaylistsById`,
-				this.station._id,
-				res => {
-					if (res.status === "success") {
-						this.station.includedPlaylists = res.data.playlists;
-						this.originalStation.includedPlaylists =
-							res.data.playlists;
-					}
-				}
-			);
-
-			this.socket.dispatch(
-				`stations.getStationExcludedPlaylistsById`,
-				this.station._id,
-				res => {
-					if (res.status === "success") {
-						this.station.excludedPlaylists = res.data.playlists;
-						this.originalStation.excludedPlaylists =
-							res.data.playlists;
-					}
-				}
-			);
-		},
-		showTab(tab) {
-			this.$refs[`${tab}-tab`].scrollIntoView({ block: "nearest" });
-			this.tab = tab;
-		},
-		isOwner() {
-			return this.loggedIn && this.userId === this.station.owner;
-		},
-		isAdmin() {
-			return this.loggedIn && this.role === "admin";
-		},
-		isOwnerOrAdmin() {
-			return this.isOwner() || this.isAdmin();
-		},
-		showPlaylist(playlistId) {
-			this.editPlaylist(playlistId);
-			this.openModal("editPlaylist");
-		},
-		selectPlaylist(playlist) {
-			if (this.station.type === "community" && this.station.partyMode) {
-				if (!this.isSelected(playlist.id)) {
-					this.partyPlaylists.push(playlist);
-					this.addPartyPlaylistSongToQueue();
-					new Toast(
-						"Successfully selected playlist to auto request songs."
-					);
-				} else {
-					new Toast("Error: Playlist already selected.");
-				}
-			} else {
-				this.socket.dispatch(
-					"stations.includePlaylist",
-					this.station._id,
-					playlist._id,
-					res => {
-						new Toast(res.message);
-					}
-				);
-			}
-		},
-		deselectPlaylist(id) {
-			return new Promise(resolve => {
-				if (
-					this.station.type === "community" &&
-					this.station.partyMode
-				) {
-					let selected = false;
-					this.currentPlaylists.forEach((playlist, index) => {
-						if (playlist._id === id) {
-							selected = true;
-							this.partyPlaylists.splice(index, 1);
-						}
-					});
-					if (selected) {
-						new Toast("Successfully deselected playlist.");
-						resolve();
-					} else {
-						new Toast("Playlist not selected.");
-						resolve();
-					}
-				} else {
-					this.socket.dispatch(
-						"stations.removeIncludedPlaylist",
-						this.station._id,
-						id,
-						res => {
-							new Toast(res.message);
-							resolve();
-						}
-					);
-				}
-			});
-		},
-		isSelected(id) {
-			let selected = false;
-			this.currentPlaylists.forEach(playlist => {
-				if (playlist._id === id) selected = true;
-			});
-			return selected;
-		},
-		isExcluded(id) {
-			let selected = false;
-			this.excludedPlaylists.forEach(playlist => {
-				if (playlist._id === id) selected = true;
-			});
-			return selected;
-		},
-		searchForPlaylists(page) {
-			if (
-				this.search.page >= page ||
-				this.search.searchedQuery !== this.search.query
-			) {
-				this.search.results = [];
-				this.search.page = 0;
-				this.search.count = 0;
-				this.search.resultsLeft = 0;
-				this.search.pageSize = 0;
-			}
-
-			const { query } = this.search;
-			const action =
-				this.station.type === "official"
-					? "playlists.searchOfficial"
-					: "playlists.searchCommunity";
-
-			this.search.searchedQuery = this.search.query;
-			this.socket.dispatch(action, query, page, res => {
-				const { data } = res;
-				if (res.status === "success") {
-					const { count, pageSize, playlists } = data;
-					this.search.results = [
-						...this.search.results,
-						...playlists
-					];
-					this.search.page = page;
-					this.search.count = count;
-					this.search.resultsLeft =
-						count - this.search.results.length;
-					this.search.pageSize = pageSize;
-				} else if (res.status === "error") {
-					this.search.results = [];
-					this.search.page = 0;
-					this.search.count = 0;
-					this.search.resultsLeft = 0;
-					this.search.pageSize = 0;
-					new Toast(res.message);
-				}
-			});
-		},
-		async blacklistPlaylist(id) {
-			if (this.isSelected(id)) await this.deselectPlaylist(id);
-
-			this.socket.dispatch(
-				"stations.excludePlaylist",
-				this.station._id,
-				id,
-				res => {
-					new Toast(res.message);
-				}
-			);
-		},
-		addPartyPlaylistSongToQueue() {
-			if (
-				this.station.type === "community" &&
-				this.station.partyMode === true &&
-				this.songsList.length < 50 &&
-				this.songsList.filter(
-					queueSong => queueSong.requestedBy === this.userId
-				).length < 3 &&
-				this.partyPlaylists
-			) {
-				const selectedPlaylist =
-					this.partyPlaylists[
-						Math.floor(Math.random() * this.partyPlaylists.length)
-					];
-				if (selectedPlaylist._id && selectedPlaylist.songs.length > 0) {
-					const selectedSong =
-						selectedPlaylist.songs[
-							Math.floor(
-								Math.random() * selectedPlaylist.songs.length
-							)
-						];
-					if (selectedSong.youtubeId) {
-						this.socket.dispatch(
-							"stations.addToQueue",
-							this.station._id,
-							selectedSong.youtubeId,
-							data => {
-								if (data.status !== "success")
-									this.addPartyPlaylistSongToQueue();
-							}
-						);
-					}
-				}
-			}
-		},
-		...mapActions("station", ["updatePartyPlaylists"]),
-		...mapActions("modalVisibility", ["openModal"]),
-		...mapActions("user/playlists", ["editPlaylist", "setPlaylists"])
-	}
-};
-</script>
-
-<style lang="less" scoped>
-.night-mode {
-	.tabs-container .tab-selection .button {
-		background: var(--dark-grey) !important;
-		color: var(--white) !important;
-	}
-}
-
-.excluded-icon {
-	color: var(--dark-red);
-}
-
-.included-icon {
-	color: var(--green);
-}
-
-.selected-icon {
-	color: var(--purple);
-}
-
-.station-playlists {
-	.tabs-container {
-		.tab-selection {
-			display: flex;
-			overflow-x: auto;
-			.button {
-				border-radius: 0;
-				border: 0;
-				text-transform: uppercase;
-				font-size: 14px;
-				color: var(--dark-grey-3);
-				background-color: var(--light-grey-2);
-				flex-grow: 1;
-				height: 32px;
-
-				&:not(:first-of-type) {
-					margin-left: 5px;
-				}
-			}
-
-			.selected {
-				background-color: var(--primary-color) !important;
-				color: var(--white) !important;
-				font-weight: 600;
-			}
-		}
-		.tab {
-			padding: 15px 0;
-			border-radius: 0;
-			.playlist-item:not(:last-of-type),
-			.item.item-draggable:not(:last-of-type) {
-				margin-bottom: 10px;
-			}
-			.load-more-button {
-				width: 100%;
-				margin-top: 10px;
-			}
-		}
-	}
-}
-.draggable-list-transition-move {
-	transition: transform 0.5s;
-}
-
-.draggable-list-ghost {
-	opacity: 0.5;
-	filter: brightness(95%);
-}
-</style>

+ 0 - 629
frontend/src/components/modals/ManageStation/Tabs/Settings.vue

@@ -1,629 +0,0 @@
-<template>
-	<div class="station-settings">
-		<label class="label">Name</label>
-		<div class="control is-grouped input-with-button">
-			<p class="control is-expanded">
-				<input class="input" type="text" v-model="station.name" />
-			</p>
-			<p class="control">
-				<a class="button is-info" @click.prevent="updateName()">Save</a>
-			</p>
-		</div>
-		<label class="label">Display Name</label>
-		<div class="control is-grouped input-with-button">
-			<p class="control is-expanded">
-				<input
-					class="input"
-					type="text"
-					v-model="station.displayName"
-				/>
-			</p>
-			<p class="control">
-				<a class="button is-info" @click.prevent="updateDisplayName()"
-					>Save</a
-				>
-			</p>
-		</div>
-		<label class="label">Description</label>
-		<div class="control is-grouped input-with-button">
-			<p class="control is-expanded">
-				<input
-					class="input"
-					type="text"
-					v-model="station.description"
-				/>
-			</p>
-			<p class="control">
-				<a class="button is-info" @click.prevent="updateDescription()"
-					>Save</a
-				>
-			</p>
-		</div>
-
-		<div class="settings-buttons">
-			<div class="small-section">
-				<label class="label">Theme</label>
-				<div class="button-wrapper">
-					<tippy
-						theme="stationSettings"
-						:interactive="true"
-						:touch="true"
-						placement="bottom"
-						trigger="click"
-						append-to="parent"
-					>
-						<button :class="station.theme">
-							<i class="material-icons">palette</i>
-							{{ station.theme }}
-						</button>
-
-						<template #content>
-							<button
-								class="blue"
-								v-if="station.theme !== 'blue'"
-								@click="updateTheme('blue')"
-							>
-								<i class="material-icons">palette</i>
-								Blue
-							</button>
-							<button
-								class="purple"
-								v-if="station.theme !== 'purple'"
-								@click="updateTheme('purple')"
-							>
-								<i class="material-icons">palette</i>
-								Purple
-							</button>
-							<button
-								class="teal"
-								v-if="station.theme !== 'teal'"
-								@click="updateTheme('teal')"
-							>
-								<i class="material-icons">palette</i>
-								Teal
-							</button>
-							<button
-								class="orange"
-								v-if="station.theme !== 'orange'"
-								@click="updateTheme('orange')"
-							>
-								<i class="material-icons">palette</i>
-								Orange
-							</button>
-							<button
-								class="red"
-								v-if="station.theme !== 'red'"
-								@click="updateTheme('red')"
-							>
-								<i class="material-icons">palette</i>
-								Red
-							</button>
-						</template>
-					</tippy>
-				</div>
-			</div>
-
-			<div class="small-section">
-				<label class="label">Privacy</label>
-				<div class="button-wrapper">
-					<tippy
-						theme="stationSettings"
-						:interactive="true"
-						:touch="true"
-						placement="bottom"
-						trigger="click"
-						append-to="parent"
-					>
-						<button :class="privacyButtons[station.privacy].style">
-							<i class="material-icons">{{
-								privacyButtons[station.privacy].iconName
-							}}</i>
-							{{ station.privacy }}
-						</button>
-
-						<template #content>
-							<button
-								class="green"
-								v-if="station.privacy !== 'public'"
-								@click="updatePrivacy('public')"
-							>
-								<i class="material-icons">{{
-									privacyButtons["public"].iconName
-								}}</i>
-								Public
-							</button>
-							<button
-								class="orange"
-								v-if="station.privacy !== 'unlisted'"
-								@click="updatePrivacy('unlisted')"
-							>
-								<i class="material-icons">{{
-									privacyButtons["unlisted"].iconName
-								}}</i>
-								Unlisted
-							</button>
-							<button
-								class="red"
-								v-if="station.privacy !== 'private'"
-								@click="updatePrivacy('private')"
-							>
-								<i class="material-icons">{{
-									privacyButtons["private"].iconName
-								}}</i>
-								Private
-							</button>
-						</template>
-					</tippy>
-				</div>
-			</div>
-
-			<div class="small-section">
-				<label class="label">Station Mode</label>
-				<div class="button-wrapper" v-if="station.type === 'community'">
-					<tippy
-						theme="stationSettings"
-						:interactive="true"
-						:touch="true"
-						placement="bottom"
-						trigger="click"
-						append-to="parent"
-					>
-						<button
-							:class="{
-								blue: !station.partyMode,
-								yellow: station.partyMode
-							}"
-						>
-							<i class="material-icons">{{
-								station.partyMode
-									? "emoji_people"
-									: "playlist_play"
-							}}</i>
-							{{ station.partyMode ? "Party" : "Playlist" }}
-						</button>
-
-						<template #content>
-							<button
-								class="blue"
-								v-if="station.partyMode"
-								@click="updatePartyMode(false)"
-							>
-								<i class="material-icons">playlist_play</i>
-								Playlist
-							</button>
-							<button
-								class="yellow"
-								v-if="!station.partyMode"
-								@click="updatePartyMode(true)"
-							>
-								<i class="material-icons">emoji_people</i>
-								Party
-							</button>
-						</template>
-					</tippy>
-				</div>
-				<div v-else class="button-wrapper">
-					<button
-						class="blue"
-						content="Can not be changed on official stations."
-						v-tippy="{ theme: 'info' }"
-					>
-						<i class="material-icons">playlist_play</i>
-						Playlist
-					</button>
-				</div>
-			</div>
-
-			<div v-if="!station.partyMode" class="small-section">
-				<label class="label">Play Mode</label>
-				<div class="button-wrapper" v-if="station.type === 'community'">
-					<tippy
-						theme="stationSettings"
-						:interactive="true"
-						:touch="true"
-						placement="bottom"
-						trigger="click"
-						append-to="parent"
-					>
-						<button class="blue">
-							<i class="material-icons">{{
-								station.playMode === "random"
-									? "shuffle"
-									: "format_list_numbered"
-							}}</i>
-							{{
-								station.playMode === "random"
-									? "Random"
-									: "Sequential"
-							}}
-						</button>
-
-						<template #content>
-							<button
-								class="blue"
-								v-if="station.playMode === 'sequential'"
-								@click="updatePlayMode('random')"
-							>
-								<i class="material-icons">shuffle</i>
-								Random
-							</button>
-							<button
-								class="blue"
-								v-if="station.playMode === 'random'"
-								@click="updatePlayMode('sequential')"
-							>
-								<i class="material-icons"
-									>format_list_numbered</i
-								>
-								Sequential
-							</button>
-						</template>
-					</tippy>
-				</div>
-				<div v-else class="button-wrapper">
-					<button
-						class="blue"
-						content="Can not be changed on official stations."
-						v-tippy="{ theme: 'info' }"
-					>
-						<i class="material-icons">shuffle</i>
-						Random
-					</button>
-				</div>
-			</div>
-
-			<div
-				v-if="
-					station.type === 'community' && station.partyMode === true
-				"
-				class="small-section"
-			>
-				<label class="label">Queue lock</label>
-				<div class="button-wrapper">
-					<tippy
-						theme="stationSettings"
-						:interactive="true"
-						:touch="true"
-						placement="bottom"
-						trigger="click"
-						append-to="parent"
-					>
-						<button
-							:class="{
-								green: station.locked,
-								red: !station.locked
-							}"
-						>
-							<i class="material-icons">{{
-								station.locked ? "lock" : "lock_open"
-							}}</i>
-							{{ station.locked ? "Locked" : "Unlocked" }}
-						</button>
-
-						<template #content>
-							<button
-								class="green"
-								v-if="!station.locked"
-								@click="updateQueueLock(true)"
-							>
-								<i class="material-icons">lock</i>
-								Locked
-							</button>
-							<button
-								class="red"
-								v-if="station.locked"
-								@click="updateQueueLock(false)"
-							>
-								<i class="material-icons">lock_open</i>
-								Unlocked
-							</button>
-						</template>
-					</tippy>
-				</div>
-			</div>
-		</div>
-	</div>
-</template>
-
-<script>
-import { mapState, mapGetters } from "vuex";
-
-import Toast from "toasters";
-
-import validation from "@/validation";
-
-export default {
-	data() {
-		return {
-			privacyButtons: {
-				public: {
-					style: "green",
-					iconName: "public"
-				},
-				private: {
-					style: "red",
-					iconName: "lock"
-				},
-				unlisted: {
-					style: "orange",
-					iconName: "link"
-				}
-			}
-		};
-	},
-	computed: {
-		...mapState("modals/manageStation", {
-			station: state => state.station,
-			originalStation: state => state.originalStation
-		}),
-		...mapGetters({
-			socket: "websockets/getSocket"
-		})
-	},
-	methods: {
-		updateName() {
-			if (this.originalStation.name !== this.station.name) {
-				const { name } = this.station;
-				if (!validation.isLength(name, 2, 16)) {
-					new Toast("Name must have between 2 and 16 characters.");
-				} else if (!validation.regex.az09_.test(name)) {
-					new Toast(
-						"Invalid name format. Allowed characters: a-z, 0-9 and _."
-					);
-				} else {
-					this.socket.dispatch(
-						"stations.updateName",
-						this.station._id,
-						name,
-						res => {
-							new Toast(res.message);
-
-							if (res.status === "success") {
-								this.station.name = name;
-								this.originalStation.name = name;
-							}
-						}
-					);
-				}
-			} else {
-				new Toast("Please make a change before saving.");
-			}
-		},
-		updateDisplayName() {
-			if (this.originalStation.displayName !== this.station.displayName) {
-				const { displayName } = this.station;
-				if (!validation.isLength(displayName, 2, 32)) {
-					new Toast(
-						"Display name must have between 2 and 32 characters."
-					);
-				} else if (!validation.regex.ascii.test(displayName)) {
-					new Toast(
-						"Invalid display name format. Only ASCII characters are allowed."
-					);
-				} else {
-					this.socket.dispatch(
-						"stations.updateDisplayName",
-						this.station._id,
-						displayName,
-						res => {
-							new Toast(res.message);
-
-							if (res.status === "success") {
-								this.station.displayName = displayName;
-								this.originalStation.displayName = displayName;
-							}
-						}
-					);
-				}
-			} else {
-				new Toast("Please make a change before saving.");
-			}
-		},
-		updateDescription() {
-			if (this.originalStation.description !== this.station.description) {
-				const { description } = this.station;
-				const characters = description
-					.split("")
-					.filter(character => character.charCodeAt(0) === 21328);
-				if (!validation.isLength(description, 2, 200)) {
-					new Toast(
-						"Description must have between 2 and 200 characters."
-					);
-				} else if (characters.length !== 0) {
-					new Toast("Invalid description format.");
-				} else {
-					this.socket.dispatch(
-						"stations.updateDescription",
-						this.station._id,
-						description,
-						res => {
-							new Toast(res.message);
-
-							if (res.status === "success") {
-								this.station.description = description;
-								this.originalStation.description = description;
-							}
-						}
-					);
-				}
-			} else {
-				new Toast("Please make a change before saving.");
-			}
-		},
-		updateTheme(theme) {
-			if (this.station.theme !== theme) {
-				this.socket.dispatch(
-					"stations.updateTheme",
-					this.station._id,
-					theme,
-					res => {
-						new Toast(res.message);
-
-						if (res.status === "success") {
-							this.station.theme = theme;
-							this.originalStation.theme = theme;
-						}
-					}
-				);
-			}
-		},
-		updatePrivacy(privacy) {
-			if (this.station.privacy !== privacy) {
-				this.socket.dispatch(
-					"stations.updatePrivacy",
-					this.station._id,
-					privacy,
-					res => {
-						new Toast(res.message);
-
-						if (res.status === "success") {
-							this.station.privacy = privacy;
-							this.originalStation.privacy = privacy;
-						}
-					}
-				);
-			}
-		},
-		updatePartyMode(partyMode) {
-			if (this.station.partyMode !== partyMode) {
-				this.socket.dispatch(
-					"stations.updatePartyMode",
-					this.station._id,
-					partyMode,
-					res => {
-						new Toast(res.message);
-
-						if (res.status === "success") {
-							this.station.partyMode = partyMode;
-							this.originalStation.partyMode = partyMode;
-						}
-					}
-				);
-			}
-		},
-		updatePlayMode(playMode) {
-			if (this.station.playMode !== playMode) {
-				this.socket.dispatch(
-					"stations.updatePlayMode",
-					this.station._id,
-					playMode,
-					res => {
-						new Toast(res.message);
-
-						if (res.status === "success") {
-							this.station.playMode = playMode;
-							this.originalStation.playMode = playMode;
-						}
-					}
-				);
-			}
-		},
-		updateQueueLock(locked) {
-			if (this.station.locked !== locked) {
-				this.socket.dispatch(
-					"stations.toggleLock",
-					this.station._id,
-					res => {
-						if (res.status === "success") {
-							if (this.originalStation) {
-								this.station.locked = res.data.locked;
-								this.originalStation.locked = res.data.locked;
-							}
-
-							new Toast(
-								`Toggled queue lock successfully to ${res.data.locked}`
-							);
-						} else {
-							new Toast("Failed to toggle queue lock.");
-						}
-					}
-				);
-			}
-		}
-	}
-};
-</script>
-
-<style lang="less" scoped>
-.station-settings {
-	.settings-buttons {
-		display: flex;
-		justify-content: center;
-		flex-wrap: wrap;
-		.small-section {
-			width: calc(50% - 10px);
-			min-width: 150px;
-			margin: 5px auto;
-		}
-	}
-	.button-wrapper {
-		display: flex;
-		flex-direction: column;
-
-		:deep(* .tippy-box[data-theme~="dropdown"] .tippy-content > span) {
-			max-width: 150px !important;
-		}
-
-		.tippy-content span button {
-			width: 150px;
-		}
-
-		button {
-			width: 100%;
-			height: 36px;
-			border: 0;
-			border-radius: @border-radius;
-			font-size: 18px;
-			color: var(--white);
-			box-shadow: @box-shadow;
-			display: flex;
-			text-align: center;
-			justify-content: center;
-			-ms-flex-align: center;
-			align-items: center;
-			-moz-user-select: none;
-			user-select: none;
-			cursor: pointer;
-			padding: 0;
-			text-transform: capitalize;
-
-			&.red {
-				background-color: var(--dark-red);
-			}
-
-			&.green {
-				background-color: var(--green);
-			}
-
-			&.blue {
-				background-color: var(--blue);
-			}
-
-			&.orange {
-				background-color: var(--orange);
-			}
-
-			&.yellow {
-				background-color: var(--yellow);
-			}
-
-			&.purple {
-				background-color: var(--purple);
-			}
-
-			&.teal {
-				background-color: var(--teal);
-			}
-
-			&.red {
-				background-color: var(--dark-red);
-			}
-
-			i {
-				font-size: 20px;
-				margin-right: 4px;
-			}
-		}
-	}
-}
-</style>

+ 0 - 188
frontend/src/components/modals/ManageStation/Tabs/Songs.vue

@@ -1,188 +0,0 @@
-<template>
-	<div class="search">
-		<div class="musare-search">
-			<label class="label"> Search for a song on Musare </label>
-			<div class="control is-grouped input-with-button">
-				<p class="control is-expanded">
-					<input
-						class="input"
-						type="text"
-						placeholder="Enter your song query here..."
-						v-model="musareSearch.query"
-						@keyup.enter="searchForMusareSongs(1)"
-					/>
-				</p>
-				<p class="control">
-					<a class="button is-info" @click="searchForMusareSongs(1)"
-						><i class="material-icons icon-with-button">search</i
-						>Search</a
-					>
-				</p>
-			</div>
-			<div v-if="musareSearch.results.length > 0">
-				<song-item
-					v-for="song in musareSearch.results"
-					:key="song._id"
-					:song="song"
-				>
-					<template #actions>
-						<i
-							class="material-icons add-to-queue-icon"
-							v-if="station.partyMode && !station.locked"
-							@click="addSongToQueue(song.youtubeId)"
-							content="Add Song to Queue"
-							v-tippy
-							>queue</i
-						>
-					</template>
-				</song-item>
-				<button
-					v-if="resultsLeftCount > 0"
-					class="button is-primary load-more-button"
-					@click="searchForMusareSongs(musareSearch.page + 1)"
-				>
-					Load {{ nextPageResultsCount }} more results
-				</button>
-			</div>
-		</div>
-		<div class="youtube-search">
-			<label class="label"> Search for a song on YouTube </label>
-			<div class="control is-grouped input-with-button">
-				<p class="control is-expanded">
-					<input
-						class="input"
-						type="text"
-						placeholder="Enter your YouTube query here..."
-						v-model="youtubeSearch.songs.query"
-						autofocus
-						@keyup.enter="searchForSongs()"
-					/>
-				</p>
-				<p class="control">
-					<a class="button is-info" @click.prevent="searchForSongs()"
-						><i class="material-icons icon-with-button">search</i
-						>Search</a
-					>
-				</p>
-			</div>
-
-			<div
-				v-if="youtubeSearch.songs.results.length > 0"
-				id="song-query-results"
-			>
-				<search-query-item
-					v-for="(result, index) in youtubeSearch.songs.results"
-					:key="result.id"
-					:result="result"
-				>
-					<template #actions>
-						<transition name="search-query-actions" mode="out-in">
-							<i
-								v-if="result.isAddedToQueue"
-								class="material-icons added-to-queue-icon"
-								content="Song Added to Queue"
-								v-tippy
-								key="added-to-queue"
-								>library_add_check</i
-							>
-							<i
-								v-else
-								class="material-icons add-to-queue-icon"
-								@click.prevent="
-									addSongToQueue(result.id, index)
-								"
-								content="Add Song to Queue"
-								v-tippy
-								key="add-to-queue"
-								>queue</i
-							>
-						</transition>
-					</template>
-				</search-query-item>
-
-				<a
-					class="button is-primary load-more-button"
-					@click.prevent="loadMoreSongs()"
-				>
-					Load more...
-				</a>
-			</div>
-		</div>
-	</div>
-</template>
-
-<script>
-import { mapState, mapGetters } from "vuex";
-
-import Toast from "toasters";
-import SearchYoutube from "@/mixins/SearchYoutube.vue";
-import SearchMusare from "@/mixins/SearchMusare.vue";
-
-import SongItem from "@/components/SongItem.vue";
-import SearchQueryItem from "../../../SearchQueryItem.vue";
-
-export default {
-	components: {
-		SongItem,
-		SearchQueryItem
-	},
-	mixins: [SearchYoutube, SearchMusare],
-	computed: {
-		...mapState("modals/manageStation", {
-			station: state => state.station,
-			originalStation: state => state.originalStation
-		}),
-		...mapGetters({
-			socket: "websockets/getSocket"
-		})
-	},
-	methods: {
-		addSongToQueue(youtubeId, index) {
-			if (this.station.type === "community") {
-				this.socket.dispatch(
-					"stations.addToQueue",
-					this.station._id,
-					youtubeId,
-					res => {
-						if (res.status !== "success")
-							new Toast(`Error: ${res.message}`);
-						else {
-							if (index)
-								this.youtubeSearch.songs.results[
-									index
-								].isAddedToQueue = true;
-
-							new Toast(res.message);
-						}
-					}
-				);
-			} else {
-				this.socket.dispatch("songs.request", youtubeId, res => {
-					if (res.status !== "success")
-						new Toast(`Error: ${res.message}`);
-					else {
-						this.youtubeSearch.songs.results[
-							index
-						].isAddedToQueue = true;
-
-						new Toast(res.message);
-					}
-				});
-			}
-		}
-	}
-};
-</script>
-
-<style lang="less">
-.search {
-	.musare-search,
-	.universal-item:not(:last-of-type) {
-		margin-bottom: 10px;
-	}
-	.load-more-button {
-		width: 100%;
-		margin-top: 10px;
-	}
-}
-</style>

+ 436 - 423
frontend/src/components/modals/ManageStation/index.vue

@@ -4,7 +4,7 @@
 		:title="
 			sector === 'home' && !isOwnerOrAdmin()
 				? 'View Queue'
-				: !isOwnerOrAdmin() && station.partyMode
+				: !isOwnerOrAdmin()
 				? 'Add Song to Queue'
 				: 'Manage Station'
 		"
@@ -12,169 +12,127 @@
 		class="manage-station-modal"
 		:size="isOwnerOrAdmin() || sector !== 'home' ? 'wide' : null"
 		:split="isOwnerOrAdmin() || sector !== 'home'"
+		:intercept-close="true"
+		@close="onCloseModal"
 	>
 		<template #body v-if="station && station._id">
 			<div class="left-section">
-				<div class="section tabs-container">
-					<div class="tab-selection">
-						<button
+				<div class="section">
+					<div class="station-info-box-wrapper">
+						<station-info-box
+							:station="station"
+							:station-paused="stationPaused"
+							:show-go-to-station="sector !== 'station'"
+						/>
+					</div>
+					<div v-if="isOwnerOrAdmin() || sector !== 'home'">
+						<div class="tab-selection">
+							<button
+								v-if="isOwnerOrAdmin()"
+								class="button is-default"
+								:class="{ selected: tab === 'settings' }"
+								ref="settings-tab"
+								@click="showTab('settings')"
+							>
+								Settings
+							</button>
+							<button
+								v-if="canRequest()"
+								class="button is-default"
+								:class="{ selected: tab === 'request' }"
+								ref="request-tab"
+								@click="showTab('request')"
+							>
+								Request
+							</button>
+							<button
+								v-if="
+									isOwnerOrAdmin() && station.autofill.enabled
+								"
+								class="button is-default"
+								:class="{ selected: tab === 'autofill' }"
+								ref="autofill-tab"
+								@click="showTab('autofill')"
+							>
+								Autofill
+							</button>
+							<button
+								v-if="isOwnerOrAdmin()"
+								class="button is-default"
+								:class="{ selected: tab === 'blacklist' }"
+								ref="blacklist-tab"
+								@click="showTab('blacklist')"
+							>
+								Blacklist
+							</button>
+						</div>
+						<settings
 							v-if="isOwnerOrAdmin()"
-							class="button is-default"
-							:class="{ selected: tab === 'settings' }"
-							ref="settings-tab"
-							@click="showTab('settings')"
-						>
-							Settings
-						</button>
-						<button
-							v-if="
-								isOwnerOrAdmin() ||
-								(loggedIn &&
-									station.type === 'community' &&
-									station.partyMode &&
-									((station.locked && isOwnerOrAdmin()) ||
-										!station.locked))
-							"
-							class="button is-default"
-							:class="{ selected: tab === 'playlists' }"
-							ref="playlists-tab"
-							@click="showTab('playlists')"
-						>
-							Playlists
-						</button>
-						<button
-							v-if="
-								loggedIn &&
-								station.type === 'community' &&
-								station.partyMode &&
-								((station.locked && isOwnerOrAdmin()) ||
-									!station.locked)
-							"
-							class="button is-default"
-							:class="{ selected: tab === 'songs' }"
-							ref="songs-tab"
-							@click="showTab('songs')"
+							class="tab"
+							v-show="tab === 'settings'"
+							:modal-uuid="modalUuid"
+							ref="settingsTabComponent"
+						/>
+						<request
+							v-if="canRequest()"
+							class="tab"
+							v-show="tab === 'request'"
+							:sector="'manageStation'"
+							:disable-auto-request="sector !== 'station'"
+							:modal-uuid="modalUuid"
+						/>
+						<playlist-tab-base
+							v-if="isOwnerOrAdmin() && station.autofill.enabled"
+							class="tab"
+							v-show="tab === 'autofill'"
+							:type="'autofill'"
+							:modal-uuid="modalUuid"
 						>
-							Add Songs
-						</button>
-						<button
+							<template #info>
+								<p>
+									Select playlists to automatically add songs
+									within to the queue
+								</p>
+							</template>
+						</playlist-tab-base>
+						<playlist-tab-base
 							v-if="isOwnerOrAdmin()"
-							class="button is-default"
-							:class="{ selected: tab === 'blacklist' }"
-							ref="blacklist-tab"
-							@click="showTab('blacklist')"
+							class="tab"
+							v-show="tab === 'blacklist'"
+							:type="'blacklist'"
+							:modal-uuid="modalUuid"
 						>
-							Blacklist
-						</button>
+							<template #info>
+								<p>
+									Blacklist a playlist to prevent all songs
+									within from playing in this station
+								</p>
+							</template>
+						</playlist-tab-base>
 					</div>
-					<settings
-						v-if="isOwnerOrAdmin()"
-						class="tab"
-						v-show="tab === 'settings'"
-					/>
-					<playlists
-						v-if="
-							isOwnerOrAdmin() ||
-							(loggedIn &&
-								station.type === 'community' &&
-								station.partyMode &&
-								((station.locked && isOwnerOrAdmin()) ||
-									!station.locked))
-						"
-						class="tab"
-						v-show="tab === 'playlists'"
-					/>
-					<songs
-						v-if="
-							loggedIn &&
-							station.type === 'community' &&
-							station.partyMode &&
-							((station.locked && isOwnerOrAdmin()) ||
-								!station.locked)
-						"
-						class="tab"
-						v-show="tab === 'songs'"
-					/>
-					<blacklist
-						v-if="isOwnerOrAdmin()"
-						class="tab"
-						v-show="tab === 'blacklist'"
-					/>
 				</div>
 			</div>
 			<div class="right-section">
 				<div class="section">
 					<div class="queue-title">
 						<h4 class="section-title">Queue</h4>
-						<i
-							v-if="isOwnerOrAdmin() && stationPaused"
-							@click="resumeStation()"
-							class="material-icons resume-station"
-							content="Resume Station"
-							v-tippy
-						>
-							play_arrow
-						</i>
-						<i
-							v-if="isOwnerOrAdmin() && !stationPaused"
-							@click="pauseStation()"
-							class="material-icons pause-station"
-							content="Pause Station"
-							v-tippy
-						>
-							pause
-						</i>
-						<quick-confirm
-							v-if="isOwnerOrAdmin()"
-							@confirm="skipStation()"
-						>
-							<i
-								class="material-icons skip-station"
-								content="Force Skip Station"
-								v-tippy
-							>
-								skip_next
-							</i>
-						</quick-confirm>
 					</div>
 					<hr class="section-horizontal-rule" />
 					<song-item
 						v-if="currentSong._id"
 						:song="currentSong"
-						:requested-by="
-							station.type === 'community' &&
-							station.partyMode === true
-						"
+						:requested-by="true"
 						header="Currently Playing.."
 						class="currently-playing"
 					/>
-					<queue sector="manageStation" />
+					<queue :modal-uuid="modalUuid" sector="manageStation" />
 				</div>
 			</div>
 		</template>
 		<template #footer>
-			<router-link
-				v-if="sector !== 'station' && station.name"
-				:to="{
-					name: 'station',
-					params: { id: station.name }
-				}"
-				class="button is-primary"
-			>
-				Go To Station
-			</router-link>
-			<a
-				class="button is-default"
-				v-if="isOwnerOrAdmin() && !station.partyMode"
-				@click="stationPlaylist()"
-			>
-				View Station Playlist
-			</a>
 			<div v-if="isOwnerOrAdmin()" class="right">
-				<quick-confirm @confirm="clearAndRefillStationQueue()">
-					<a class="button is-danger">
-						Clear and refill station queue
-					</a>
+				<quick-confirm @confirm="resetQueue()">
+					<a class="button is-danger">Reset queue</a>
 				</quick-confirm>
 				<quick-confirm @confirm="removeStation()">
 					<button class="button is-danger">Delete station</button>
@@ -188,32 +146,28 @@
 import { mapState, mapGetters, mapActions } from "vuex";
 
 import Toast from "toasters";
-import ws from "@/ws";
 
-import QuickConfirm from "@/components/QuickConfirm.vue";
+import { mapModalState, mapModalActions } from "@/vuex_helpers";
+
 import Queue from "@/components/Queue.vue";
 import SongItem from "@/components/SongItem.vue";
-import Modal from "../../Modal.vue";
+import StationInfoBox from "@/components/StationInfoBox.vue";
 
-import Settings from "./Tabs/Settings.vue";
-import Playlists from "./Tabs/Playlists.vue";
-import Songs from "./Tabs/Songs.vue";
-import Blacklist from "./Tabs/Blacklist.vue";
+import Settings from "./Settings.vue";
+import PlaylistTabBase from "@/components/PlaylistTabBase.vue";
+import Request from "@/components/Request.vue";
 
 export default {
 	components: {
-		Modal,
-		QuickConfirm,
 		Queue,
 		SongItem,
+		StationInfoBox,
 		Settings,
-		Playlists,
-		Songs,
-		Blacklist
+		PlaylistTabBase,
+		Request
 	},
 	props: {
-		stationId: { type: String, default: "" },
-		sector: { type: String, default: "admin" }
+		modalUuid: { type: String, default: "" }
 	},
 	computed: {
 		...mapState({
@@ -221,13 +175,15 @@ export default {
 			userId: state => state.user.auth.userId,
 			role: state => state.user.auth.role
 		}),
-		...mapState("modals/manageStation", {
+		...mapModalState("modals/manageStation/MODAL_UUID", {
+			stationId: state => state.stationId,
+			sector: state => state.sector,
 			tab: state => state.tab,
 			station: state => state.station,
-			originalStation: state => state.originalStation,
 			songsList: state => state.songsList,
-			includedPlaylists: state => state.includedPlaylists,
-			excludedPlaylists: state => state.excludedPlaylists,
+			stationPlaylist: state => state.stationPlaylist,
+			autofill: state => state.autofill,
+			blacklist: state => state.blacklist,
 			stationPaused: state => state.stationPaused,
 			currentSong: state => state.currentSong
 		}),
@@ -235,8 +191,171 @@ export default {
 			socket: "websockets/getSocket"
 		})
 	},
+	watch: {
+		// eslint-disable-next-line
+		"station.requests": function (requests) {
+			if (this.tab === "request" && !this.canRequest()) {
+				if (this.isOwnerOrAdmin()) this.showTab("settings");
+				else if (!(this.sector === "home" && !this.isOwnerOrAdmin()))
+					this.closeModal("manageStation");
+			}
+		},
+		// eslint-disable-next-line
+		"station.autofill": function (autofill) {
+			if (this.tab === "autofill" && autofill && !autofill.enabled)
+				this.showTab("settings");
+		}
+	},
 	mounted() {
-		ws.onConnect(this.init);
+		this.socket.dispatch(`stations.getStationById`, this.stationId, res => {
+			if (res.status === "success") {
+				const { station } = res.data;
+				this.editStation(station);
+
+				if (!this.isOwnerOrAdmin()) this.showTab("request");
+
+				const currentSong = res.data.station.currentSong
+					? res.data.station.currentSong
+					: {};
+
+				this.updateCurrentSong(currentSong);
+
+				this.updateStationPaused(res.data.station.paused);
+
+				this.socket.dispatch(
+					"stations.getStationAutofillPlaylistsById",
+					this.stationId,
+					res => {
+						if (res.status === "success")
+							this.setAutofillPlaylists(res.data.playlists);
+					}
+				);
+
+				this.socket.dispatch(
+					"stations.getStationBlacklistById",
+					this.stationId,
+					res => {
+						if (res.status === "success")
+							this.setBlacklist(res.data.playlists);
+					}
+				);
+
+				if (this.isOwnerOrAdmin()) {
+					this.socket.dispatch(
+						"playlists.getPlaylistForStation",
+						this.station._id,
+						true,
+						res => {
+							if (res.status === "success") {
+								this.updateStationPlaylist(res.data.playlist);
+							}
+						}
+					);
+				}
+
+				this.socket.dispatch(
+					"stations.getQueue",
+					this.stationId,
+					res => {
+						if (res.status === "success")
+							this.updateSongsList(res.data.queue);
+					}
+				);
+
+				this.socket.dispatch(
+					"apis.joinRoom",
+					`manage-station.${this.stationId}`
+				);
+
+				this.socket.on(
+					"event:station.updated",
+					res => {
+						this.updateStation(res.data.station);
+					},
+					{ modalUuid: this.modalUuid }
+				);
+
+				this.socket.on(
+					"event:station.autofillPlaylist",
+					res => {
+						const { playlist } = res.data;
+						const playlistIndex = this.autofill
+							.map(autofillPlaylist => autofillPlaylist._id)
+							.indexOf(playlist._id);
+						if (playlistIndex === -1) this.autofill.push(playlist);
+					},
+					{ modalUuid: this.modalUuid }
+				);
+
+				this.socket.on(
+					"event:station.blacklistedPlaylist",
+					res => {
+						const { playlist } = res.data;
+						const playlistIndex = this.blacklist
+							.map(blacklistedPlaylist => blacklistedPlaylist._id)
+							.indexOf(playlist._id);
+						if (playlistIndex === -1) this.blacklist.push(playlist);
+					},
+					{ modalUuid: this.modalUuid }
+				);
+
+				this.socket.on(
+					"event:station.removedAutofillPlaylist",
+					res => {
+						const { playlistId } = res.data;
+						const playlistIndex = this.autofill
+							.map(playlist => playlist._id)
+							.indexOf(playlistId);
+						if (playlistIndex >= 0)
+							this.autofill.splice(playlistIndex, 1);
+					},
+					{ modalUuid: this.modalUuid }
+				);
+
+				this.socket.on(
+					"event:station.removedBlacklistedPlaylist",
+					res => {
+						const { playlistId } = res.data;
+						const playlistIndex = this.blacklist
+							.map(playlist => playlist._id)
+							.indexOf(playlistId);
+						if (playlistIndex >= 0)
+							this.blacklist.splice(playlistIndex, 1);
+					},
+					{ modalUuid: this.modalUuid }
+				);
+
+				this.socket.on(
+					"event:station.deleted",
+					() => {
+						new Toast(`The station you were editing was deleted.`);
+						this.closeModal("manageStation");
+					},
+					{ modalUuid: this.modalUuid }
+				);
+
+				this.socket.on(
+					"event:user.station.favorited",
+					res => {
+						if (res.data.stationId === this.stationId)
+							this.updateIsFavorited(true);
+					},
+					{ modalUuid: this.modalUuid }
+				);
+
+				this.socket.on(
+					"event:user.station.unfavorited",
+					res => {
+						if (res.data.stationId === this.stationId)
+							this.updateIsFavorited(false);
+					},
+					{ modalUuid: this.modalUuid }
+				);
+			} else {
+				new Toast(`Station with that ID not found`);
+				this.closeModal("manageStation");
+			}
+		});
 
 		this.socket.on(
 			"event:manageStation.queue.updated",
@@ -244,7 +363,7 @@ export default {
 				if (res.data.stationId === this.station._id)
 					this.updateSongsList(res.data.queue);
 			},
-			{ modal: "manageStation" }
+			{ modalUuid: this.modalUuid }
 		);
 
 		this.socket.on(
@@ -253,7 +372,7 @@ export default {
 				if (res.data.stationId === this.station._id)
 					this.repositionSongInList(res.data.song);
 			},
-			{ modal: "manageStation" }
+			{ modalUuid: this.modalUuid }
 		);
 
 		this.socket.on(
@@ -262,7 +381,7 @@ export default {
 				if (res.data.stationId === this.station._id)
 					this.updateStationPaused(true);
 			},
-			{ modal: "manageStation" }
+			{ modalUuid: this.modalUuid }
 		);
 
 		this.socket.on(
@@ -271,7 +390,7 @@ export default {
 				if (res.data.stationId === this.station._id)
 					this.updateStationPaused(false);
 			},
-			{ modal: "manageStation" }
+			{ modalUuid: this.modalUuid }
 		);
 
 		this.socket.on(
@@ -280,8 +399,75 @@ export default {
 				if (res.data.stationId === this.station._id)
 					this.updateCurrentSong(res.data.currentSong || {});
 			},
-			{ modal: "manageStation" }
+			{ modalUuid: this.modalUuid }
 		);
+
+		if (this.isOwnerOrAdmin()) {
+			this.socket.on(
+				"event:playlist.song.added",
+				res => {
+					if (this.stationPlaylist._id === res.data.playlistId)
+						this.stationPlaylist.songs.push(res.data.song);
+				},
+				{
+					modalUuid: this.modalUuid
+				}
+			);
+
+			this.socket.on(
+				"event:playlist.song.removed",
+				res => {
+					if (this.stationPlaylist._id === res.data.playlistId) {
+						// remove song from array of playlists
+						this.stationPlaylist.songs.forEach((song, index) => {
+							if (song.youtubeId === res.data.youtubeId)
+								this.stationPlaylist.songs.splice(index, 1);
+						});
+					}
+				},
+				{
+					modalUuid: this.modalUuid
+				}
+			);
+
+			this.socket.on(
+				"event:playlist.songs.repositioned",
+				res => {
+					if (this.stationPlaylist._id === res.data.playlistId) {
+						// for each song that has a new position
+						res.data.songsBeingChanged.forEach(changedSong => {
+							this.stationPlaylist.songs.forEach(
+								(song, index) => {
+									// find song locally
+									if (
+										song.youtubeId === changedSong.youtubeId
+									) {
+										// change song position attribute
+										this.stationPlaylist.songs[
+											index
+										].position = changedSong.position;
+
+										// reposition in array if needed
+										if (index !== changedSong.position - 1)
+											this.stationPlaylist.songs.splice(
+												changedSong.position - 1,
+												0,
+												this.stationPlaylist.songs.splice(
+													index,
+													1
+												)[0]
+											);
+									}
+								}
+							);
+						});
+					}
+				},
+				{
+					modalUuid: this.modalUuid
+				}
+			);
+		}
 	},
 	beforeUnmount() {
 		this.socket.dispatch(
@@ -292,211 +478,21 @@ export default {
 
 		if (this.isOwnerOrAdmin()) this.showTab("settings");
 		this.clearStation();
+
+		// Delete the VueX module that was created for this modal, after all other cleanup tasks are performed
+		this.$store.unregisterModule([
+			"modals",
+			"manageStation",
+			this.modalUuid
+		]);
 	},
 	methods: {
-		init() {
-			this.socket.dispatch(
-				`stations.getStationById`,
-				this.stationId,
-				res => {
-					if (res.status === "success") {
-						const { station } = res.data;
-						this.editStation(station);
-
-						if (!this.isOwnerOrAdmin() && this.station.partyMode)
-							this.showTab("songs");
-
-						const currentSong = res.data.station.currentSong
-							? res.data.station.currentSong
-							: {};
-
-						this.updateCurrentSong(currentSong);
-
-						this.updateStationPaused(res.data.station.paused);
-
-						this.socket.dispatch(
-							"stations.getStationIncludedPlaylistsById",
-							this.stationId,
-							res => {
-								if (res.status === "success")
-									this.setIncludedPlaylists(
-										res.data.playlists
-									);
-							}
-						);
-
-						this.socket.dispatch(
-							"stations.getStationExcludedPlaylistsById",
-							this.stationId,
-							res => {
-								if (res.status === "success")
-									this.setExcludedPlaylists(
-										res.data.playlists
-									);
-							}
-						);
-
-						this.socket.dispatch(
-							"stations.getQueue",
-							this.stationId,
-							res => {
-								if (res.status === "success")
-									this.updateSongsList(res.data.queue);
-							}
-						);
-
-						this.socket.dispatch(
-							"apis.joinRoom",
-							`manage-station.${this.stationId}`
-						);
-
-						this.socket.on(
-							"event:station.name.updated",
-							res => {
-								this.station.name = res.data.name;
-							},
-							{ modal: "manageStation" }
-						);
-
-						this.socket.on(
-							"event:station.displayName.updated",
-							res => {
-								this.station.displayName = res.data.displayName;
-							},
-							{ modal: "manageStation" }
-						);
-
-						this.socket.on(
-							"event:station.description.updated",
-							res => {
-								this.station.description = res.data.description;
-							},
-							{ modal: "manageStation" }
-						);
-
-						this.socket.on(
-							"event:station.partyMode.updated",
-							res => {
-								if (this.station.type === "community")
-									this.station.partyMode = res.data.partyMode;
-							},
-							{ modal: "manageStation" }
-						);
-
-						this.socket.on(
-							"event:station.playMode.updated",
-							res => {
-								this.station.playMode = res.data.playMode;
-							},
-							{ modal: "manageStation" }
-						);
-
-						this.socket.on(
-							"event:station.theme.updated",
-							res => {
-								const { theme } = res.data;
-								this.station.theme = theme;
-							},
-							{ modal: "manageStation" }
-						);
-
-						this.socket.on(
-							"event:station.privacy.updated",
-							res => {
-								this.station.privacy = res.data.privacy;
-							},
-							{ modal: "manageStation" }
-						);
-
-						this.socket.on(
-							"event:station.queue.lock.toggled",
-							res => {
-								this.station.locked = res.data.locked;
-							},
-							{ modal: "manageStation" }
-						);
-
-						this.socket.on(
-							"event:station.includedPlaylist",
-							res => {
-								const { playlist } = res.data;
-								const playlistIndex = this.includedPlaylists
-									.map(
-										includedPlaylist => includedPlaylist._id
-									)
-									.indexOf(playlist._id);
-								if (playlistIndex === -1)
-									this.includedPlaylists.push(playlist);
-							},
-							{ modal: "manageStation" }
-						);
-
-						this.socket.on(
-							"event:station.excludedPlaylist",
-							res => {
-								const { playlist } = res.data;
-								const playlistIndex = this.excludedPlaylists
-									.map(
-										excludedPlaylist => excludedPlaylist._id
-									)
-									.indexOf(playlist._id);
-								if (playlistIndex === -1)
-									this.excludedPlaylists.push(playlist);
-							},
-							{ modal: "manageStation" }
-						);
-
-						this.socket.on(
-							"event:station.removedIncludedPlaylist",
-							res => {
-								const { playlistId } = res.data;
-								const playlistIndex = this.includedPlaylists
-									.map(playlist => playlist._id)
-									.indexOf(playlistId);
-								if (playlistIndex >= 0)
-									this.includedPlaylists.splice(
-										playlistIndex,
-										1
-									);
-							},
-							{ modal: "manageStation" }
-						);
-
-						this.socket.on(
-							"event:station.removedExcludedPlaylist",
-							res => {
-								const { playlistId } = res.data;
-								const playlistIndex = this.excludedPlaylists
-									.map(playlist => playlist._id)
-									.indexOf(playlistId);
-								if (playlistIndex >= 0)
-									this.excludedPlaylists.splice(
-										playlistIndex,
-										1
-									);
-							},
-							{ modal: "manageStation" }
-						);
-
-						this.socket.on(
-							"event:station.deleted",
-							() => {
-								new Toast(
-									`The station you were editing was deleted.`
-								);
-								this.closeModal("manageStation");
-							},
-							{ modal: "manageStation" }
-						);
-					} else {
-						new Toast(`Station with that ID not found`);
-						this.closeModal("manageStation");
-					}
-				}
-			);
-		},
 		isOwner() {
-			return this.loggedIn && this.userId === this.station.owner;
+			return (
+				this.loggedIn &&
+				this.station &&
+				this.userId === this.station.owner
+			);
 		},
 		isAdmin() {
 			return this.loggedIn && this.role === "admin";
@@ -504,6 +500,17 @@ export default {
 		isOwnerOrAdmin() {
 			return this.isOwner() || this.isAdmin();
 		},
+		canRequest() {
+			return (
+				this.station &&
+				this.loggedIn &&
+				this.station.requests &&
+				this.station.requests.enabled &&
+				(this.station.requests.access === "user" ||
+					(this.station.requests.access === "owner" &&
+						this.isOwnerOrAdmin()))
+			);
+		},
 		removeStation() {
 			this.socket.dispatch("stations.remove", this.station._id, res => {
 				new Toast(res.message);
@@ -537,9 +544,9 @@ export default {
 				}
 			);
 		},
-		clearAndRefillStationQueue() {
+		resetQueue() {
 			this.socket.dispatch(
-				"stations.clearAndRefillStationQueue",
+				"stations.resetQueue",
 				this.station._id,
 				res => {
 					if (res.status !== "success")
@@ -551,30 +558,23 @@ export default {
 				}
 			);
 		},
-		stationPlaylist() {
-			this.socket.dispatch(
-				"playlists.getPlaylistForStation",
-				this.station._id,
-				false,
-				res => {
-					if (res.status === "success") {
-						this.editPlaylist(res.data.playlist._id);
-						this.openModal("editPlaylist");
-					} else {
-						new Toast(res.message);
-					}
-				}
-			);
+		onCloseModal() {
+			if (this.isOwnerOrAdmin() || this.sector !== "home")
+				this.$refs.settingsTabComponent.onCloseModal();
+			else this.closeModal("manageStation");
 		},
-		...mapActions("modals/manageStation", [
+		...mapModalActions("modals/manageStation/MODAL_UUID", [
 			"editStation",
-			"setIncludedPlaylists",
-			"setExcludedPlaylists",
+			"setAutofillPlaylists",
+			"setBlacklist",
 			"clearStation",
 			"updateSongsList",
+			"updateStationPlaylist",
 			"repositionSongInList",
 			"updateStationPaused",
-			"updateCurrentSong"
+			"updateCurrentSong",
+			"updateStation",
+			"updateIsFavorited"
 		]),
 		...mapActions({
 			showTab(dispatch, payload) {
@@ -582,11 +582,13 @@ export default {
 					this.$refs[`${payload}-tab`].scrollIntoView({
 						block: "nearest"
 					}); // Only works if the ref exists, which it doesn't always
-				return dispatch("modals/manageStation/showTab", payload);
+				return dispatch(
+					`modals/manageStation/${this.modalUuid}/showTab`,
+					payload
+				);
 			}
 		}),
-		...mapActions("modalVisibility", ["openModal", "closeModal"]),
-		...mapActions("user/playlists", ["editPlaylist"])
+		...mapActions("modalVisibility", ["openModal", "closeModal"])
 	}
 };
 </script>
@@ -611,16 +613,19 @@ export default {
 .night-mode {
 	.manage-station-modal.modal .modal-card-body {
 		.left-section {
-			.tabs-container.section {
+			.station-info-box-wrapper {
+				border: 0;
+			}
+			.section {
 				background-color: transparent !important;
-				.tab-selection .button {
-					background: var(--dark-grey);
-					color: var(--white);
-				}
-				.tab {
-					background-color: var(--dark-grey-3);
-					border: 0;
-				}
+			}
+			.tab-selection .button {
+				background: var(--dark-grey);
+				color: var(--white);
+			}
+			.tab {
+				background-color: var(--dark-grey-3);
+				border: 0;
 			}
 		}
 		.right-section .section,
@@ -632,40 +637,48 @@ export default {
 }
 
 .manage-station-modal.modal .modal-card-body {
+	display: flex;
+	flex-wrap: wrap;
+	height: 100%;
+
 	.left-section {
-		.tabs-container {
-			padding: 15px 0 !important;
-			.tab-selection {
-				display: flex;
-				overflow-x: auto;
-
-				.button {
-					border-radius: @border-radius @border-radius 0 0;
-					border: 0;
-					text-transform: uppercase;
-					font-size: 14px;
-					color: var(--dark-grey-3);
-					background-color: var(--light-grey-2);
-					flex-grow: 1;
-					height: 32px;
-
-					&:not(:first-of-type) {
-						margin-left: 5px;
-					}
-				}
+		.station-info-box-wrapper {
+			border-radius: @border-radius;
+			border: 1px solid var(--light-grey-3);
+			overflow: hidden;
+			margin-bottom: 20px;
+		}
 
-				.selected {
-					background-color: var(--primary-color) !important;
-					color: var(--white) !important;
-					font-weight: 600;
+		.tab-selection {
+			display: flex;
+			overflow-x: auto;
+
+			.button {
+				border-radius: @border-radius @border-radius 0 0;
+				border: 0;
+				text-transform: uppercase;
+				font-size: 14px;
+				color: var(--dark-grey-3);
+				background-color: var(--light-grey-2);
+				flex-grow: 1;
+				height: 32px;
+
+				&:not(:first-of-type) {
+					margin-left: 5px;
 				}
 			}
-			.tab {
-				border: 1px solid var(--light-grey-3);
-				padding: 15px;
-				border-radius: 0 0 @border-radius @border-radius;
+
+			.selected {
+				background-color: var(--primary-color) !important;
+				color: var(--white) !important;
+				font-weight: 600;
 			}
 		}
+		.tab {
+			border: 1px solid var(--light-grey-3);
+			padding: 15px 10px;
+			border-radius: 0 0 @border-radius @border-radius;
+		}
 	}
 	.right-section {
 		.section {

+ 1 - 2
frontend/src/components/modals/Register.vue

@@ -137,13 +137,12 @@
 <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: { Modal, InputHelpBox },
+	components: { InputHelpBox },
 	data() {
 		return {
 			username: {

+ 3 - 4
frontend/src/components/modals/RemoveAccount.vue

@@ -179,11 +179,10 @@ import { mapActions, mapGetters } from "vuex";
 
 import Toast from "toasters";
 
-import QuickConfirm from "@/components/QuickConfirm.vue";
-import Modal from "../Modal.vue";
-
 export default {
-	components: { Modal, QuickConfirm },
+	props: {
+		modalUuid: { type: String, default: "" }
+	},
 	data() {
 		return {
 			name: "RemoveAccount",

+ 22 - 20
frontend/src/components/modals/Report.vue

@@ -171,7 +171,14 @@
 											class="material-icons"
 											content="View Report"
 											v-tippy
-											@click="view(report._id)"
+											@click="
+												openModal({
+													modal: 'viewReport',
+													data: {
+														reportId: report._id
+													}
+												})
+											"
 										>
 											open_in_full
 										</i>
@@ -201,22 +208,23 @@
 				</div>
 			</template>
 		</modal>
-		<view-report v-if="modals.viewReport" />
 	</div>
 </template>
 
 <script>
-import { mapState, mapGetters, mapActions } from "vuex";
+import { mapGetters, mapActions } from "vuex";
 import Toast from "toasters";
 import ws from "@/ws";
+import { mapModalState } from "@/vuex_helpers";
 
-import ViewReport from "@/components/modals/ViewReport.vue";
 import SongItem from "@/components/SongItem.vue";
 import ReportInfoItem from "@/components/ReportInfoItem.vue";
-import Modal from "../Modal.vue";
 
 export default {
-	components: { Modal, ViewReport, SongItem, ReportInfoItem },
+	components: { SongItem, ReportInfoItem },
+	props: {
+		modalUuid: { type: String, default: "" }
+	},
 	data() {
 		return {
 			icons: {
@@ -349,12 +357,8 @@ export default {
 		};
 	},
 	computed: {
-		...mapState({
-			song: state => state.modals.report.song,
-			previousSong: state => state.station.previousSong
-		}),
-		...mapState("modalVisibility", {
-			modals: state => state.modals
+		...mapModalState("modals/report/MODAL_UUID", {
+			song: state => state.song
 		}),
 		...mapGetters({
 			socket: "websockets/getSocket"
@@ -370,9 +374,13 @@ export default {
 					report => report._id !== res.data.reportId
 				);
 			},
-			{ modal: "report" }
+			{ modalUuid: this.modalUuid }
 		);
 	},
+	beforeUnmount() {
+		// Delete the VueX module that was created for this modal, after all other cleanup tasks are performed
+		this.$store.unregisterModule(["modals", "report", this.modalUuid]);
+	},
 	methods: {
 		init() {
 			this.socket.dispatch(
@@ -391,10 +399,6 @@ export default {
 				}
 			);
 		},
-		view(reportId) {
-			this.viewReport(reportId);
-			this.openModal("viewReport");
-		},
 		create() {
 			const issues = [];
 
@@ -430,9 +434,7 @@ export default {
 				}
 			);
 		},
-		...mapActions("modalVisibility", ["openModal", "closeModal"]),
-		...mapActions("modals/viewReport", ["viewReport"]),
-		...mapActions("modals/report", ["reportSong"])
+		...mapActions("modalVisibility", ["openModal", "closeModal"])
 	}
 };
 </script>

+ 17 - 7
frontend/src/components/modals/ViewPunishment.vue

@@ -9,20 +9,19 @@
 </template>
 
 <script>
-import { mapState, mapGetters, mapActions } from "vuex";
+import { mapGetters, mapActions } from "vuex";
 import { format, formatDistance, parseISO } from "date-fns";
 
 import Toast from "toasters";
 import ws from "@/ws";
+import { mapModalState, mapModalActions } from "@/vuex_helpers";
 
-import Modal from "../Modal.vue";
 import PunishmentItem from "../PunishmentItem.vue";
 
 export default {
-	components: { Modal, PunishmentItem },
+	components: { PunishmentItem },
 	props: {
-		punishmentId: { type: String, default: "" },
-		sector: { type: String, default: "admin" }
+		modalUuid: { type: String, default: "" }
 	},
 	data() {
 		return {
@@ -30,7 +29,8 @@ export default {
 		};
 	},
 	computed: {
-		...mapState("modals/viewPunishment", {
+		...mapModalState("modals/viewPunishment/MODAL_UUID", {
+			punishmentId: state => state.punishmentId,
 			punishment: state => state.punishment
 		}),
 		...mapGetters({
@@ -40,6 +40,14 @@ export default {
 	mounted() {
 		ws.onConnect(this.init);
 	},
+	beforeUnmount() {
+		// Delete the VueX module that was created for this modal, after all other cleanup tasks are performed
+		this.$store.unregisterModule([
+			"modals",
+			"viewPunishment",
+			this.modalUuid
+		]);
+	},
 	methods: {
 		init() {
 			this.socket.dispatch(
@@ -57,7 +65,9 @@ export default {
 			);
 		},
 		...mapActions("modalVisibility", ["closeModal"]),
-		...mapActions("modals/viewPunishment", ["viewPunishment"]),
+		...mapModalActions("modals/viewPunishment/MODAL_UUID", [
+			"viewPunishment"
+		]),
 		format,
 		formatDistance,
 		parseISO

+ 15 - 13
frontend/src/components/modals/ViewReport.vue

@@ -115,19 +115,18 @@
 </template>
 
 <script>
-import { mapActions, mapGetters, mapState } from "vuex";
+import { mapActions, mapGetters } from "vuex";
 import Toast from "toasters";
 import ws from "@/ws";
+import { mapModalState } from "@/vuex_helpers";
 
-import Modal from "@/components/Modal.vue";
 import SongItem from "@/components/SongItem.vue";
 import ReportInfoItem from "@/components/ReportInfoItem.vue";
-import QuickConfirm from "@/components/QuickConfirm.vue";
 
 export default {
-	components: { Modal, SongItem, ReportInfoItem, QuickConfirm },
+	components: { SongItem, ReportInfoItem },
 	props: {
-		sector: { type: String, default: "admin" }
+		modalUuid: { type: String, default: "" }
 	},
 	data() {
 		return {
@@ -144,8 +143,8 @@ export default {
 		};
 	},
 	computed: {
-		...mapState("modals/viewReport", {
-			reportId: state => state.viewingReportId
+		...mapModalState("modals/viewReport/MODAL_UUID", {
+			reportId: state => state.reportId
 		}),
 		...mapGetters({
 			socket: "websockets/getSocket"
@@ -159,13 +158,13 @@ export default {
 			res => {
 				this.report.resolved = res.data.resolved;
 			},
-			{ modal: "viewReport" }
+			{ modalUuid: this.modalUuid }
 		);
 
 		this.socket.on(
 			"event:admin.report.removed",
 			() => this.closeModal("viewReport"),
-			{ modal: "viewReport" }
+			{ modalUuid: this.modalUuid }
 		);
 
 		this.socket.on(
@@ -179,11 +178,13 @@ export default {
 					issue.resolved = res.data.resolved;
 				}
 			},
-			{ modal: "viewReport" }
+			{ modalUuid: this.modalUuid }
 		);
 	},
 	beforeUnmount() {
 		this.socket.dispatch("apis.leaveRoom", `view-report.${this.reportId}`);
+		// Delete the VueX module that was created for this modal, after all other cleanup tasks are performed
+		this.$store.unregisterModule(["modals", "viewReport", this.modalUuid]);
 	},
 	methods: {
 		init() {
@@ -243,15 +244,16 @@ export default {
 			);
 		},
 		openSong() {
-			this.editSong({ songId: this.report.song._id });
-			this.openModal("editSong");
+			this.openModal({
+				modal: "editSong",
+				data: { song: { songId: this.report.song._id } }
+			});
 		},
 		...mapActions("admin/reports", [
 			"indexReports",
 			"resolveReport",
 			"removeReport"
 		]),
-		...mapActions("modals/editSong", ["editSong"]),
 		...mapActions("modalVisibility", ["closeModal", "openModal"])
 	}
 };

+ 33 - 73
frontend/src/components/modals/WhatIsNew.vue

@@ -1,53 +1,44 @@
 <template>
-	<div v-if="news !== null">
-		<modal title="News" class="what-is-news-modal">
-			<template #body>
-				<div
-					class="section news-item"
-					v-html="sanitize(marked(news.markdown))"
-				></div>
-			</template>
-			<template #footer>
-				<span v-if="news.createdBy">
-					By
-					<user-id-to-username
-						:user-id="news.createdBy"
-						:alt="news.createdBy"
-						:link="true" /></span
-				>&nbsp;<span :title="new Date(news.createdAt)">
-					{{
-						formatDistance(news.createdAt, new Date(), {
-							addSuffix: true
-						})
-					}}
-				</span>
-			</template>
-		</modal>
-	</div>
-	<div v-else></div>
+	<modal title="News" class="what-is-news-modal">
+		<template #body>
+			<div
+				class="section news-item"
+				v-html="sanitize(marked(news.markdown))"
+			></div>
+		</template>
+		<template #footer>
+			<span v-if="news.createdBy">
+				By
+				<user-id-to-username
+					:user-id="news.createdBy"
+					:alt="news.createdBy"
+					:link="true" /></span
+			>&nbsp;<span :title="new Date(news.createdAt)">
+				{{
+					formatDistance(news.createdAt, new Date(), {
+						addSuffix: true
+					})
+				}}
+			</span>
+		</template>
+	</modal>
 </template>
 
 <script>
 import { formatDistance } from "date-fns";
 import { marked } from "marked";
 import { sanitize } from "dompurify";
-import { mapGetters, mapActions } from "vuex";
-import ws from "@/ws";
+import { mapActions } from "vuex";
 
-import UserIdToUsername from "@/components/UserIdToUsername.vue";
-import Modal from "../Modal.vue";
+import { mapModalState } from "@/vuex_helpers";
 
 export default {
-	components: { Modal, UserIdToUsername },
-	data() {
-		return {
-			isModalActive: false,
-			news: null
-		};
+	props: {
+		modalUuid: { type: String, default: "" }
 	},
 	computed: {
-		...mapGetters({
-			socket: "websockets/getSocket"
+		...mapModalState("modals/whatIsNew/MODAL_UUID", {
+			news: state => state.news
 		})
 	},
 	mounted() {
@@ -61,43 +52,12 @@ export default {
 				}
 			}
 		});
-
-		ws.onConnect(this.init);
+	},
+	beforeUnmount() {
+		// Delete the VueX module that was created for this modal, after all other cleanup tasks are performed
+		this.$store.unregisterModule(["modals", "whatIsNew", this.modalUuid]);
 	},
 	methods: {
-		init() {
-			const newUser = !localStorage.getItem("firstVisited");
-			this.socket.dispatch("news.newest", newUser, res => {
-				if (res.status !== "success") return;
-
-				const { news } = res.data;
-
-				this.news = news;
-				if (this.news) {
-					if (newUser) {
-						this.openModal("whatIsNew");
-					} else if (localStorage.getItem("whatIsNew")) {
-						if (
-							parseInt(localStorage.getItem("whatIsNew")) <
-							news.createdAt
-						) {
-							this.openModal("whatIsNew");
-							localStorage.setItem("whatIsNew", news.createdAt);
-						}
-					} else {
-						if (
-							parseInt(localStorage.getItem("firstVisited")) <
-							news.createdAt
-						)
-							this.openModal("whatIsNew");
-						localStorage.setItem("whatIsNew", news.createdAt);
-					}
-				}
-
-				if (!localStorage.getItem("firstVisited"))
-					localStorage.setItem("firstVisited", Date.now());
-			});
-		},
 		marked,
 		sanitize,
 		formatDistance,

+ 14 - 3
frontend/src/main.js

@@ -61,6 +61,17 @@ app.component("PageMetadata", {
 	}
 });
 
+const globalComponents = require.context(
+	"@/components/global/",
+	true,
+	/\.vue$/i
+);
+globalComponents.keys().forEach(componentFilePath => {
+	const componentName = componentFilePath.split("/").pop().split(".")[0];
+	const component = globalComponents(componentFilePath);
+	app.component(componentName, component.default || component);
+});
+
 app.directive("scroll", {
 	mounted(el, binding) {
 		const f = evt => {
@@ -151,6 +162,7 @@ const router = createRouter({
 		},
 		{
 			path: "/admin",
+			name: "admin",
 			component: () => import("@/pages/Admin/index.vue"),
 			children: [
 				{
@@ -209,9 +221,8 @@ router.beforeEach((to, from, next) => {
 		window.stationInterval = 0;
 	}
 
-	if (from.name === "home" && to.name === "station") {
-		if (store.state.modalVisibility.modals.manageStation)
-			store.dispatch("modalVisibility/closeModal", "manageStation");
+	if (to.name === "station") {
+		store.dispatch("modalVisibility/closeModal", "manageStation");
 	}
 
 	if (ws.socket && to.fullPath !== from.fullPath) {

+ 1 - 0
frontend/src/ms.js

@@ -1,3 +1,4 @@
+/* eslint-disable-next-line no-redeclare */
 /* global MediaMetadata */
 
 export default {

+ 0 - 9
frontend/src/pages/404.vue

@@ -14,15 +14,6 @@
 	</div>
 </template>
 
-<script>
-import MainHeader from "@/components/layout/MainHeader.vue";
-import MainFooter from "@/components/layout/MainFooter.vue";
-
-export default {
-	components: { MainHeader, MainFooter }
-};
-</script>
-
 <style lang="less" scoped>
 .wrapper {
 	min-height: calc(100vh - 100px);

+ 0 - 9
frontend/src/pages/About.vue

@@ -59,15 +59,6 @@
 	</div>
 </template>
 
-<script>
-import MainHeader from "@/components/layout/MainHeader.vue";
-import MainFooter from "@/components/layout/MainFooter.vue";
-
-export default {
-	components: { MainHeader, MainFooter }
-};
-</script>
-
 <style lang="less" scoped>
 .night-mode {
 	.card {

+ 17 - 26
frontend/src/pages/Admin/News.vue

@@ -3,7 +3,15 @@
 		<page-metadata title="Admin | News" />
 		<div class="container">
 			<div class="button-row">
-				<button class="is-primary button" @click="edit()">
+				<button
+					class="is-primary button"
+					@click="
+						openModal({
+							modal: 'editNews',
+							data: { createNews: true }
+						})
+					"
+				>
 					Create News Item
 				</button>
 			</div>
@@ -20,7 +28,12 @@
 					<div class="row-options">
 						<button
 							class="button is-primary icon-with-button material-icons"
-							@click="edit(slotProps.item._id)"
+							@click="
+								openModal({
+									modal: 'editNews',
+									data: { newsId: slotProps.item._id }
+								})
+							"
 							content="Edit News"
 							v-tippy
 						>
@@ -69,32 +82,18 @@
 				</template>
 			</advanced-table>
 		</div>
-
-		<edit-news
-			v-if="modals.editNews"
-			:news-id="editingNewsId"
-			sector="admin"
-		/>
 	</div>
 </template>
 
 <script>
-import { mapActions, mapState, mapGetters } from "vuex";
-import { defineAsyncComponent } from "vue";
+import { mapActions, mapGetters } from "vuex";
 import Toast from "toasters";
 
 import AdvancedTable from "@/components/AdvancedTable.vue";
-import QuickConfirm from "@/components/QuickConfirm.vue";
-import UserIdToUsername from "@/components/UserIdToUsername.vue";
 
 export default {
 	components: {
-		AdvancedTable,
-		QuickConfirm,
-		UserIdToUsername,
-		EditNews: defineAsyncComponent(() =>
-			import("@/components/modals/EditNews.vue")
-		)
+		AdvancedTable
 	},
 	data() {
 		return {
@@ -205,19 +204,11 @@ export default {
 		};
 	},
 	computed: {
-		...mapState("modalVisibility", {
-			modals: state => state.modals
-		}),
 		...mapGetters({
 			socket: "websockets/getSocket"
 		})
 	},
 	methods: {
-		edit(id) {
-			if (id) this.editingNewsId = id;
-			else this.editingNewsId = "";
-			this.openModal("editNews");
-		},
 		remove(id) {
 			this.socket.dispatch(
 				"news.remove",

+ 9 - 30
frontend/src/pages/Admin/Playlists.vue

@@ -17,7 +17,12 @@
 					<div class="row-options">
 						<button
 							class="button is-primary icon-with-button material-icons"
-							@click="edit(slotProps.item._id)"
+							@click="
+								openModal({
+									modal: 'editPlaylist',
+									data: { playlistId: slotProps.item._id }
+								})
+							"
 							:disabled="slotProps.item.removed"
 							content="Edit Playlist"
 							v-tippy
@@ -78,37 +83,21 @@
 				</template>
 			</advanced-table>
 		</div>
-
-		<edit-playlist v-if="modals.editPlaylist" sector="admin" />
-		<edit-song v-if="modals.editSong" song-type="songs" />
-		<report v-if="modals.report" />
 	</div>
 </template>
 
 <script>
-import { mapState, mapActions } from "vuex";
-import { defineAsyncComponent } from "vue";
+import { mapActions } from "vuex";
 
 import AdvancedTable from "@/components/AdvancedTable.vue";
 import RunJobDropdown from "@/components/RunJobDropdown.vue";
-import UserIdToUsername from "@/components/UserIdToUsername.vue";
 
 import utils from "../../../js/utils";
 
 export default {
 	components: {
-		EditPlaylist: defineAsyncComponent(() =>
-			import("@/components/modals/EditPlaylist")
-		),
-		Report: defineAsyncComponent(() =>
-			import("@/components/modals/Report.vue")
-		),
-		EditSong: defineAsyncComponent(() =>
-			import("@/components/modals/EditSong")
-		),
 		AdvancedTable,
-		RunJobDropdown,
-		UserIdToUsername
+		RunJobDropdown
 	},
 	data() {
 		return {
@@ -338,16 +327,7 @@ export default {
 			]
 		};
 	},
-	computed: {
-		...mapState("modalVisibility", {
-			modals: state => state.modals
-		})
-	},
 	methods: {
-		edit(playlistId) {
-			this.editPlaylist(playlistId);
-			this.openModal("editPlaylist");
-		},
 		getDateFormatted(createdAt) {
 			const date = new Date(createdAt);
 			const year = date.getFullYear();
@@ -360,8 +340,7 @@ export default {
 		formatTimeLong(length) {
 			return this.utils.formatTimeLong(length);
 		},
-		...mapActions("modalVisibility", ["openModal"]),
-		...mapActions("user/playlists", ["editPlaylist"])
+		...mapActions("modalVisibility", ["openModal"])
 	}
 };
 </script>

+ 9 - 24
frontend/src/pages/Admin/Punishments.vue

@@ -14,7 +14,12 @@
 					<div class="row-options">
 						<button
 							class="button is-primary icon-with-button material-icons"
-							@click="view(slotProps.item._id)"
+							@click="
+								openModal({
+									modal: 'viewPunishment',
+									data: { punishmentId: slotProps.item._id }
+								})
+							"
 							:disabled="slotProps.item.removed"
 							content="View Punishment"
 							v-tippy
@@ -115,33 +120,21 @@
 				</div>
 			</div>
 		</div>
-		<view-punishment
-			v-if="modals.viewPunishment"
-			:punishment-id="viewingPunishmentId"
-			sector="admin"
-		/>
 	</div>
 </template>
 
 <script>
-import { mapState, mapGetters, mapActions } from "vuex";
+import { mapGetters, mapActions } from "vuex";
 import Toast from "toasters";
-import { defineAsyncComponent } from "vue";
 
 import AdvancedTable from "@/components/AdvancedTable.vue";
-import UserIdToUsername from "@/components/UserIdToUsername.vue";
 
 export default {
 	components: {
-		ViewPunishment: defineAsyncComponent(() =>
-			import("@/components/modals/ViewPunishment.vue")
-		),
-		AdvancedTable,
-		UserIdToUsername
+		AdvancedTable
 	},
 	data() {
 		return {
-			viewingPunishmentId: "",
 			ipBan: {
 				expiresAt: "1h"
 			},
@@ -278,18 +271,11 @@ export default {
 		};
 	},
 	computed: {
-		...mapState("modalVisibility", {
-			modals: state => state.modals
-		}),
 		...mapGetters({
 			socket: "websockets/getSocket"
 		})
 	},
 	methods: {
-		view(punishmentId) {
-			this.viewingPunishmentId = punishmentId;
-			this.openModal("viewPunishment");
-		},
 		banIP() {
 			this.socket.dispatch(
 				"punishments.banIP",
@@ -310,8 +296,7 @@ export default {
 			const minute = `${date.getMinutes()}`.padStart(2, 0);
 			return `${year}-${month}-${day} ${hour}:${minute}`;
 		},
-		...mapActions("modalVisibility", ["openModal"]),
-		...mapActions("admin/punishments", ["viewPunishment"])
+		...mapActions("modalVisibility", ["openModal"])
 	}
 };
 </script>

+ 9 - 30
frontend/src/pages/Admin/Reports.vue

@@ -15,7 +15,12 @@
 					<div class="row-options">
 						<button
 							class="button is-primary icon-with-button material-icons"
-							@click="view(slotProps.item._id)"
+							@click="
+								openModal({
+									modal: 'viewReport',
+									data: { reportId: slotProps.item._id }
+								})
+							"
 							:disabled="slotProps.item.removed"
 							content="View Report"
 							v-tippy
@@ -101,35 +106,19 @@
 				</template>
 			</advanced-table>
 		</div>
-
-		<view-report v-if="modals.viewReport" sector="admin" />
-		<edit-song v-if="modals.editSong" song-type="songs" />
-		<report v-if="modals.report" />
 	</div>
 </template>
 
 <script>
-import { mapState, mapActions } from "vuex";
-import { defineAsyncComponent } from "vue";
+import { mapActions } from "vuex";
 
 import Toast from "toasters";
 
 import AdvancedTable from "@/components/AdvancedTable.vue";
-import UserIdToUsername from "@/components/UserIdToUsername.vue";
 
 export default {
 	components: {
-		ViewReport: defineAsyncComponent(() =>
-			import("@/components/modals/ViewReport.vue")
-		),
-		Report: defineAsyncComponent(() =>
-			import("@/components/modals/Report.vue")
-		),
-		EditSong: defineAsyncComponent(() =>
-			import("@/components/modals/EditSong/index.vue")
-		),
-		AdvancedTable,
-		UserIdToUsername
+		AdvancedTable
 	},
 	data() {
 		return {
@@ -269,16 +258,7 @@ export default {
 			}
 		};
 	},
-	computed: {
-		...mapState("modalVisibility", {
-			modals: state => state.modals
-		})
-	},
 	methods: {
-		view(reportId) {
-			this.viewReport(reportId);
-			this.openModal("viewReport");
-		},
 		resolve(reportId, value) {
 			return this.resolveReport({ reportId, value })
 				.then(res => {
@@ -296,8 +276,7 @@ export default {
 			return `${year}-${month}-${day} ${hour}:${minute}`;
 		},
 		...mapActions("modalVisibility", ["openModal", "closeModal"]),
-		...mapActions("admin/reports", ["resolveReport"]),
-		...mapActions("modals/viewReport", ["viewReport"])
+		...mapActions("admin/reports", ["resolveReport"])
 	}
 };
 </script>

+ 60 - 90
frontend/src/pages/Admin/Songs.vue

@@ -256,53 +256,20 @@
 				</template>
 			</advanced-table>
 		</div>
-		<import-album v-if="modals.importAlbum" />
-		<edit-song v-if="modals.editSong" song-type="songs" />
-		<edit-songs v-if="modals.editSongs" />
-		<report v-if="modals.report" />
-		<import-playlist v-if="modals.importPlaylist" />
-		<bulk-actions v-if="modals.bulkActions" :type="bulkActionsType" />
-		<confirm v-if="modals.confirm" @confirmed="handleConfirmed()" />
 	</div>
 </template>
 
 <script>
 import { mapState, mapActions, mapGetters } from "vuex";
-import { defineAsyncComponent } from "vue";
 
 import Toast from "toasters";
 
 import AdvancedTable from "@/components/AdvancedTable.vue";
-import UserIdToUsername from "@/components/UserIdToUsername.vue";
-import QuickConfirm from "@/components/QuickConfirm.vue";
 import RunJobDropdown from "@/components/RunJobDropdown.vue";
 
 export default {
 	components: {
-		EditSong: defineAsyncComponent(() =>
-			import("@/components/modals/EditSong")
-		),
-		EditSongs: defineAsyncComponent(() =>
-			import("@/components/modals/EditSongs.vue")
-		),
-		Report: defineAsyncComponent(() =>
-			import("@/components/modals/Report.vue")
-		),
-		ImportAlbum: defineAsyncComponent(() =>
-			import("@/components/modals/ImportAlbum.vue")
-		),
-		ImportPlaylist: defineAsyncComponent(() =>
-			import("@/components/modals/ImportPlaylist.vue")
-		),
-		BulkActions: defineAsyncComponent(() =>
-			import("@/components/modals/BulkActions.vue")
-		),
-		Confirm: defineAsyncComponent(() =>
-			import("@/components/modals/Confirm.vue")
-		),
 		AdvancedTable,
-		UserIdToUsername,
-		QuickConfirm,
 		RunJobDropdown
 	},
 	data() {
@@ -624,19 +591,10 @@ export default {
 					name: "Recalculate all song ratings",
 					socket: "songs.recalculateAllRatings"
 				}
-			],
-			confirm: {
-				message: "",
-				action: "",
-				params: null
-			},
-			bulkActionsType: null
+			]
 		};
 	},
 	computed: {
-		...mapState("modalVisibility", {
-			modals: state => state.modals
-		}),
 		...mapState("modals/editSong", {
 			song: state => state.song
 		}),
@@ -659,12 +617,16 @@ export default {
 	},
 	methods: {
 		create() {
-			this.editSong({ newSong: true });
-			this.openModal("editSong");
+			this.openModal({
+				modal: "editSong",
+				data: { song: { newSong: true } }
+			});
 		},
 		editOne(song) {
-			this.editSong({ songId: song._id });
-			this.openModal("editSong");
+			this.openModal({
+				modal: "editSong",
+				data: { song: { songId: song._id } }
+			});
 		},
 		editMany(selectedRows) {
 			if (selectedRows.length === 1) this.editOne(selectedRows[0]);
@@ -672,8 +634,7 @@ export default {
 				const songs = selectedRows.map(row => ({
 					songId: row._id
 				}));
-				this.editSongs(songs);
-				this.openModal("editSongs");
+				this.openModal({ modal: "editSongs", data: { songs } });
 			}
 		},
 		verifyOne(songId) {
@@ -705,37 +666,49 @@ export default {
 			);
 		},
 		setTags(selectedRows) {
-			this.bulkActionsType = {
-				name: "tags",
-				action: "songs.editTags",
-				items: selectedRows.map(row => row._id),
-				regex: /^[a-zA-Z0-9_]{1,64}$|^[a-zA-Z0-9_]{1,64}\[[a-zA-Z0-9_]{1,64}\]$/,
-				autosuggest: true,
-				autosuggestDataAction: "songs.getTags"
-			};
-			this.openModal("bulkActions");
+			this.openModal({
+				modal: "bulkActions",
+				data: {
+					type: {
+						name: "tags",
+						action: "songs.editTags",
+						items: selectedRows.map(row => row._id),
+						regex: /^[a-zA-Z0-9_]{1,64}$|^[a-zA-Z0-9_]{1,64}\[[a-zA-Z0-9_]{1,64}\]$/,
+						autosuggest: true,
+						autosuggestDataAction: "songs.getTags"
+					}
+				}
+			});
 		},
 		setArtists(selectedRows) {
-			this.bulkActionsType = {
-				name: "artists",
-				action: "songs.editArtists",
-				items: selectedRows.map(row => row._id),
-				regex: /^(?=.{1,64}$).*$/,
-				autosuggest: true,
-				autosuggestDataAction: "songs.getArtists"
-			};
-			this.openModal("bulkActions");
+			this.openModal({
+				modal: "bulkActions",
+				data: {
+					type: {
+						name: "artists",
+						action: "songs.editArtists",
+						items: selectedRows.map(row => row._id),
+						regex: /^(?=.{1,64}$).*$/,
+						autosuggest: true,
+						autosuggestDataAction: "songs.getArtists"
+					}
+				}
+			});
 		},
 		setGenres(selectedRows) {
-			this.bulkActionsType = {
-				name: "genres",
-				action: "songs.editGenres",
-				items: selectedRows.map(row => row._id),
-				regex: /^[\x00-\x7F]{1,32}$/,
-				autosuggest: true,
-				autosuggestDataAction: "songs.getGenres"
-			};
-			this.openModal("bulkActions");
+			this.openModal({
+				modal: "bulkActions",
+				data: {
+					type: {
+						name: "genres",
+						action: "songs.editGenres",
+						items: selectedRows.map(row => row._id),
+						regex: /^[\x00-\x7F]{1,32}$/,
+						autosuggest: true,
+						autosuggestDataAction: "songs.getGenres"
+					}
+				}
+			});
 		},
 		deleteOne(songId) {
 			this.socket.dispatch("songs.remove", songId, res => {
@@ -760,26 +733,23 @@ export default {
 			const minute = `${date.getMinutes()}`.padStart(2, 0);
 			return `${year}-${month}-${day} ${hour}:${minute}`;
 		},
-		confirmAction(confirm) {
-			this.confirm = confirm;
-			this.updateConfirmMessage(confirm.message);
-			this.openModal("confirm");
+		confirmAction({ message, action, params }) {
+			this.openModal({
+				modal: "confirm",
+				data: {
+					message,
+					action,
+					params,
+					onCompleted: this.handleConfirmed
+				}
+			});
 		},
-		handleConfirmed() {
-			const { action, params } = this.confirm;
+		handleConfirmed({ action, params }) {
 			if (typeof this[action] === "function") {
 				if (params) this[action](params);
 				else this[action]();
 			}
-			this.confirm = {
-				message: "",
-				action: "",
-				params: null
-			};
 		},
-		...mapActions("modals/editSong", ["editSong"]),
-		...mapActions("modals/editSongs", ["editSongs"]),
-		...mapActions("modals/confirm", ["updateConfirmMessage"]),
 		...mapActions("modalVisibility", ["openModal"])
 	}
 };

+ 172 - 95
frontend/src/pages/Admin/Stations.vue

@@ -5,7 +5,12 @@
 			<div class="button-row">
 				<button
 					class="button is-primary"
-					@click="openModal('createStation')"
+					@click="
+						openModal({
+							modal: 'createStation',
+							data: { official: true }
+						})
+					"
 				>
 					Create Station
 				</button>
@@ -23,7 +28,15 @@
 					<div class="row-options">
 						<button
 							class="button is-primary icon-with-button material-icons"
-							@click="edit(slotProps.item._id)"
+							@click="
+								openModal({
+									modal: 'manageStation',
+									data: {
+										stationId: slotProps.item._id,
+										sector: 'admin'
+									}
+								})
+							"
 							:disabled="slotProps.item.removed"
 							content="Manage Station"
 							v-tippy
@@ -42,6 +55,16 @@
 								delete_forever
 							</button>
 						</quick-confirm>
+						<router-link
+							:to="{ path: `/${slotProps.item.name}` }"
+							target="_blank"
+							class="button is-primary icon-with-button material-icons"
+							:disabled="slotProps.item.removed"
+							content="View Station"
+							v-tippy
+						>
+							radio
+						</router-link>
 					</div>
 				</template>
 				<template #column-_id="slotProps">
@@ -84,76 +107,57 @@
 						:link="true"
 					/>
 				</template>
-				<template #column-stationMode="slotProps">
-					<span
-						:title="slotProps.item.partyMode ? 'Party' : 'Playlist'"
-						>{{
-							slotProps.item.partyMode ? "Party" : "Playlist"
-						}}</span
-					>
-				</template>
-				<template #column-playMode="slotProps">
-					<span :title="slotProps.item.playMode">{{
-						slotProps.item.playMode === "random"
-							? "Random"
-							: "Sequential"
-					}}</span>
-				</template>
 				<template #column-theme="slotProps">
 					<span :title="slotProps.item.theme">{{
 						slotProps.item.theme
 					}}</span>
 				</template>
+				<template #column-requestsEnabled="slotProps">
+					<span :title="slotProps.item.requests.enabled">{{
+						slotProps.item.requests.enabled
+					}}</span>
+				</template>
+				<template #column-requestsAccess="slotProps">
+					<span :title="slotProps.item.requests.access">{{
+						slotProps.item.requests.access
+					}}</span>
+				</template>
+				<template #column-requestsLimit="slotProps">
+					<span :title="slotProps.item.requests.limit">{{
+						slotProps.item.requests.limit
+					}}</span>
+				</template>
+				<template #column-autofillEnabled="slotProps">
+					<span :title="slotProps.item.autofill.enabled">{{
+						slotProps.item.autofill.enabled
+					}}</span>
+				</template>
+				<template #column-autofillLimit="slotProps">
+					<span :title="slotProps.item.autofill.limit">{{
+						slotProps.item.autofill.limit
+					}}</span>
+				</template>
+				<template #column-autofillMode="slotProps">
+					<span :title="slotProps.item.autofill.mode">{{
+						slotProps.item.autofill.mode
+					}}</span>
+				</template>
 			</advanced-table>
 		</div>
-
-		<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>
 
 <script>
-import { mapState, mapActions, mapGetters } from "vuex";
-import { defineAsyncComponent } from "vue";
+import { mapActions, mapGetters } from "vuex";
 
 import Toast from "toasters";
 
 import AdvancedTable from "@/components/AdvancedTable.vue";
-import QuickConfirm from "@/components/QuickConfirm.vue";
-import UserIdToUsername from "@/components/UserIdToUsername.vue";
 import RunJobDropdown from "@/components/RunJobDropdown.vue";
 
 export default {
 	components: {
-		EditPlaylist: defineAsyncComponent(() =>
-			import("@/components/modals/EditPlaylist")
-		),
-		CreatePlaylist: defineAsyncComponent(() =>
-			import("@/components/modals/CreatePlaylist.vue")
-		),
-		ManageStation: defineAsyncComponent(() =>
-			import("@/components/modals/ManageStation/index.vue")
-		),
-		Report: defineAsyncComponent(() =>
-			import("@/components/modals/Report.vue")
-		),
-		EditSong: defineAsyncComponent(() =>
-			import("@/components/modals/EditSong")
-		),
-		CreateStation: defineAsyncComponent(() =>
-			import("@/components/modals/CreateStation.vue")
-		),
 		AdvancedTable,
-		QuickConfirm,
-		UserIdToUsername,
 		RunJobDropdown
 	},
 	data() {
@@ -172,12 +176,12 @@ export default {
 				{
 					name: "options",
 					displayName: "Options",
-					properties: ["_id"],
+					properties: ["_id", "name"],
 					sortable: false,
 					hidable: false,
 					resizable: false,
-					minWidth: 85,
-					defaultWidth: 85
+					minWidth: 129,
+					defaultWidth: 129
 				},
 				{
 					name: "_id",
@@ -226,24 +230,64 @@ export default {
 					defaultWidth: 150
 				},
 				{
-					name: "stationMode",
-					displayName: "Station Mode",
-					properties: ["partyMode"],
-					sortable: false,
+					name: "theme",
+					displayName: "Theme",
+					properties: ["theme"],
+					sortProperty: "theme",
 					defaultVisibility: "hidden"
 				},
 				{
-					name: "playMode",
-					displayName: "Play Mode",
-					properties: ["playMode"],
-					sortable: false,
+					name: "requestsEnabled",
+					displayName: "Requests Enabled",
+					properties: ["requests.enabled"],
+					sortProperty: "requests.enabled",
+					minWidth: 180,
+					defaultWidth: 180,
 					defaultVisibility: "hidden"
 				},
 				{
-					name: "theme",
-					displayName: "Theme",
-					properties: ["theme"],
-					sortProperty: "theme",
+					name: "requestsAccess",
+					displayName: "Requests Access",
+					properties: ["requests.access"],
+					sortProperty: "requests.access",
+					minWidth: 180,
+					defaultWidth: 180,
+					defaultVisibility: "hidden"
+				},
+				{
+					name: "requestsLimit",
+					displayName: "Requests Limit",
+					properties: ["requests.limit"],
+					sortProperty: "requests.limit",
+					minWidth: 180,
+					defaultWidth: 180,
+					defaultVisibility: "hidden"
+				},
+				{
+					name: "autofillEnabled",
+					displayName: "Autofill Enabled",
+					properties: ["autofill.enabled"],
+					sortProperty: "autofill.enabled",
+					minWidth: 180,
+					defaultWidth: 180,
+					defaultVisibility: "hidden"
+				},
+				{
+					name: "autofillLimit",
+					displayName: "Autofill Limit",
+					properties: ["autofill.limit"],
+					sortProperty: "autofill.limit",
+					minWidth: 180,
+					defaultWidth: 180,
+					defaultVisibility: "hidden"
+				},
+				{
+					name: "autofillMode",
+					displayName: "Autofill Mode",
+					properties: ["autofill.mode"],
+					sortProperty: "autofill.mode",
+					minWidth: 180,
+					defaultWidth: 180,
 					defaultVisibility: "hidden"
 				}
 			],
@@ -307,46 +351,86 @@ export default {
 					defaultFilterType: "contains"
 				},
 				{
-					name: "stationMode",
-					displayName: "Station Mode",
-					property: "partyMode",
-					filterTypes: ["boolean"],
-					defaultFilterType: "boolean",
+					name: "theme",
+					displayName: "Theme",
+					property: "theme",
+					filterTypes: ["exact"],
+					defaultFilterType: "exact",
 					dropdown: [
-						[true, "Party"],
-						[false, "Playlist"]
+						["blue", "Blue"],
+						["purple", "Purple"],
+						["teal", "Teal"],
+						["orange", "Orange"],
+						["red", "Red"]
 					]
 				},
 				{
-					name: "playMode",
-					displayName: "Play Mode",
-					property: "playMode",
+					name: "requestsEnabled",
+					displayName: "Requests Enabled",
+					property: "requests.enabled",
+					filterTypes: ["boolean"],
+					defaultFilterType: "boolean"
+				},
+				{
+					name: "requestsAccess",
+					displayName: "Requests Access",
+					property: "requests.access",
 					filterTypes: ["exact"],
 					defaultFilterType: "exact",
 					dropdown: [
-						["random", "Random"],
-						["sequential", "Sequential"]
+						["owner", "Owner"],
+						["user", "User"]
 					]
 				},
 				{
-					name: "theme",
-					displayName: "Theme",
-					property: "theme",
+					name: "requestsLimit",
+					displayName: "Requests Limit",
+					property: "requests.limit",
+					filterTypes: [
+						"numberLesserEqual",
+						"numberLesser",
+						"numberGreater",
+						"numberGreaterEqual",
+						"numberEquals"
+					],
+					defaultFilterType: "numberLesser"
+				},
+				{
+					name: "autofillEnabled",
+					displayName: "Autofill Enabled",
+					property: "autofill.enabled",
+					filterTypes: ["boolean"],
+					defaultFilterType: "boolean"
+				},
+				{
+					name: "autofillLimit",
+					displayName: "Autofill Limit",
+					property: "autofill.limit",
+					filterTypes: [
+						"numberLesserEqual",
+						"numberLesser",
+						"numberGreater",
+						"numberGreaterEqual",
+						"numberEquals"
+					],
+					defaultFilterType: "numberLesser"
+				},
+				{
+					name: "autofillMode",
+					displayName: "Autofill Mode",
+					property: "autofill.mode",
 					filterTypes: ["exact"],
 					defaultFilterType: "exact",
 					dropdown: [
-						["blue", "Blue"],
-						["purple", "Purple"],
-						["teal", "Teal"],
-						["orange", "Orange"],
-						["red", "Red"]
+						["random", "Random"],
+						["sequential", "Sequential"]
 					]
 				}
 			],
 			events: {
 				adminRoom: "stations",
 				updated: {
-					event: "admin.station.updated",
+					event: "station.updated",
 					id: "station._id",
 					item: "station"
 				},
@@ -364,18 +448,11 @@ export default {
 		};
 	},
 	computed: {
-		...mapState("modalVisibility", {
-			modals: state => state.modals
-		}),
 		...mapGetters({
 			socket: "websockets/getSocket"
 		})
 	},
 	methods: {
-		edit(stationId) {
-			this.editingStationId = stationId;
-			this.openModal("manageStation");
-		},
 		remove(stationId) {
 			this.socket.dispatch(
 				"stations.remove",

+ 1 - 3
frontend/src/pages/Admin/Users/DataRequests.vue

@@ -62,12 +62,10 @@ import { mapGetters } from "vuex";
 import Toast from "toasters";
 
 import AdvancedTable from "@/components/AdvancedTable.vue";
-import QuickConfirm from "@/components/QuickConfirm.vue";
 
 export default {
 	components: {
-		AdvancedTable,
-		QuickConfirm
+		AdvancedTable
 	},
 	data() {
 		return {

+ 2 - 18
frontend/src/pages/Admin/Users/index.vue

@@ -93,32 +93,22 @@
 				</template>
 			</advanced-table>
 		</div>
-		<edit-user
-			v-if="modals.editUser"
-			:user-id="editingUserId"
-			sector="admin"
-		/>
 	</div>
 </template>
 
 <script>
-import { mapState, mapActions } from "vuex";
-import { defineAsyncComponent } from "vue";
+import { mapActions } from "vuex";
 
 import AdvancedTable from "@/components/AdvancedTable.vue";
 import ProfilePicture from "@/components/ProfilePicture.vue";
 
 export default {
 	components: {
-		EditUser: defineAsyncComponent(() =>
-			import("@/components/modals/EditUser.vue")
-		),
 		AdvancedTable,
 		ProfilePicture
 	},
 	data() {
 		return {
-			editingUserId: "",
 			columnDefault: {
 				sortable: true,
 				hidable: true,
@@ -304,18 +294,12 @@ export default {
 			}
 		};
 	},
-	computed: {
-		...mapState("modalVisibility", {
-			modals: state => state.modals
-		})
-	},
 	mounted() {
 		if (this.$route.query.userId) this.edit(this.$route.query.userId);
 	},
 	methods: {
 		edit(userId) {
-			this.editingUserId = userId;
-			this.openModal("editUser");
+			this.openModal({ modal: "editUser", data: { userId } });
 		},
 		...mapActions("modalVisibility", ["openModal"])
 	}

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

@@ -246,14 +246,10 @@ import { mapState, mapActions, mapGetters } from "vuex";
 
 import keyboardShortcuts from "@/keyboardShortcuts";
 
-import MainHeader from "@/components/layout/MainHeader.vue";
-import MainFooter from "@/components/layout/MainFooter.vue";
 import FloatingBox from "@/components/FloatingBox.vue";
 
 export default {
 	components: {
-		MainHeader,
-		MainFooter,
 		FloatingBox
 	},
 	data() {

+ 132 - 133
frontend/src/pages/Home.vue

@@ -76,7 +76,14 @@
 												v-if="isOwnerOrAdmin(element)"
 												class="material-icons manage-station"
 												@click.prevent="
-													manageStation(element._id)
+													openModal({
+														modal: 'manageStation',
+														data: {
+															stationId:
+																element._id,
+															sector: 'home'
+														}
+													})
 												"
 												content="Manage Station"
 												v-tippy
@@ -87,7 +94,14 @@
 												v-else
 												class="material-icons manage-station"
 												@click.prevent="
-													manageStation(element._id)
+													openModal({
+														modal: 'manageStation',
+														data: {
+															stationId:
+																element._id,
+															sector: 'home'
+														}
+													})
 												"
 												content="View Queue"
 												v-tippy
@@ -147,8 +161,12 @@
 														element.type ===
 														'official'
 													"
-													title="Musare"
-													>Musare</span
+													:title="
+														siteSettings.sitename
+													"
+													>{{
+														siteSettings.sitename
+													}}</span
 												>
 												<user-id-to-username
 													v-else
@@ -238,19 +256,13 @@
 									>No Songs Playing</span
 								>
 								<i
-									class="material-icons stationMode"
-									:content="
-										element.partyMode
-											? 'Station in Party mode'
-											: 'Station in Playlist mode'
-									"
+									v-if="canRequest(element)"
+									class="material-icons"
+									content="You can request songs in this station"
 									v-tippy="{ theme: 'info' }"
-									>{{
-										element.partyMode
-											? "emoji_people"
-											: "playlist_play"
-									}}</i
 								>
+									queue
+								</i>
 							</div>
 						</router-link>
 					</template>
@@ -329,7 +341,13 @@
 										v-if="isOwnerOrAdmin(station)"
 										class="material-icons manage-station"
 										@click.prevent="
-											manageStation(station._id)
+											openModal({
+												modal: 'manageStation',
+												data: {
+													stationId: station._id,
+													sector: 'home'
+												}
+											})
 										"
 										content="Manage Station"
 										v-tippy
@@ -340,7 +358,13 @@
 										v-else
 										class="material-icons manage-station"
 										@click.prevent="
-											manageStation(station._id)
+											openModal({
+												modal: 'manageStation',
+												data: {
+													stationId: station._id,
+													sector: 'home'
+												}
+											})
 										"
 										content="View Queue"
 										v-tippy
@@ -391,8 +415,8 @@
 									<span class="host">
 										<span
 											v-if="station.type === 'official'"
-											title="Musare"
-											>Musare</span
+											:title="siteSettings.sitename"
+											>{{ siteSettings.sitename }}</span
 										>
 										<user-id-to-username
 											v-else
@@ -466,19 +490,21 @@
 						>
 						<span v-else class="songTitle">No Songs Playing</span>
 						<i
-							class="material-icons stationMode"
-							:content="
-								station.partyMode
-									? 'Station in Party mode'
-									: 'Station in Playlist mode'
-							"
+							v-if="canRequest(station)"
+							class="material-icons"
+							content="You can request songs in this station"
+							v-tippy="{ theme: 'info' }"
+						>
+							queue
+						</i>
+						<i
+							v-else-if="canRequest(station, false)"
+							class="material-icons"
+							content="Login to request songs in this station"
 							v-tippy="{ theme: 'info' }"
-							>{{
-								station.partyMode
-									? "emoji_people"
-									: "playlist_play"
-							}}</i
 						>
+							queue
+						</i>
 					</div>
 				</router-link>
 				<h4 v-if="stations.length === 0">
@@ -487,56 +513,22 @@
 			</div>
 			<main-footer />
 		</div>
-		<create-station v-if="modals.createStation" />
-		<manage-station
-			v-if="modals.manageStation"
-			:station-id="editingStationId"
-			sector="home"
-		/>
-		<create-playlist v-if="modals.createPlaylist" />
-		<edit-playlist v-if="modals.editPlaylist" />
-		<edit-song v-if="modals.editSong" song-type="songs" sector="home" />
-		<report v-if="modals.report" />
 	</div>
 </template>
 
 <script>
 import { mapState, mapGetters, mapActions } from "vuex";
-import { defineAsyncComponent } from "vue";
 import draggable from "vuedraggable";
 import Toast from "toasters";
 
-import MainHeader from "@/components/layout/MainHeader.vue";
-import MainFooter from "@/components/layout/MainFooter.vue";
 import SongThumbnail from "@/components/SongThumbnail.vue";
-import UserIdToUsername from "@/components/UserIdToUsername.vue";
 
 import ws from "@/ws";
+import keyboardShortcuts from "@/keyboardShortcuts";
 
 export default {
 	components: {
-		MainHeader,
-		MainFooter,
 		SongThumbnail,
-		CreateStation: defineAsyncComponent(() =>
-			import("@/components/modals/CreateStation.vue")
-		),
-		ManageStation: defineAsyncComponent(() =>
-			import("@/components/modals/ManageStation/index.vue")
-		),
-		EditPlaylist: defineAsyncComponent(() =>
-			import("@/components/modals/EditPlaylist")
-		),
-		CreatePlaylist: defineAsyncComponent(() =>
-			import("@/components/modals/CreatePlaylist.vue")
-		),
-		Report: defineAsyncComponent(() =>
-			import("@/components/modals/Report.vue")
-		),
-		EditSong: defineAsyncComponent(() =>
-			import("@/components/modals/EditSong")
-		),
-		UserIdToUsername,
 		draggable
 	},
 	data() {
@@ -606,6 +598,9 @@ export default {
 	async mounted() {
 		this.siteSettings = await lofig.get("siteSettings");
 
+		if (this.$route.query.searchQuery)
+			this.searchQuery = this.$route.query.query;
+
 		if (
 			!this.loggedIn &&
 			this.$route.redirectedFrom &&
@@ -668,54 +663,19 @@ export default {
 			if (station) station.userCount = res.data.userCount;
 		});
 
-		this.socket.on("event:station.privacy.updated", res => {
-			const station = this.stations.find(
-				station => station._id === res.data.stationId
-			);
-
-			if (station) station.privacy = res.data.privacy;
-		});
-
-		this.socket.on("event:station.name.updated", res => {
-			const station = this.stations.find(
-				station => station._id === res.data.stationId
-			);
-
-			if (station) station.name = res.data.name;
-		});
-
-		this.socket.on("event:station.displayName.updated", res => {
-			const station = this.stations.find(
-				station => station._id === res.data.stationId
-			);
-
-			if (station) station.displayName = res.data.displayName;
-		});
-
-		this.socket.on("event:station.description.updated", res => {
-			const station = this.stations.find(
-				station => station._id === res.data.stationId
-			);
-
-			if (station) station.description = res.data.description;
-		});
-
-		this.socket.on("event:station.theme.updated", res => {
-			const { stationId, theme } = res.data;
-			const station = this.stations.find(
-				station => station._id === stationId
-			);
-
-			if (station) station.theme = theme;
-		});
+		this.socket.on("event:station.updated", res => {
+			const stationIndex = this.stations
+				.map(station => station._id)
+				.indexOf(res.data.station._id);
 
-		this.socket.on("event:station.partyMode.updated", res => {
-			const { stationId, partyMode } = res.data;
-			const station = this.stations.find(
-				station => station._id === stationId
-			);
+			if (stationIndex !== -1) {
+				this.stations[stationIndex] = {
+					...this.stations[stationIndex],
+					...res.data.station
+				};
 
-			if (station) station.partyMode = partyMode;
+				this.calculateFavoriteStations();
+			}
 		});
 
 		this.socket.on("event:station.nextSong", res => {
@@ -783,36 +743,68 @@ export default {
 		this.socket.on("event:user.orderOfFavoriteStations.updated", res => {
 			this.orderOfFavoriteStations = res.data.order;
 		});
+
+		if (this.isAdmin()) {
+			// ctrl + alt + f
+			keyboardShortcuts.registerShortcut("home.toggleAdminFilter", {
+				keyCode: 70,
+				ctrl: true,
+				alt: true,
+				handler: () => {
+					if (this.$route.query.adminFilter === undefined)
+						this.$router.push({
+							query: { ...this.$route.query, adminFilter: null }
+						});
+					else
+						this.$router.push({
+							query: {
+								...this.$route.query,
+								adminFilter: undefined
+							}
+						});
+				}
+			});
+		}
 	},
 	beforeUnmount() {
 		this.socket.dispatch("apis.leaveRoom", "home", () => {});
+
+		const shortcutNames = ["home.toggleAdminFilter"];
+
+		shortcutNames.forEach(shortcutName => {
+			keyboardShortcuts.unregisterShortcut(shortcutName);
+		});
 	},
 	methods: {
 		init() {
-			this.socket.dispatch("stations.index", res => {
-				this.stations = [];
+			this.socket.dispatch(
+				"stations.index",
+				this.$route.query.adminFilter === undefined,
+				res => {
+					this.stations = [];
 
-				if (res.status === "success") {
-					res.data.stations.forEach(station => {
-						const modifiableStation = station;
+					if (res.status === "success") {
+						res.data.stations.forEach(station => {
+							const modifiableStation = station;
 
-						if (!modifiableStation.currentSong)
-							modifiableStation.currentSong = {
-								thumbnail: "/assets/notes-transparent.png"
-							};
+							if (!modifiableStation.currentSong)
+								modifiableStation.currentSong = {
+									thumbnail: "/assets/notes-transparent.png"
+								};
 
-						if (
-							modifiableStation.currentSong &&
-							!modifiableStation.currentSong.thumbnail
-						)
-							modifiableStation.currentSong.ytThumbnail = `https://img.youtube.com/vi/${station.currentSong.youtubeId}/mqdefault.jpg`;
+							if (
+								modifiableStation.currentSong &&
+								!modifiableStation.currentSong.thumbnail
+							)
+								modifiableStation.currentSong.ytThumbnail = `https://img.youtube.com/vi/${station.currentSong.youtubeId}/mqdefault.jpg`;
 
-						this.stations.push(modifiableStation);
-					});
+							this.stations.push(modifiableStation);
+						});
 
-					this.orderOfFavoriteStations = res.data.favorited;
+						this.orderOfFavoriteStations = res.data.favorited;
+					}
 				}
-			});
+			);
 
 			this.socket.dispatch("apis.joinRoom", "home");
 		},
@@ -825,6 +817,17 @@ export default {
 		isOwnerOrAdmin(station) {
 			return this.isOwner(station) || this.isAdmin();
 		},
+		canRequest(station, requireLogin = true) {
+			return (
+				station &&
+				(!requireLogin || this.loggedIn) &&
+				station.requests &&
+				station.requests.enabled &&
+				(station.requests.access === "user" ||
+					(station.requests.access === "owner" &&
+						this.isOwnerOrAdmin(station)))
+			);
+		},
 		isPlaying(station) {
 			return typeof station.currentSong.title !== "undefined";
 		},
@@ -867,10 +870,6 @@ export default {
 				res => new Toast(res.message)
 			);
 		},
-		manageStation(stationId) {
-			this.editingStationId = stationId;
-			this.openModal("manageStation");
-		},
 		...mapActions("modalVisibility", ["openModal"]),
 		...mapActions("station", ["updateIfStationIsFavorited"])
 	}

+ 0 - 5
frontend/src/pages/News.vue

@@ -44,12 +44,7 @@ import { sanitize } from "dompurify";
 
 import ws from "@/ws";
 
-import MainHeader from "@/components/layout/MainHeader.vue";
-import MainFooter from "@/components/layout/MainFooter.vue";
-import UserIdToUsername from "@/components/UserIdToUsername.vue";
-
 export default {
-	components: { MainHeader, MainFooter, UserIdToUsername },
 	data() {
 		return {
 			news: []

+ 0 - 9
frontend/src/pages/Privacy.vue

@@ -202,12 +202,3 @@
 		<main-footer />
 	</div>
 </template>
-
-<script>
-import MainHeader from "@/components/layout/MainHeader.vue";
-import MainFooter from "@/components/layout/MainFooter.vue";
-
-export default {
-	components: { MainHeader, MainFooter }
-};
-</script>

+ 15 - 20
frontend/src/pages/Profile/Tabs/Playlists.vue

@@ -1,7 +1,5 @@
 <template>
 	<div class="content playlists-tab">
-		<create-playlist v-if="modals.createPlaylist" />
-
 		<div v-if="playlists.length > 0">
 			<h4 class="section-title">
 				{{ myUserId === userId ? "My" : null }}
@@ -49,7 +47,12 @@
 						<template #actions>
 							<i
 								v-if="myUserId === userId"
-								@click="showPlaylist(element._id)"
+								@click="
+									openModal({
+										modal: 'editPlaylist',
+										data: { playlistId: element._id }
+									})
+								"
 								class="material-icons edit-icon"
 								content="Edit Playlist"
 								v-tippy
@@ -57,7 +60,12 @@
 							>
 							<i
 								v-else
-								@click="showPlaylist(element._id)"
+								@click="
+									openModal({
+										modal: 'editPlaylist',
+										data: { playlistId: element._id }
+									})
+								"
 								class="material-icons view-icon"
 								content="View Playlist"
 								v-tippy
@@ -84,8 +92,7 @@
 </template>
 
 <script>
-import { mapActions, mapState, mapGetters } from "vuex";
-import { defineAsyncComponent } from "vue";
+import { mapActions, mapGetters } from "vuex";
 
 import PlaylistItem from "@/components/PlaylistItem.vue";
 import SortablePlaylists from "@/mixins/SortablePlaylists.vue";
@@ -93,10 +100,7 @@ import ws from "@/ws";
 
 export default {
 	components: {
-		PlaylistItem,
-		CreatePlaylist: defineAsyncComponent(() =>
-			import("@/components/modals/CreatePlaylist.vue")
-		)
+		PlaylistItem
 	},
 	mixins: [SortablePlaylists],
 	props: {
@@ -110,11 +114,6 @@ export default {
 		}
 	},
 	computed: {
-		...mapState({
-			...mapState("modalVisibility", {
-				modals: state => state.modals
-			})
-		}),
 		...mapGetters({
 			socket: "websockets/getSocket"
 		})
@@ -152,12 +151,8 @@ export default {
 		);
 	},
 	methods: {
-		showPlaylist(playlistId) {
-			this.editPlaylist(playlistId);
-			this.openModal("editPlaylist");
-		},
 		...mapActions("modalVisibility", ["openModal"]),
-		...mapActions("user/playlists", ["editPlaylist", "setPlaylists"])
+		...mapActions("user/playlists", ["setPlaylists"])
 	}
 };
 </script>

+ 1 - 5
frontend/src/pages/Profile/Tabs/RecentActivity.vue

@@ -45,10 +45,9 @@ import Toast from "toasters";
 
 import ActivityItem from "@/components/ActivityItem.vue";
 import ws from "@/ws";
-import QuickConfirm from "@/components/QuickConfirm.vue";
 
 export default {
-	components: { ActivityItem, QuickConfirm },
+	components: { ActivityItem },
 	props: {
 		userId: {
 			type: String,
@@ -67,9 +66,6 @@ export default {
 	},
 	computed: {
 		...mapState({
-			...mapState("modalVisibility", {
-				modals: state => state.modals
-			}),
 			myUserId: state => state.user.auth.userId
 		}),
 		...mapGetters({

+ 2 - 27
frontend/src/pages/Profile/index.vue

@@ -1,10 +1,5 @@
 <template>
 	<div v-if="isUser">
-		<edit-playlist v-if="modals.editPlaylist" />
-		<view-report v-if="modals.viewReport" />
-		<edit-song v-if="modals.editSong" song-type="songs" />
-		<report v-if="modals.report" />
-
 		<page-metadata :title="`Profile | ${user.username}`" />
 		<main-header />
 		<div class="container">
@@ -108,37 +103,20 @@
 <script>
 import { mapState, mapGetters } from "vuex";
 import { format, parseISO } from "date-fns";
-import { defineAsyncComponent } from "vue";
 import ws from "@/ws";
 
 import TabQueryHandler from "@/mixins/TabQueryHandler.vue";
 
 import ProfilePicture from "@/components/ProfilePicture";
-import MainHeader from "@/components/layout/MainHeader";
-import MainFooter from "@/components/layout/MainFooter.vue";
 
 import RecentActivity from "./Tabs/RecentActivity.vue";
 import Playlists from "./Tabs/Playlists.vue";
 
 export default {
 	components: {
-		MainHeader,
-		MainFooter,
 		ProfilePicture,
 		RecentActivity,
-		Playlists,
-		EditPlaylist: defineAsyncComponent(() =>
-			import("@/components/modals/EditPlaylist")
-		),
-		Report: defineAsyncComponent(() =>
-			import("@/components/modals/Report.vue")
-		),
-		ViewReport: defineAsyncComponent(() =>
-			import("@/components/modals/ViewReport.vue")
-		),
-		EditSong: defineAsyncComponent(() =>
-			import("@/components/modals/EditSong")
-		)
+		Playlists
 	},
 	mixins: [TabQueryHandler],
 	data() {
@@ -152,10 +130,7 @@ export default {
 	computed: {
 		...mapState({
 			role: state => state.user.auth.role,
-			myUserId: state => state.user.auth.userId,
-			...mapState("modalVisibility", {
-				modals: state => state.modals
-			})
+			myUserId: state => state.user.auth.userId
 		}),
 		...mapGetters({
 			socket: "websockets/getSocket"

+ 1 - 3
frontend/src/pages/ResetPassword.vue

@@ -285,14 +285,12 @@
 import Toast from "toasters";
 import { mapGetters, mapState } from "vuex";
 
-import MainHeader from "@/components/layout/MainHeader.vue";
-import MainFooter from "@/components/layout/MainFooter.vue";
 import InputHelpBox from "@/components/InputHelpBox.vue";
 
 import validation from "@/validation";
 
 export default {
-	components: { MainHeader, MainFooter, InputHelpBox },
+	components: { InputHelpBox },
 	props: {
 		mode: {
 			default: "reset",

+ 1 - 3
frontend/src/pages/Settings/Tabs/Account.vue

@@ -88,13 +88,11 @@ import Toast from "toasters";
 import InputHelpBox from "@/components/InputHelpBox.vue";
 import SaveButton from "@/components/SaveButton.vue";
 import validation from "@/validation";
-import QuickConfirm from "@/components/QuickConfirm.vue";
 
 export default {
 	components: {
 		InputHelpBox,
-		SaveButton,
-		QuickConfirm
+		SaveButton
 	},
 	data() {
 		return {

+ 4 - 3
frontend/src/pages/Settings/Tabs/Security.vue

@@ -101,7 +101,7 @@
 		<div v-if="!isGithubLinked">
 			<h4 class="section-title">Link your GitHub account</h4>
 			<p class="section-description">
-				Link your Musare account with GitHub
+				Link your {{ sitename }} account with GitHub
 			</p>
 
 			<hr class="section-horizontal-rule" />
@@ -172,13 +172,13 @@ import { mapGetters, mapState } from "vuex";
 
 import InputHelpBox from "@/components/InputHelpBox.vue";
 import validation from "@/validation";
-import QuickConfirm from "@/components/QuickConfirm.vue";
 
 export default {
-	components: { InputHelpBox, QuickConfirm },
+	components: { InputHelpBox },
 	data() {
 		return {
 			apiDomain: "",
+			sitename: "Musare",
 			validation: {
 				oldPassword: {
 					value: "",
@@ -224,6 +224,7 @@ export default {
 	},
 	async mounted() {
 		this.apiDomain = await lofig.get("backend.apiDomain");
+		this.sitename = await lofig.get("siteSettings.sitename");
 	},
 	methods: {
 		togglePasswordVisibility(ref) {

+ 1 - 14
frontend/src/pages/Settings/index.vue

@@ -42,26 +42,19 @@
 			</div>
 		</div>
 		<main-footer />
-
-		<remove-account v-if="modals.removeAccount" />
 	</div>
 </template>
 
 <script>
-import { mapActions, mapGetters, mapState } from "vuex";
+import { mapActions, mapGetters } from "vuex";
 import { defineAsyncComponent } from "vue";
 import Toast from "toasters";
 import ws from "@/ws";
 
 import TabQueryHandler from "@/mixins/TabQueryHandler.vue";
 
-import MainHeader from "@/components/layout/MainHeader.vue";
-import MainFooter from "@/components/layout/MainFooter.vue";
-
 export default {
 	components: {
-		MainHeader,
-		MainFooter,
 		SecuritySettings: defineAsyncComponent(() =>
 			import("./Tabs/Security.vue")
 		),
@@ -73,9 +66,6 @@ export default {
 		),
 		PreferencesSettings: defineAsyncComponent(() =>
 			import("./Tabs/Preferences.vue")
-		),
-		RemoveAccount: defineAsyncComponent(() =>
-			import("@/components/modals/RemoveAccount.vue")
 		)
 	},
 	mixins: [TabQueryHandler],
@@ -87,9 +77,6 @@ export default {
 	computed: {
 		...mapGetters({
 			socket: "websockets/getSocket"
-		}),
-		...mapState("modalVisibility", {
-			modals: state => state.modals
 		})
 	},
 	mounted() {

+ 43 - 94
frontend/src/pages/Station/Sidebar/Playlists.vue

@@ -17,7 +17,7 @@
 					<playlist-item :playlist="element" class="item-draggable">
 						<template #actions>
 							<i
-								v-if="isExcluded(element._id)"
+								v-if="isBlacklisted(element._id)"
 								class="material-icons stop-icon"
 								content="This playlist is blacklisted in this station"
 								v-tippy="{ theme: 'info' }"
@@ -26,35 +26,27 @@
 							<i
 								v-if="
 									station.type === 'community' &&
-									(isOwnerOrAdmin() || station.partyMode) &&
+									isOwnerOrAdmin() &&
 									!isSelected(element._id) &&
-									!isExcluded(element._id)
+									!isBlacklisted(element._id)
 								"
 								@click="selectPlaylist(element)"
 								class="material-icons play-icon"
-								:content="
-									station.partyMode
-										? 'Request songs from this playlist'
-										: 'Play songs from this playlist'
-								"
+								content="Request songs from this playlist"
 								v-tippy
 								>play_arrow</i
 							>
 							<quick-confirm
 								v-if="
 									station.type === 'community' &&
-									(isOwnerOrAdmin() || station.partyMode) &&
+									isOwnerOrAdmin() &&
 									isSelected(element._id)
 								"
 								@confirm="deselectPlaylist(element._id)"
 							>
 								<i
 									class="material-icons stop-icon"
-									:content="
-										station.partyMode
-											? 'Stop requesting songs from this playlist'
-											: 'Stop playing songs from this playlist'
-									"
+									content="Stop requesting songs from this playlist"
 									v-tippy
 									>stop</i
 								>
@@ -63,7 +55,7 @@
 								v-if="
 									station.type === 'community' &&
 									isOwnerOrAdmin() &&
-									!isExcluded(element._id)
+									!isBlacklisted(element._id)
 								"
 								@confirm="blacklistPlaylist(element._id)"
 							>
@@ -75,7 +67,12 @@
 								>
 							</quick-confirm>
 							<i
-								@click="edit(element._id)"
+								@click="
+									openModal({
+										modal: 'editPlaylist',
+										data: { playlistId: element._id }
+									})
+								"
 								class="material-icons edit-icon"
 								content="Edit Playlist"
 								v-tippy
@@ -107,17 +104,15 @@ import ws from "@/ws";
 
 import PlaylistItem from "@/components/PlaylistItem.vue";
 import SortablePlaylists from "@/mixins/SortablePlaylists.vue";
-import QuickConfirm from "@/components/QuickConfirm.vue";
 
 export default {
-	components: { PlaylistItem, QuickConfirm },
+	components: { PlaylistItem },
 	mixins: [SortablePlaylists],
 	computed: {
 		currentPlaylists() {
-			if (this.station.type === "community" && this.station.partyMode)
-				return this.partyPlaylists;
+			if (this.station.type === "community") return this.autoRequest;
 
-			return this.includedPlaylists;
+			return this.autofill;
 		},
 		...mapState({
 			role: state => state.user.auth.role,
@@ -125,9 +120,9 @@ export default {
 			loggedIn: state => state.user.auth.loggedIn
 		}),
 		...mapState("station", {
-			partyPlaylists: state => state.partyPlaylists,
-			includedPlaylists: state => state.includedPlaylists,
-			excludedPlaylists: state => state.excludedPlaylists,
+			autoRequest: state => state.autoRequest,
+			autofill: state => state.autofill,
+			blacklist: state => state.blacklist,
 			songsList: state => state.songsList
 		}),
 		...mapGetters({
@@ -137,38 +132,36 @@ export default {
 	mounted() {
 		ws.onConnect(this.init);
 
-		this.socket.on("event:station.includedPlaylist", res => {
+		this.socket.on("event:station.autofillPlaylist", res => {
 			const { playlist } = res.data;
-			const playlistIndex = this.includedPlaylists
-				.map(includedPlaylist => includedPlaylist._id)
+			const playlistIndex = this.autofill
+				.map(autofillPlaylist => autofillPlaylist._id)
 				.indexOf(playlist._id);
-			if (playlistIndex === -1) this.includedPlaylists.push(playlist);
+			if (playlistIndex === -1) this.autofill.push(playlist);
 		});
 
-		this.socket.on("event:station.excludedPlaylist", res => {
+		this.socket.on("event:station.blacklistedPlaylist", res => {
 			const { playlist } = res.data;
-			const playlistIndex = this.excludedPlaylists
-				.map(excludedPlaylist => excludedPlaylist._id)
+			const playlistIndex = this.blacklist
+				.map(blacklistedPlaylist => blacklistedPlaylist._id)
 				.indexOf(playlist._id);
-			if (playlistIndex === -1) this.excludedPlaylists.push(playlist);
+			if (playlistIndex === -1) this.blacklist.push(playlist);
 		});
 
-		this.socket.on("event:station.removedIncludedPlaylist", res => {
+		this.socket.on("event:station.removedAutofillPlaylist", res => {
 			const { playlistId } = res.data;
-			const playlistIndex = this.includedPlaylists
+			const playlistIndex = this.autofill
 				.map(playlist => playlist._id)
 				.indexOf(playlistId);
-			if (playlistIndex >= 0)
-				this.includedPlaylists.splice(playlistIndex, 1);
+			if (playlistIndex >= 0) this.autofill.splice(playlistIndex, 1);
 		});
 
-		this.socket.on("event:station.removedExcludedPlaylist", res => {
+		this.socket.on("event:station.removedBlacklistedPlaylist", res => {
 			const { playlistId } = res.data;
-			const playlistIndex = this.excludedPlaylists
+			const playlistIndex = this.blacklist
 				.map(playlist => playlist._id)
 				.indexOf(playlistId);
-			if (playlistIndex >= 0)
-				this.excludedPlaylists.splice(playlistIndex, 1);
+			if (playlistIndex >= 0) this.blacklist.splice(playlistIndex, 1);
 		});
 	},
 	methods: {
@@ -189,15 +182,10 @@ export default {
 		isOwnerOrAdmin() {
 			return this.isOwner() || this.isAdmin();
 		},
-		edit(id) {
-			this.editPlaylist(id);
-			this.openModal("editPlaylist");
-		},
 		selectPlaylist(playlist) {
-			if (this.station.type === "community" && this.station.partyMode) {
+			if (this.station.type === "community") {
 				if (!this.isSelected(playlist.id)) {
-					this.partyPlaylists.push(playlist);
-					this.addPartyPlaylistSongToQueue();
+					this.autoRequest.push(playlist);
 					new Toast(
 						"Successfully selected playlist to auto request songs."
 					);
@@ -206,7 +194,7 @@ export default {
 				}
 			} else {
 				this.socket.dispatch(
-					"stations.includePlaylist",
+					"stations.autofillPlaylist",
 					this.station._id,
 					playlist._id,
 					res => {
@@ -217,15 +205,12 @@ export default {
 		},
 		deselectPlaylist(id) {
 			return new Promise(resolve => {
-				if (
-					this.station.type === "community" &&
-					this.station.partyMode
-				) {
+				if (this.station.type === "community") {
 					let selected = false;
 					this.currentPlaylists.forEach((playlist, index) => {
 						if (playlist._id === id) {
 							selected = true;
-							this.partyPlaylists.splice(index, 1);
+							this.autoRequest.splice(index, 1);
 						}
 					});
 					if (selected) {
@@ -237,7 +222,7 @@ export default {
 					}
 				} else {
 					this.socket.dispatch(
-						"stations.removeIncludedPlaylist",
+						"stations.removeAutofillPlaylist",
 						this.station._id,
 						id,
 						res => {
@@ -255,9 +240,9 @@ export default {
 			});
 			return selected;
 		},
-		isExcluded(id) {
+		isBlacklisted(id) {
 			let selected = false;
-			this.excludedPlaylists.forEach(playlist => {
+			this.blacklist.forEach(playlist => {
 				if (playlist._id === id) selected = true;
 			});
 			return selected;
@@ -266,7 +251,7 @@ export default {
 			if (this.isSelected(id)) await this.deselectPlaylist(id);
 
 			this.socket.dispatch(
-				"stations.excludePlaylist",
+				"stations.blacklistPlaylist",
 				this.station._id,
 				id,
 				res => {
@@ -274,44 +259,8 @@ export default {
 				}
 			);
 		},
-		addPartyPlaylistSongToQueue() {
-			if (
-				this.station.type === "community" &&
-				this.station.partyMode === true &&
-				this.songsList.length < 50 &&
-				this.songsList.filter(
-					queueSong => queueSong.requestedBy === this.userId
-				).length < 3 &&
-				this.partyPlaylists
-			) {
-				const selectedPlaylist =
-					this.partyPlaylists[
-						Math.floor(Math.random() * this.partyPlaylists.length)
-					];
-				if (selectedPlaylist._id && selectedPlaylist.songs.length > 0) {
-					const selectedSong =
-						selectedPlaylist.songs[
-							Math.floor(
-								Math.random() * selectedPlaylist.songs.length
-							)
-						];
-					if (selectedSong.youtubeId) {
-						this.socket.dispatch(
-							"stations.addToQueue",
-							this.station._id,
-							selectedSong.youtubeId,
-							data => {
-								if (data.status !== "success")
-									this.addPartyPlaylistSongToQueue();
-							}
-						);
-					}
-				}
-			}
-		},
-		...mapActions("station", ["updatePartyPlaylists"]),
 		...mapActions("modalVisibility", ["openModal"]),
-		...mapActions("user/playlists", ["editPlaylist", "setPlaylists"])
+		...mapActions("user/playlists", ["setPlaylists"])
 	}
 };
 </script>

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov