Browse Source

Merge branch 'polishing' into dependabot/npm_and_yarn/frontend/nth-check-2.0.1

Kristian Vos 3 years ago
parent
commit
2db0730132
29 changed files with 696 additions and 395 deletions
  1. 2 0
      .env.example
  2. 19 4
      .wiki/Configuration.md
  3. 1 0
      backend/logic/actions/apis.js
  4. 39 0
      backend/logic/actions/playlists.js
  5. 20 8
      frontend/src/App.vue
  6. 1 0
      frontend/src/components/ActivityItem.vue
  7. 2 1
      frontend/src/components/FloatingBox.vue
  8. 12 4
      frontend/src/components/SearchQueryItem.vue
  9. 2 2
      frontend/src/components/modals/CreatePlaylist.vue
  10. 2 4
      frontend/src/components/modals/EditNews.vue
  11. 63 51
      frontend/src/components/modals/EditPlaylist/Tabs/AddSongs.vue
  12. 5 1
      frontend/src/components/modals/EditPlaylist/index.vue
  13. 1 1
      frontend/src/components/modals/EditSong/Tabs/Reports.vue
  14. 3 1
      frontend/src/components/modals/EditSong/index.vue
  15. 2 2
      frontend/src/components/modals/Login.vue
  16. 2 2
      frontend/src/components/modals/ManageStation/Tabs/Playlists.vue
  17. 1 1
      frontend/src/components/modals/ManageStation/Tabs/Settings.vue
  18. 53 34
      frontend/src/components/modals/ManageStation/Tabs/Songs.vue
  19. 3 1
      frontend/src/components/modals/ManageStation/index.vue
  20. 2 2
      frontend/src/components/modals/Register.vue
  21. 3 3
      frontend/src/mixins/ScrollAndFetchHandler.vue
  22. 19 1
      frontend/src/mixins/SearchMusare.vue
  23. 1 1
      frontend/src/mixins/SearchYoutube.vue
  24. 2 1
      frontend/src/pages/Admin/index.vue
  25. 51 4
      frontend/src/pages/Admin/tabs/Playlists.vue
  26. 242 221
      frontend/src/pages/ResetPassword.vue
  27. 74 0
      frontend/src/store/modules/admin.js
  28. 3 4
      frontend/src/store/modules/modals/editPlaylist.js
  29. 66 41
      musare.sh

+ 2 - 0
.env.example

@@ -17,3 +17,5 @@ REDIS_HOST=127.0.0.1
 REDIS_PORT=6379
 REDIS_PASSWORD=PASSWORD
 
+BACKUP_LOCATION=
+BACKUP_NAME=

+ 19 - 4
.wiki/Configuration.md

@@ -48,10 +48,25 @@ Location: `frontend/dist/config/default.json`
 ## Docker Environment
 Location: `.env`
 
+In the table below the container host refers to the IP address that the docker container listens on, setting this to `127.0.0.1` for example will only expose the configured port to localhost, whereas setting to `0.0.0.0` will expose the port on all interfaces.
+
+The container port refers to the external docker container port, used to access services within the container. Changing this does not require any changes to configuration within container. For example setting the `MONGO_PORT` to `21018` will allow you to access the mongo service through that port, even though the application within the container is listening on `21017`.
+
 | Property | Description |
 | --- | --- |
-| Ports | Will be how you access the services on your machine, or what ports you will need to specify in your nginx files when using proxy_pass. |
 | `COMPOSE_PROJECT_NAME` | Should be a unique name for this installation, especially if you have multiple instances of Musare on the same machine. |
-| `FRONTEND_MODE` | Should be either `dev` or `prod` (self-explanatory). |
-| `MONGO_ROOT_PASSWORD` | Password of the root/admin user of MongoDB |
-| `MONGO_USER_USERNAME` | Password for the "musare" user (what the backend uses) of MongoDB |
+| `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`. |
+| `MONGO_HOST` | Mongo container host. |
+| `MONGO_PORT` | Mongo container port. |
+| `MONGO_ROOT_PASSWORD` | Password of the root/admin user for MongoDB. |
+| `MONGO_USER_USERNAME` | Application username for MongoDB. |
+| `MONGO_USER_PASSWORD` | Application password for MongoDB. |
+| `REDIS_HOST` | Redis container host. |
+| `REDIS_PORT` | Redis container port. |
+| `REDIS_PASSWORD` | Redis password. |
+| `BACKUP_LOCATION` | Directory to store musare.sh backups. Defaults to `/backups` in script location. |
+| `BACKUP_NAME` | Name of musare.sh backup files. Defaults to `musare-$(date +"%Y-%m-%d-%s").dump`. |

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

@@ -187,6 +187,7 @@ export default {
 			page === "stations" ||
 			page === "reports" ||
 			page === "news" ||
+			page === "playlists" ||
 			page === "users" ||
 			page === "statistics" ||
 			page === "punishments"

+ 39 - 0
backend/logic/actions/playlists.js

@@ -26,6 +26,11 @@ CacheModule.runJob("SUB", {
 				room: `profile.${playlist.createdBy}.playlists`,
 				args: ["event:playlist.created", { data: { playlist } }]
 			});
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.playlists",
+			args: ["event:admin.playlist.created", { data: { playlist } }]
+		});
 	}
 });
 
