瀏覽代碼

Merge remote-tracking branch 'origin/polishing' into owen

Owen Diffey 3 年之前
父節點
當前提交
41ea4a09c4

+ 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`,

+ 9 - 9
backend/package-lock.json

@@ -10,7 +10,7 @@
       "license": "GPL-3.0",
       "dependencies": {
         "async": "^3.2.0",
-        "axios": "^0.21.1",
+        "axios": "^0.21.2",
         "bcrypt": "^5.0.1",
         "bluebird": "^3.7.2",
         "body-parser": "^1.19.0",
@@ -472,11 +472,11 @@
       "integrity": "sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw=="
     },
     "node_modules/axios": {
-      "version": "0.21.1",
-      "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
-      "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==",
+      "version": "0.21.2",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.2.tgz",
+      "integrity": "sha512-87otirqUw3e8CzHTMO+/9kh/FSgXt/eVDvipijwDtEuwbkySWZ9SBm6VEubmJ/kLKEoLQV/POhxXFb66bfekfg==",
       "dependencies": {
-        "follow-redirects": "^1.10.0"
+        "follow-redirects": "^1.14.0"
       }
     },
     "node_modules/balanced-match": {
@@ -4063,11 +4063,11 @@
       "integrity": "sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw=="
     },
     "axios": {
-      "version": "0.21.1",
-      "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
-      "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==",
+      "version": "0.21.2",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.2.tgz",
+      "integrity": "sha512-87otirqUw3e8CzHTMO+/9kh/FSgXt/eVDvipijwDtEuwbkySWZ9SBm6VEubmJ/kLKEoLQV/POhxXFb66bfekfg==",
       "requires": {
-        "follow-redirects": "^1.10.0"
+        "follow-redirects": "^1.14.0"
       }
     },
     "balanced-match": {

+ 1 - 1
backend/package.json

@@ -16,7 +16,7 @@
   },
   "dependencies": {
     "async": "^3.2.0",
-    "axios": "^0.21.1",
+    "axios": "^0.21.2",
     "bcrypt": "^5.0.1",
     "bluebird": "^3.7.2",
     "body-parser": "^1.19.0",

文件差異過大導致無法顯示
+ 335 - 300
frontend/package-lock.json


+ 20 - 20
frontend/package.json

@@ -17,44 +17,44 @@
     "prod": "npx webpack --config webpack.prod.js"
   },
   "devDependencies": {
-    "@babel/core": "^7.14.6",
-    "@babel/plugin-proposal-object-rest-spread": "^7.14.7",
+    "@babel/core": "^7.15.5",
+    "@babel/eslint-parser": "^7.15.7",
+    "@babel/plugin-proposal-object-rest-spread": "^7.15.6",
     "@babel/plugin-syntax-dynamic-import": "^7.8.3",
-    "@babel/plugin-transform-runtime": "^7.14.5",
-    "@babel/preset-env": "^7.14.7",
-    "@vue/compiler-sfc": "^3.1.4",
-    "@babel/eslint-parser": "^7.14.7",
+    "@babel/plugin-transform-runtime": "^7.15.0",
+    "@babel/preset-env": "^7.15.6",
+    "@vue/compiler-sfc": "^3.2.19",
     "babel-loader": "^8.2.2",
-    "css-loader": "^5.2.6",
-    "eslint": "^7.30.0",
+    "css-loader": "^5.2.7",
+    "eslint": "^7.32.0",
     "eslint-config-prettier": "^8.3.0",
+    "eslint-plugin-import": "^2.24.2",
+    "eslint-plugin-prettier": "^3.4.1",
+    "eslint-plugin-vue": "^7.18.0",
     "eslint-webpack-plugin": "^2.5.4",
-    "eslint-plugin-import": "^2.23.0",
-    "eslint-plugin-prettier": "^3.4.0",
-    "eslint-plugin-vue": "^7.13.0",
     "fetch": "^1.1.0",
     "node-sass": "^6.0.1",
     "prettier": "2.3.2",
     "sass-loader": "^12.1.0",
     "vue-style-loader": "^4.1.3",
-    "webpack-cli": "^4.7.2",
+    "webpack-cli": "^4.8.0",
     "webpack-dev-server": "^3.11.2"
   },
   "dependencies": {
-    "@babel/runtime": "^7.13.10",
+    "@babel/runtime": "^7.15.4",
     "config": "^3.3.6",
-    "date-fns": "^2.22.1",
-    "dompurify": "^2.3.0",
+    "date-fns": "^2.24.0",
+    "dompurify": "^2.3.3",
     "eslint-config-airbnb-base": "^14.2.1",
     "html-webpack-plugin": "^5.3.2",
     "marked": "^2.1.3",
     "toasters": "^2.3.1",
-    "vue": "^3.1.4",
+    "vue": "^3.2.19",
     "vue-content-loader": "^2.0.0",
-    "vue-loader": "^16.3.0",
-    "vue-router": "^4.0.10",
-    "vue-tippy": "^6.0.0-alpha.30",
-    "vuedraggable": "^4.0.1",
+    "vue-loader": "^16.8.1",
+    "vue-router": "^4.0.11",
+    "vue-tippy": "^6.0.0-alpha.33",
+    "vuedraggable": "^4.1.0",
     "vuex": "^4.0.2",
     "webpack": "5.38.0",
     "webpack-bundle-analyzer": "^4.4.2",

+ 20 - 8
frontend/src/App.vue

@@ -472,10 +472,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);
 		}
@@ -615,7 +611,8 @@ a {
 		}
 	}
 
-	.play-icon {
+	.play-icon,
+	.added-to-playlist-icon {
 		color: var(--green);
 	}
 
@@ -1038,7 +1035,8 @@ h4.section-title {
 			}
 		}
 
-		.play-icon {
+		.play-icon,
+		.added-to-playlist-icon {
 			color: var(--green);
 		}
 
@@ -1102,7 +1100,7 @@ h4.section-title {
 	transition: all 0.3s ease;
 }
 
-.steps-fade-enter,
+.steps-fade-enter-from,
 .steps-fade-leave-to {
 	opacity: 0;
 }
@@ -1133,7 +1131,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);
@@ -1151,6 +1149,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");
 					}
 				}
 			);

+ 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

@@ -598,7 +598,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);
 			}
 		}),
@@ -618,6 +620,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

@@ -1575,7 +1575,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

@@ -412,7 +412,7 @@ export default {
 			);
 		},
 		showTab(tab) {
-			this.$refs[`${tab}-tab`].scrollIntoView();
+			this.$refs[`${tab}-tab`].scrollIntoView({ block: "nearest" });
 			this.tab = tab;
 		},
 		isOwner() {
@@ -518,8 +518,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;

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

@@ -568,7 +568,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>

+ 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

@@ -224,7 +224,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

@@ -127,19 +127,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: {
@@ -150,7 +188,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
@@ -256,7 +294,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;

部分文件因文件數量過多而無法顯示