@@ -42,6 +47,11 @@ CacheModule.runJob("SUB", {
 			room: `profile.${res.userId}.playlists`,
 			args: ["event:playlist.deleted", { data: { playlistId: res.playlistId } }]
 		});
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.playlists",
+			args: ["event:admin.playlist.deleted", { data: { playlistId: res.playlistId } }]
+		});
 	}
 });
 
@@ -87,6 +97,11 @@ CacheModule.runJob("SUB", {
 					}
 				]
 			});
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.playlists",
+			args: ["event:admin.playlist.song.added", { data: { playlistId: res.playlistId, song: res.song } }]
+		});
 	}
 });
 
@@ -117,6 +132,14 @@ CacheModule.runJob("SUB", {
 					}
 				]
 			});
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.playlists",
+			args: [
+				"event:admin.playlist.song.removed",
+				{ data: { playlistId: res.playlistId, youtubeId: res.youtubeId } }
+			]
+		});
 	}
 });
 
@@ -147,6 +170,14 @@ CacheModule.runJob("SUB", {
 					}
 				]
 			});
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.playlists",
+			args: [
+				"event:admin.playlist.displayName.updated",
+				{ data: { playlistId: res.playlistId, displayName: res.displayName } }
+			]
+		});
 	}
 });
 
@@ -163,6 +194,14 @@ CacheModule.runJob("SUB", {
 			});
 		});
 
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.playlists",
+			args: [
+				"event:admin.playlist.privacy.updated",
+				{ data: { playlistId: res.playlist._id, privacy: res.playlist.privacy } }
+			]
+		});
+
 		if (res.playlist.privacy === "public")
 			return WSModule.runJob("EMIT_TO_ROOM", {
 				room: `profile.${res.userId}.playlists`,

+ 20 - 8
frontend/src/App.vue

@@ -464,10 +464,6 @@ a {
 			0 10px 10px rgba(0, 0, 0, 0.22);
 		background-color: var(--white);
 
-		&:not([data-theme~="songActions"]) > .tippy-arrow::before {
-			border-top-color: var(--white);
-		}
-
 		.tippy-content {
 			color: var(--black);
 		}
@@ -607,7 +603,8 @@ a {
 		}
 	}
 
-	.play-icon {
+	.play-icon,
+	.added-to-playlist-icon {
 		color: var(--green);
 	}
 
@@ -1030,7 +1027,8 @@ h4.section-title {
 			}
 		}
 
-		.play-icon {
+		.play-icon,
+		.added-to-playlist-icon {
 			color: var(--green);
 		}
 
@@ -1094,7 +1092,7 @@ h4.section-title {
 	transition: all 0.3s ease;
 }
 
-.steps-fade-enter,
+.steps-fade-enter-from,
 .steps-fade-leave-to {
 	opacity: 0;
 }
@@ -1125,7 +1123,7 @@ h4.section-title {
 		min-height: 50px;
 		background-color: var(--white);
 		font-size: 30px;
-		cursor: pointer;
+		user-select: none;
 
 		&.selected {
 			background-color: var(--primary-color);
@@ -1143,6 +1141,20 @@ h4.section-title {
 	}
 }
 
+/* This class is used for content-box in ResetPassword, but not in RemoveAccount. This is because ResetPassword uses transitions and RemoveAccount does not */
+.content-box-wrapper {
+	position: relative;
+	width: 100%;
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+	min-height: 200px;
+
+	.content-box {
+		position: absolute;
+	}
+}
+
 .content-box {
 	margin-top: 90px;
 	border-radius: 3px;

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

@@ -4,6 +4,7 @@
 			<img
 				v-if="activity.payload.thumbnail"
 				:src="activity.payload.thumbnail"
+				onerror="this.src='/assets/notes.png'"
 				:alt="textOnlyMessage"
 			/>
 			<i class="material-icons activity-type-icon">{{ getIcon() }}</i>

+ 2 - 1
frontend/src/components/FloatingBox.vue

@@ -174,8 +174,9 @@ export default {
 	.box-body {
 		display: flex;
 		flex-wrap: wrap;
-		justify-content: space-evenly;
 		padding: 10px;
+		height: calc(100% - 24px); /* 24px is the height of the box-header */
+		overflow: auto;
 
 		span {
 			padding: 3px 6px;

+ 12 - 4
frontend/src/components/SearchQueryItem.vue

@@ -46,20 +46,28 @@ export default {
 </script>
 
 <style lang="scss">
-.search-query-actions-enter-active {
+.search-query-actions-enter-active,
+.musare-search-query-actions-enter-active,
+.youtube-search-query-actions-enter-active {
 	transition: all 0.2s ease;
 }
 
-.search-query-actions-leave-active {
+.search-query-actions-leave-active,
+.musare-search-query-actions-leave-active,
+.youtube-search-query-actions-leave-active {
 	transition: all 0.2s cubic-bezier(1, 0.5, 0.8, 1);
 }
 
-.search-query-actions-enter {
+.search-query-actions-enter,
+.musare-search-query-actions-enter,
+.youtube-search-query-actions-enter {
 	transform: translateX(-20px);
 	opacity: 0;
 }
 
-.search-query-actions-leave-to {
+.search-query-actions-leave-to,
+.musare-search-query-actions-leave-to,
+.youtube-search-query-actions-leave-to {
 	transform: translateX(20px);
 	opacity: 0;
 }

+ 2 - 2
frontend/src/components/modals/CreatePlaylist.vue

@@ -82,12 +82,12 @@ export default {
 					new Toast(res.message);
 
 					if (res.status === "success") {
+						this.closeModal("createPlaylist");
+
 						if (!window.addToPlaylistDropdown) {
 							this.editPlaylist(res.data.playlistId);
 							this.openModal("editPlaylist");
 						}
-
-						this.closeModal("createPlaylist");
 					}
 				}
 			);

+ 2 - 4
frontend/src/components/modals/EditNews.vue

@@ -46,10 +46,8 @@
 							:user-id="createdBy"
 							:alt="createdBy"
 							:link="true"
-						/>
-						&nbsp;
-					</span>
-					<span :title="new Date(createdAt)">
+						/> </span
+					>&nbsp;<span :title="new Date(createdAt)">
 						{{
 							formatDistance(createdAt, new Date(), {
 								addSuffix: true

+ 63 - 51
frontend/src/components/modals/EditPlaylist/Tabs/AddSongs.vue

@@ -24,25 +24,36 @@
 				class="song-query-results"
 			>
 				<song-item
-					v-for="song in musareSearch.results"
+					v-for="(song, index) in musareSearch.results"
 					:key="song._id"
 					:song="song"
-					disabled-actions="addToPlaylist"
 				>
 					<template #actions>
-						<add-to-playlist-dropdown
-							:song="{ youtubeId: song.songId }"
-							placement="top-end"
+						<transition
+							name="musare-search-query-actions"
+							mode="out-in"
 						>
-							<template #button>
-								<i
-									class="material-icons add-to-playlist-icon"
-									content="Add Song to Playlist"
-									v-tippy
-									>playlist_add</i
-								>
-							</template>
-						</add-to-playlist-dropdown>
+							<i
+								v-if="song.isAddedToQueue"
+								class="material-icons added-to-playlist-icon"
+								content="Song is already in playlist"
+								v-tippy
+								>done</i
+							>
+							<i
+								v-else
+								class="material-icons add-to-playlist-icon"
+								content="Add Song to Playlist"
+								v-tippy
+								@click="
+									addMusareSongToPlaylist(
+										song.youtubeId,
+										index
+									)
+								"
+								>playlist_add</i
+							>
+						</transition>
 					</template>
 				</song-item>
 
@@ -87,51 +98,33 @@
 				class="song-query-results"
 			>
 				<search-query-item
-					v-for="result in youtubeSearch.songs.results"
+					v-for="(result, index) in youtubeSearch.songs.results"
 					:key="result.id"
 					:result="result"
 				>
 					<template #actions>
-						<add-to-playlist-dropdown
-							:song="{ youtubeId: result.id }"
-							placement="top-end"
+						<transition
+							name="youtube-search-query-actions"
+							mode="out-in"
 						>
-							<template #button>
-								<i
-									class="material-icons add-to-playlist-icon"
-									content="Add Song to Playlist"
-									v-tippy
-									>playlist_add</i
-								>
-							</template>
-						</add-to-playlist-dropdown>
-						<!-- <transition name="search-query-actions" mode="out-in">
-							<a
-								class="button is-success"
+							<i
 								v-if="result.isAddedToQueue"
-								href="#"
-								key="added-to-playlist"
+								class="material-icons added-to-playlist-icon"
+								content="Song is already in playlist"
+								v-tippy
+								>done</i
 							>
-								<i class="material-icons icon-with-button"
-									>done</i
-								>
-								Added to playlist
-							</a>
-							<a
-								class="button is-dark"
+							<i
 								v-else
-								@click.prevent="
-									addSongToPlaylist(result.id, index)
+								class="material-icons add-to-playlist-icon"
+								content="Add Song to Playlist"
+								v-tippy
+								@click="
+									addYouTubeSongToPlaylist(result.id, index)
 								"
-								href="#"
-								key="add-to-playlist"
+								>playlist_add</i
 							>
-								<i class="material-icons icon-with-button"
-									>add</i
-								>
-								Add to playlist
-							</a>
-						</transition> -->
+						</transition>
 					</template>
 				</search-query-item>
 
@@ -154,11 +147,10 @@ import SearchYoutube from "@/mixins/SearchYoutube.vue";
 import SearchMusare from "@/mixins/SearchMusare.vue";
 
 import SongItem from "@/components/SongItem.vue";
-import AddToPlaylistDropdown from "@/components/AddToPlaylistDropdown.vue";
 import SearchQueryItem from "@/components/SearchQueryItem.vue";
 
 export default {
-	components: { SearchQueryItem, SongItem, AddToPlaylistDropdown },
+	components: { SearchQueryItem, SongItem },
 	mixins: [SearchYoutube, SearchMusare],
 	computed: {
 		...mapState("modals/editPlaylist", {
@@ -181,6 +173,16 @@ export default {
 				})
 			);
 		},
+		"musareSearch.results": function checkIfSongInPlaylist(songs) {
+			songs.forEach((searchItem, index) =>
+				this.playlist.songs.find(song => {
+					if (song._id === searchItem._id)
+						this.musareSearch.results[index].isAddedToQueue = true;
+
+					return song._id === searchItem._id;
+				})
+			);
+		},
 		"playlist.songs": function checkIfSongInPlaylist() {
 			this.youtubeSearch.songs.results.forEach((searchItem, index) =>
 				this.playlist.songs.find(song => {
@@ -195,6 +197,16 @@ export default {
 					return song.youtubeId === searchItem.id;
 				})
 			);
+			console.log(222);
+			this.musareSearch.results.forEach((searchItem, index) =>
+				this.playlist.songs.find(song => {
+					this.musareSearch.results[index].isAddedToQueue = false;
+					if (song.youtubeId === searchItem.youtubeId)
+						this.musareSearch.results[index].isAddedToQueue = true;
+
+					return song.youtubeId === searchItem.youtubeId;
+				})
+			);
 		}
 	}
 };

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

@@ -585,7 +585,9 @@ export default {
 		},
 		...mapActions({
 			showTab(dispatch, payload) {
-				this.$refs[`${payload}-tab`].scrollIntoView();
+				this.$refs[`${payload}-tab`].scrollIntoView({
+					block: "nearest"
+				});
 				return dispatch("modals/editPlaylist/showTab", payload);
 			}
 		}),
@@ -605,6 +607,8 @@ export default {
 .edit-playlist-modal {
 	.modal-card {
 		width: 1300px;
+		height: 100%;
+		overflow: auto;
 
 		.modal-card-body {
 			padding: 16px;

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

@@ -279,7 +279,7 @@ export default {
 	},
 	methods: {
 		showTab(tab) {
-			this.$refs[`${tab}-tab`].scrollIntoView();
+			this.$refs[`${tab}-tab`].scrollIntoView({ block: "nearest" });
 			this.tab = tab;
 		},
 		resolve(reportId) {

+ 3 - 1
frontend/src/components/modals/EditSong/index.vue

@@ -1566,7 +1566,9 @@ export default {
 		]),
 		...mapActions({
 			showTab(dispatch, payload) {
-				this.$refs[`${payload}-tab`].scrollIntoView();
+				this.$refs[`${payload}-tab`].scrollIntoView({
+					block: "nearest"
+				});
 				return dispatch("modals/editSong/showTab", payload);
 			}
 		}),

+ 2 - 2
frontend/src/components/modals/Login.vue

@@ -70,12 +70,12 @@
 							<router-link to="/terms" @click="closeLoginModal()">
 								Terms of Service
 							</router-link>
-							&nbsp;and
+							and
 							<router-link
 								to="/privacy"
 								@click="closeLoginModal()"
 							>
-								Privacy Policy </router-link
+								Privacy Policy</router-link
 							>.
 						</p>
 					</form>

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

@@ -740,7 +740,7 @@ export default {
 			);
 		},
 		showTab(tab) {
-			this.$refs[`${tab}-tab`].scrollIntoView();
+			this.$refs[`${tab}-tab`].scrollIntoView({ block: "nearest" });
 			this.tab = tab;
 		},
 		isOwner() {
@@ -885,8 +885,8 @@ export default {
 			this.search.searchedQuery = this.search.query;
 			this.socket.dispatch(action, query, page, res => {
 				const { data } = res;
-				const { count, pageSize, playlists } = data;
 				if (res.status === "success") {
+					const { count, pageSize, playlists } = data;
 					this.search.results = [
 						...this.search.results,
 						...playlists

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

@@ -568,7 +568,7 @@ export default {
 			font-size: 18px;
 			color: var(--white);
 			box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.25);
-			display: block;
+			display: flex;
 			text-align: center;
 			justify-content: center;
 			-ms-flex-align: center;

+ 53 - 34
frontend/src/components/modals/ManageStation/Tabs/Songs.vue

@@ -65,14 +65,33 @@
 							: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
+								<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
@@ -121,40 +140,32 @@
 						>
 							<template #actions>
 								<transition
-									name="search-query-actions"
+									name="youtube-search-query-actions"
 									mode="out-in"
 								>
-									<a
-										class="button is-success"
-										v-if="result.isAddedToQueue"
-										key="added-to-queue"
+									<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
-											class="
-												material-icons
-												icon-with-button
-											"
-											>done</i
-										>
-										Added to queue
-									</a>
-									<a
-										class="button is-dark"
+									<i
 										v-else
-										@click.prevent="
+										class="material-icons add-to-queue-icon"
+										@click="
 											addSongToQueue(result.id, index)
 										"
-										key="add-to-queue"
+										content="Add Song to Queue"
+										v-tippy
+										>queue</i
 									>
-										<i
-											class="
-												material-icons
-												icon-with-button
-											"
-											>add</i
-										>
-										Add to queue
-									</a>
 								</transition>
 							</template>
 						</search-query-item>
@@ -252,6 +263,13 @@ export default {
 		excludedSongIds() {
 			return this.excludedSongs.map(excludedSong => excludedSong._id);
 		},
+		songsInQueue() {
+			if (this.station.currentSong)
+				return this.songsList
+					.map(song => song.youtubeId)
+					.concat(this.station.currentSong.youtubeId);
+			return this.songsList.map(song => song.youtubeId);
+		},
 		...mapState({
 			loggedIn: state => state.user.auth.loggedIn,
 			userId: state => state.user.auth.userId,
@@ -261,6 +279,7 @@ export default {
 			parentTab: state => state.tab,
 			station: state => state.station,
 			originalStation: state => state.originalStation,
+			songsList: state => state.songsList,
 			excludedPlaylists: state => state.excludedPlaylists,
 			stationPlaylist: state => state.stationPlaylist
 		}),

+ 3 - 1
frontend/src/components/modals/ManageStation/index.vue

@@ -644,7 +644,9 @@ export default {
 		...mapActions({
 			showTab(dispatch, payload) {
 				if (this.$refs[`${payload}-tab`])
-					this.$refs[`${payload}-tab`].scrollIntoView(); // Only works if the ref exists, which it doesn't always
+					this.$refs[`${payload}-tab`].scrollIntoView({
+						block: "nearest"
+					}); // Only works if the ref exists, which it doesn't always
 				return dispatch("modals/manageStation/showTab", payload);
 			}
 		}),

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

@@ -104,12 +104,12 @@
 						<router-link to="/terms" @click="closeRegisterModal()">
 							Terms of Service
 						</router-link>
-						&nbsp;and
+						and
 						<router-link
 							to="/privacy"
 							@click="closeRegisterModal()"
 						>
-							Privacy Policy </router-link
+							Privacy Policy</router-link
 						>.
 					</p>
 				</section>

+ 3 - 3
frontend/src/mixins/ScrollAndFetchHandler.vue

@@ -1,8 +1,5 @@
 <script>
 export default {
-	setup() {
-		window.addEventListener("scroll", this.handleScroll);
-	},
 	data() {
 		return {
 			position: 1,
@@ -20,6 +17,9 @@ export default {
 			return this.maxPosition - 1;
 		}
 	},
+	mounted() {
+		window.addEventListener("scroll", this.handleScroll);
+	},
 	unmounted() {
 		clearInterval(this.interval);
 		window.removeEventListener("scroll", this.handleScroll);

+ 19 - 1
frontend/src/mixins/SearchMusare.vue

@@ -44,10 +44,15 @@ export default {
 					const { data } = res;
 					const { count, pageSize, songs } = data;
 
+					const newSongs = songs.map(song => ({
+						isAddedToQueue: false,
+						...song
+					}));
+
 					if (res.status === "success") {
 						this.musareSearch.results = [
 							...this.musareSearch.results,
-							...songs
+							...newSongs
 						];
 						this.musareSearch.page = page;
 						this.musareSearch.count = count;
@@ -64,6 +69,19 @@ export default {
 					}
 				}
 			);
+		},
+		addMusareSongToPlaylist(id, index) {
+			this.socket.dispatch(
+				"playlists.addSongToPlaylist",
+				false,
+				id,
+				this.playlist._id,
+				res => {
+					new Toast(res.message);
+					if (res.status === "success")
+						this.musareSearch.results[index].isAddedToQueue = true;
+				}
+			);
 		}
 	}
 };

+ 1 - 1
frontend/src/mixins/SearchYoutube.vue

@@ -79,7 +79,7 @@ export default {
 				}
 			);
 		},
-		addSongToPlaylist(id, index) {
+		addYouTubeSongToPlaylist(id, index) {
 			this.socket.dispatch(
 				"playlists.addSongToPlaylist",
 				false,

+ 2 - 1
frontend/src/pages/Admin/index.vue

@@ -218,7 +218,8 @@ export default {
 		showTab(tab) {
 			if (this.$refs[`${tab}-tab`])
 				this.$refs[`${tab}-tab`].scrollIntoView({
-					inline: "center"
+					inline: "center",
+					block: "nearest"
 				});
 			this.currentTab = tab;
 		}

+ 51 - 4
frontend/src/pages/Admin/tabs/Playlists.vue

@@ -115,19 +115,57 @@ export default {
 	},
 	data() {
 		return {
-			utils,
-			playlists: []
+			utils
 		};
 	},
 	computed: {
 		...mapState("modalVisibility", {
 			modals: state => state.modals
 		}),
+		...mapState("admin/playlists", {
+			playlists: state => state.playlists
+		}),
 		...mapGetters({
 			socket: "websockets/getSocket"
 		})
 	},
 	mounted() {
+		this.socket.on("event:admin.playlist.created", res =>
+			this.addPlaylist(res.data.playlist)
+		);
+
+		this.socket.on("event:admin.playlist.deleted", res =>
+			this.removePlaylist(res.data.playlistId)
+		);
+
+		this.socket.on("event:admin.playlist.song.added", res =>
+			this.addPlaylistSong({
+				playlistId: res.data.playlistId,
+				song: res.data.song
+			})
+		);
+
+		this.socket.on("event:admin.playlist.song.removed", res =>
+			this.removePlaylistSong({
+				playlistId: res.data.playlistId,
+				youtubeId: res.data.youtubeId
+			})
+		);
+
+		this.socket.on("event:admin.playlist.displayName.updated", res =>
+			this.updatePlaylistDisplayName({
+				playlistId: res.data.playlistId,
+				displayName: res.data.displayName
+			})
+		);
+
+		this.socket.on("event:admin.playlist.privacy.updated", res =>
+			this.updatePlaylistPrivacy({
+				playlistId: res.data.playlistId,
+				privacy: res.data.privacy
+			})
+		);
+
 		ws.onConnect(this.init);
 	},
 	methods: {
@@ -138,7 +176,7 @@ export default {
 		init() {
 			this.socket.dispatch("playlists.index", res => {
 				if (res.status === "success") {
-					this.playlists = res.data.playlists;
+					this.setPlaylists(res.data.playlists);
 					// if (this.$route.query.userId) {
 					// 	const user = this.users.find(
 					// 		user => user._id === this.$route.query.userId
@@ -221,7 +259,16 @@ export default {
 			);
 		},
 		...mapActions("modalVisibility", ["openModal"]),
-		...mapActions("user/playlists", ["editPlaylist"])
+		...mapActions("user/playlists", ["editPlaylist"]),
+		...mapActions("admin/playlists", [
+			"addPlaylist",
+			"setPlaylists",
+			"removePlaylist",
+			"addPlaylistSong",
+			"removePlaylistSong",
+			"updatePlaylistDisplayName",
+			"updatePlaylistPrivacy"
+		])
 	}
 };
 </script>

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

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

+ 74 - 0
frontend/src/store/modules/admin.js

@@ -188,6 +188,80 @@ const modules = {
 				});
 			}
 		}
+	},
+	playlists: {
+		namespaced: true,
+		state: {
+			playlists: []
+		},
+		getters: {},
+		actions: {
+			setPlaylists: ({ commit }, playlists) =>
+				commit("setPlaylists", playlists),
+			addPlaylist: ({ commit }, playlist) =>
+				commit("addPlaylist", playlist),
+			removePlaylist: ({ commit }, playlistId) =>
+				commit("removePlaylist", playlistId),
+			addPlaylistSong: ({ commit }, { playlistId, song }) =>
+				commit("addPlaylistSong", { playlistId, song }),
+			removePlaylistSong: ({ commit }, { playlistId, youtubeId }) =>
+				commit("removePlaylistSong", { playlistId, youtubeId }),
+			updatePlaylistDisplayName: (
+				{ commit },
+				{ playlistId, displayName }
+			) =>
+				commit("updatePlaylistDisplayName", {
+					playlistId,
+					displayName
+				}),
+			updatePlaylistPrivacy: ({ commit }, { playlistId, privacy }) =>
+				commit("updatePlaylistPrivacy", { playlistId, privacy })
+		},
+		mutations: {
+			setPlaylists(state, playlists) {
+				state.playlists = playlists;
+			},
+			addPlaylist(state, playlist) {
+				state.playlists.unshift(playlist);
+			},
+			removePlaylist(state, playlistId) {
+				state.playlists = state.playlists.filter(
+					playlist => playlist._id !== playlistId
+				);
+			},
+			addPlaylistSong(state, { playlistId, song }) {
+				state.playlists[
+					state.playlists.findIndex(
+						playlist => playlist._id === playlistId
+					)
+				].songs.push(song);
+			},
+			removePlaylistSong(state, { playlistId, youtubeId }) {
+				const playlistIndex = state.playlists.findIndex(
+					playlist => playlist._id === playlistId
+				);
+				state.playlists[playlistIndex].songs.splice(
+					state.playlists[playlistIndex].songs.findIndex(
+						song => song.youtubeId === youtubeId
+					),
+					1
+				);
+			},
+			updatePlaylistDisplayName(state, { playlistId, displayName }) {
+				state.playlists[
+					state.playlists.findIndex(
+						playlist => playlist._id === playlistId
+					)
+				].displayName = displayName;
+			},
+			updatePlaylistPrivacy(state, { playlistId, privacy }) {
+				state.playlists[
+					state.playlists.findIndex(
+						playlist => playlist._id === playlistId
+					)
+				].privacy = privacy;
+			}
+		}
 	}
 };
 

+ 3 - 4
frontend/src/store/modules/modals/editPlaylist.js

@@ -32,10 +32,9 @@ export default {
 			state.playlist.songs.push(song);
 		},
 		removeSong(state, youtubeId) {
-			state.playlist.songs.forEach((song, index) => {
-				if (song.youtubeId === youtubeId)
-					state.playlist.songs.splice(index, 1);
-			});
+			state.playlist.songs = state.playlist.songs.filter(
+				song => song.youtubeId !== youtubeId
+			);
 		},
 		updatePlaylistSongs(state, playlistSongs) {
 			state.playlist.songs = playlistSongs;

+ 66 - 41
musare.sh

@@ -38,56 +38,67 @@ handleServices()
     fi
 }
 
+dockerCommand()
+{
+    validCommands=(start stop restart build ps)
+    if [[ ${validCommands[*]} =~ (^|[[:space:]])"$2"($|[[:space:]]) ]]; then
+        servicesString=$(handleServices "${@:3}")
+        if [[ ${servicesString:0:1} == 1 ]]; then
+            if [[ ${servicesString:2:4} == "all" ]]; then
+                servicesString=""
+            else
+                servicesString=${servicesString:2}
+            fi
+            if [[ ${2} == "stop" || ${2} == "restart" ]]; then
+                # shellcheck disable=SC2086
+                docker-compose stop ${servicesString}
+            fi
+            if [[ ${2} == "start" || ${2} == "restart" ]]; then
+                # shellcheck disable=SC2086
+                docker-compose up -d ${servicesString}
+            fi
+            if [[ ${2} == "build" || ${2} == "ps" ]]; then
+                # shellcheck disable=SC2086
+                docker-compose "${2}" ${servicesString}
+            fi
+        else
+            echo -e "${RED}${servicesString:2}\n${YELLOW}Usage: ${1} restart [backend, frontend, mongo, redis]${NC}"
+        fi
+    else
+        echo -e "${RED}Error: Invalid dockerCommand input${NC}"
+    fi
+}
+
 if [[ -x "$(command -v docker)" && -x "$(command -v docker-compose)" ]]; then
     case $1 in
     start)
         echo -e "${CYAN}Musare | Start Services${NC}"
-        servicesString=$(handleServices "${@:2}")
-        if [[ ${servicesString:0:1} == 1 && ${servicesString:2:4} == "all" ]]; then
-            docker-compose up -d
-        elif [[ ${servicesString:0:1} == 1 ]]; then
-            docker-compose up -d ${servicesString:2}
-        else
-            echo -e "${RED}${servicesString:2}\n${YELLOW}Usage: $(basename "$0") start [backend, frontend, mongo, redis]${NC}"
-        fi
+        # shellcheck disable=SC2068
+        dockerCommand "$(basename "$0")" start ${@:2}
         ;;
 
     stop)
         echo -e "${CYAN}Musare | Stop Services${NC}"
-        servicesString=$(handleServices "${@:2}")
-        if [[ ${servicesString:0:1} == 1 && ${servicesString:2:4} == "all" ]]; then
-            docker-compose stop
-        elif [[ ${servicesString:0:1} == 1 ]]; then
-            docker-compose stop ${servicesString:2}
-        else
-            echo -e "${RED}${servicesString:2}\n${YELLOW}Usage: $(basename "$0") stop [backend, frontend, mongo, redis]${NC}"
-        fi
+        # shellcheck disable=SC2068
+        dockerCommand "$(basename "$0")" stop ${@:2}
         ;;
 
     restart)
         echo -e "${CYAN}Musare | Restart Services${NC}"
-        servicesString=$(handleServices "${@:2}")
-        if [[ ${servicesString:0:1} == 1 && ${servicesString:2:4} == "all" ]]; then
-            docker-compose stop
-            docker-compose up -d
-        elif [[ ${servicesString:0:1} == 1 ]]; then
-            docker-compose stop ${servicesString:2}
-            docker-compose up -d ${servicesString:2}
-        else
-            echo -e "${RED}${servicesString:2}\n${YELLOW}Usage: $(basename "$0") restart [backend, frontend, mongo, redis]${NC}"
-        fi
+        # shellcheck disable=SC2068
+        dockerCommand "$(basename "$0")" restart ${@:2}
         ;;
 
     build)
         echo -e "${CYAN}Musare | Build Services${NC}"
-        servicesString=$(handleServices "${@:2}")
-        if [[ ${servicesString:0:1} == 1 && ${servicesString:2:4} == "all" ]]; then
-            docker-compose build
-        elif [[ ${servicesString:0:1} == 1 ]]; then
-            docker-compose build ${servicesString:2}
-        else
-            echo -e "${RED}${servicesString:2}\n${YELLOW}Usage: $(basename "$0") build [backend, frontend, mongo, redis]${NC}"
-        fi
+        # shellcheck disable=SC2068
+        dockerCommand "$(basename "$0")" build ${@:2}
+        ;;
+
+    status)
+        echo -e "${CYAN}Musare | Service Status${NC}"
+        # shellcheck disable=SC2068
+        dockerCommand "$(basename "$0")" ps ${@:2}
         ;;
 
     reset)
@@ -109,10 +120,12 @@ if [[ -x "$(command -v docker)" && -x "$(command -v docker-compose)" ]]; then
                 echo -e "${RED}Cancelled reset${NC}"
             fi
         elif [[ ${servicesString:0:1} == 1 ]]; then
-            echo -e "${GREEN}Are you sure you want to reset all data for $(echo ${servicesString:2} | tr ' ' ',')? ${YELLOW}[y,n]: ${NC}"
+            echo -e "${GREEN}Are you sure you want to reset all data for $(echo "${servicesString:2}" | tr ' ' ',')? ${YELLOW}[y,n]: ${NC}"
             read -r confirm
             if [[ "${confirm}" == y* ]]; then
+                # shellcheck disable=SC2086
                 docker-compose stop ${servicesString:2}
+                # shellcheck disable=SC2086
                 docker-compose rm -v --force ${servicesString:2}
                 if [[ "${servicesString:2}" == *redis* && -d ".redis" ]]; then
                     rm -rf .redis
@@ -146,7 +159,7 @@ if [[ -x "$(command -v docker)" && -x "$(command -v docker-compose)" ]]; then
                     echo -e "${RED}Error: Mongo offline, please start to attach.${NC}"
                 else
                     echo -e "${YELLOW}Detach with CTRL+C${NC}"
-                    docker-compose exec mongo mongo musare -u ${MONGO_USER_USERNAME} -p ${MONGO_USER_PASSWORD}
+                    docker-compose exec mongo mongo musare -u "${MONGO_USER_USERNAME}" -p "${MONGO_USER_PASSWORD}"
                 fi
             else
                 echo -e "${RED}Error: .env does not exist${NC}"
@@ -221,12 +234,22 @@ if [[ -x "$(command -v docker)" && -x "$(command -v docker-compose)" ]]; then
         if [[ -f .env ]]; then
             # shellcheck disable=SC1091
             source .env
-            if [[ ! -d "${scriptLocation%x}/backups" ]]; then
-                echo -e "${YELLOW}Creating backup directory at ${scriptLocation%x}/backups${NC}"
-                mkdir "${scriptLocation%x}/backups"
+            if [[ -z "${BACKUP_LOCATION}" ]]; then
+                backupLocation="${scriptLocation%x}/backups"
+            else
+                backupLocation="${BACKUP_LOCATION%/}"
+            fi
+            if [[ ! -d "${backupLocation}" ]]; then
+                echo -e "${YELLOW}Creating backup directory at ${backupLocation}${NC}"
+                mkdir "${backupLocation}"
+            fi
+            if [[ -z "${BACKUP_NAME}" ]]; then
+                backupLocation="${backupLocation}/musare-$(date +"%Y-%m-%d-%s").dump"
+            else
+                backupLocation="${backupLocation}/${BACKUP_NAME}"
             fi
-            echo -e "${YELLOW}Creating backup at ${scriptLocation%x}/backups/musare-$(date +"%Y-%m-%d-%s").dump${NC}"
-            docker-compose exec -T mongo sh -c "mongodump --authenticationDatabase musare -u ${MONGO_USER_USERNAME} -p ${MONGO_USER_PASSWORD} -d musare --archive" > "${scriptLocation%x}/backups/musare-$(date +"%Y-%m-%d-%s").dump"
+            echo -e "${YELLOW}Creating backup at ${backupLocation}${NC}"
+            docker-compose exec -T mongo sh -c "mongodump --authenticationDatabase musare -u ${MONGO_USER_USERNAME} -p ${MONGO_USER_PASSWORD} -d musare --archive" > "${backupLocation}"
         else
             echo -e "${RED}Error: .env does not exist${NC}"
         fi
@@ -299,6 +322,7 @@ if [[ -x "$(command -v docker)" && -x "$(command -v docker-compose)" ]]; then
         echo -e "${YELLOW}start - Start services${NC}"
         echo -e "${YELLOW}stop - Stop services${NC}"
         echo -e "${YELLOW}restart - Restart services${NC}"
+        echo -e "${YELLOW}status - Service status${NC}"
         echo -e "${YELLOW}logs - View logs for services${NC}"
         echo -e "${YELLOW}update - Update Musare${NC}"
         echo -e "${YELLOW}attach [backend,mongo] - Attach to backend service or mongo shell${NC}"
@@ -317,6 +341,7 @@ if [[ -x "$(command -v docker)" && -x "$(command -v docker-compose)" ]]; then
         echo -e "${YELLOW}start - Start services${NC}"
         echo -e "${YELLOW}stop - Stop services${NC}"
         echo -e "${YELLOW}restart - Restart services${NC}"
+        echo -e "${YELLOW}status - Service status${NC}"
         echo -e "${YELLOW}logs - View logs for services${NC}"
         echo -e "${YELLOW}update - Update Musare${NC}"
         echo -e "${YELLOW}attach [backend,mongo] - Attach to backend service or mongo shell${NC}"