Browse Source

feat(DraggableFavoriteStations): initial implementation on homepage

Signed-off-by: Jonathan <theflametrooper@gmail.com>
Jonathan 3 năm trước cách đây
mục cha
commit
d558fafd2c

+ 30 - 16
backend/logic/actions/stations.js

@@ -419,18 +419,29 @@ export default {
 	 * @param {object} session - user session
 	 * @param {Function} cb - callback
 	 */
-	index(session, cb) {
+	async index(session, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" });
+
 		async.waterfall(
 			[
+				// get array of the ids of the user's favorite stations
 				next => {
-					CacheModule.runJob("HGETALL", { table: "stations" }, this).then(stations => next(null, stations));
+					if (session.userId)
+						return userModel.findById(session.userId).select({ favoriteStations: -1 }).exec(next);
+					return next(null, { favoriteStations: [] });
 				},
 
-				(items, next) => {
+				({ favoriteStations }, next) => {
+					CacheModule.runJob("HGETALL", { table: "stations" }, this).then(stations =>
+						next(null, stations, favoriteStations)
+					);
+				},
+
+				(stations, favorited, next) => {
 					const filteredStations = [];
 
 					async.eachLimit(
-						items,
+						stations,
 						1,
 						(station, nextStation) => {
 							async.waterfall(
@@ -438,15 +449,8 @@ export default {
 									callback => {
 										// only relevant if user logged in
 										if (session.userId) {
-											return StationsModule.runJob("HAS_USER_FAVORITED_STATION", {
-												userId: session.userId,
-												stationId: station._id
-											})
-												.then(isStationFavorited => {
-													station.isFavorited = isStationFavorited;
-													return callback();
-												})
-												.catch(err => callback(err));
+											if (favorited.indexOf(station._id) !== -1) station.isFavorited = true;
+											return callback();
 										}
 
 										return callback();
@@ -476,11 +480,11 @@ export default {
 								}
 							);
 						},
-						() => next(null, filteredStations)
+						() => next(null, filteredStations, favorited)
 					);
 				}
 			],
-			async (err, stations) => {
+			async (err, stations, favorited) => {
 				if (err) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 					this.log("ERROR", "STATIONS_INDEX", `Indexing stations failed. "${err}"`);
@@ -489,7 +493,7 @@ export default {
 
 				this.log("SUCCESS", "STATIONS_INDEX", `Indexing stations successful.`, false);
 
-				return cb({ status: "success", stations });
+				return cb({ status: "success", data: { stations, favorited } });
 			}
 		);
 	},
@@ -2344,6 +2348,7 @@ export default {
 	 */
 	remove: isOwnerRequired(async function remove(session, stationId, cb) {
 		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 
 		async.waterfall(
 			[
@@ -2370,6 +2375,15 @@ export default {
 							.then(() => {})
 							.catch(next);
 					next(null, station);
+				},
+
+				// remove reference to the station id in any array of a user's favorite stations
+				(station, next) => {
+					userModel.updateMany(
+						{ favoriteStations: stationId },
+						{ $pull: { favoriteStations: stationId } },
+						err => next(err, station)
+					);
 				}
 			],
 			async (err, station) => {

+ 71 - 0
backend/logic/actions/users.js

@@ -29,6 +29,17 @@ CacheModule.runJob("SUB", {
 	}
 });
 
+CacheModule.runJob("SUB", {
+	channel: "user.updateOrderOfFavoriteStations",
+	cb: res => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: res.userId }, this).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:user.orderOfFavoriteStations.changed", res.favoriteStations);
+			});
+		});
+	}
+});
+
 CacheModule.runJob("SUB", {
 	channel: "user.updateOrderOfPlaylists",
 	cb: res => {
@@ -675,6 +686,66 @@ export default {
 		);
 	}),
 
+	/**
+	 * Updates the order of a user's favorite stations
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Array} favoriteStations - array of station ids (with a specific order)
+	 * @param {Function} cb - gets called with the result
+	 */
+	updateOrderOfFavoriteStations: isLoginRequired(async function updateOrderOfFavoriteStations(
+		session,
+		favoriteStations,
+		cb
+	) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					userModel.updateOne(
+						{ _id: session.userId },
+						{ $set: { favoriteStations } },
+						{ runValidators: true },
+						next
+					);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"UPDATE_ORDER_OF_USER_FAVORITE_STATIONS",
+						`Couldn't update order of favorite stations for user "${session.userId}" to "${favoriteStations}". "${err}"`
+					);
+
+					return cb({ status: "failure", message: err });
+				}
+
+				CacheModule.runJob("PUB", {
+					channel: "user.updateOrderOfFavoriteStations",
+					value: {
+						favoriteStations,
+						userId: session.userId
+					}
+				});
+
+				this.log(
+					"SUCCESS",
+					"UPDATE_ORDER_OF_USER_FAVORITE_STATIONS",
+					`Updated order of favorite stations for user "${session.userId}" to "${favoriteStations}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Order of favorite stations successfully updated"
+				});
+			}
+		);
+	}),
+
 	/**
 	 * Updates the order of a user's playlists
 	 *

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

@@ -409,7 +409,7 @@ export default {
 		dragOptions() {
 			return {
 				animation: 200,
-				group: "description",
+				group: "songs",
 				disabled: !this.isEditable(),
 				ghostClass: "draggable-list-ghost"
 			};

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

@@ -18,7 +18,7 @@ export default {
 		dragOptions() {
 			return {
 				animation: 200,
-				group: "description",
+				group: "playlists",
 				disabled: this.myUserId !== this.userId,
 				ghostClass: "draggable-list-ghost"
 			};

+ 313 - 230
frontend/src/pages/Home.vue

@@ -50,153 +50,193 @@
 						<h2>My Favorites</h2>
 					</div>
 				</div>
-				<router-link
-					v-for="(station, index) in favoriteStations"
-					:key="index"
-					:to="{
-						name: 'station',
-						params: { id: station.name }
-					}"
-					class="card station-card"
-					:class="{
-						isPrivate: station.privacy === 'private',
-						isMine: isOwner(station)
-					}"
-					:style="'--primary-color: var(--' + station.theme + ')'"
+
+				<draggable
+					class="scrollable-list"
+					v-model="favoriteStations"
+					v-bind="dragOptions"
+					@start="drag = true"
+					@end="drag = false"
+					@change="changeFavoriteOrder"
 				>
-					<song-thumbnail
-						class="card-image"
-						:song="station.currentSong"
-					/>
-					<div class="card-content">
-						<div class="media">
-							<div class="media-left displayName">
-								<i
-									v-if="loggedIn && !station.isFavorited"
-									@click.prevent="favoriteStation(station)"
-									class="favorite material-icons"
-									content="Favorite Station"
-									v-tippy
-									>star_border</i
-								>
-								<i
-									v-if="loggedIn && station.isFavorited"
-									@click.prevent="unfavoriteStation(station)"
-									class="favorite material-icons"
-									content="Unfavorite Station"
-									v-tippy
-									>star</i
-								>
-								<h5>{{ station.displayName }}</h5>
-								<i
-									v-if="station.type === 'official'"
-									class="material-icons verified-station"
-									content="Verified Station"
-									v-tippy
-								>
-									check_circle
-								</i>
+					<transition-group
+						type="transition"
+						:name="!drag ? 'draggable-list-transition' : null"
+					>
+						<router-link
+							v-for="(station, index) in favoriteStations"
+							:key="`key-${index}`"
+							:to="{
+								name: 'station',
+								params: { id: station.name }
+							}"
+							:class="{
+								card: true,
+								'station-card': true,
+								'item-draggable': true,
+								isPrivate: station.privacy === 'private',
+								isMine: isOwner(station)
+							}"
+							:style="
+								'--primary-color: var(--' + station.theme + ')'
+							"
+						>
+							<song-thumbnail
+								class="card-image"
+								:song="station.currentSong"
+							/>
+							<div class="card-content">
+								<div class="media">
+									<div class="media-left displayName">
+										<i
+											v-if="
+												loggedIn && !station.isFavorited
+											"
+											@click.prevent="
+												favoriteStation(station)
+											"
+											class="favorite material-icons"
+											content="Favorite Station"
+											v-tippy
+											>star_border</i
+										>
+										<i
+											v-if="
+												loggedIn && station.isFavorited
+											"
+											@click.prevent="
+												unfavoriteStation(station)
+											"
+											class="favorite material-icons"
+											content="Unfavorite Station"
+											v-tippy
+											>star</i
+										>
+										<h5>{{ station.displayName }}</h5>
+										<i
+											v-if="station.type === 'official'"
+											class="material-icons verified-station"
+											content="Verified Station"
+											v-tippy
+										>
+											check_circle
+										</i>
+									</div>
+								</div>
+
+								<div class="content">
+									{{ station.description }}
+								</div>
+								<div class="under-content">
+									<p class="hostedBy">
+										Hosted by
+										<span class="host">
+											<span
+												v-if="
+													station.type === 'official'
+												"
+												title="Musare"
+												>Musare</span
+											>
+											<user-id-to-username
+												v-else
+												:user-id="station.owner"
+												:link="true"
+											/>
+										</span>
+									</p>
+									<div class="icons">
+										<i
+											v-if="
+												station.type === 'community' &&
+													isOwner(station)
+											"
+											class="homeIcon material-icons"
+											content="This is your station."
+											v-tippy
+											>home</i
+										>
+										<i
+											v-if="station.privacy === 'private'"
+											class="privateIcon material-icons"
+											content="This station is not visible to other users."
+											v-tippy
+											>lock</i
+										>
+										<i
+											v-if="
+												station.privacy === 'unlisted'
+											"
+											class="unlistedIcon material-icons"
+											content="Unlisted Station"
+											v-tippy
+											>link</i
+										>
+									</div>
+								</div>
 							</div>
-						</div>
-
-						<div class="content">
-							{{ station.description }}
-						</div>
-						<div class="under-content">
-							<p class="hostedBy">
-								Hosted by
-								<span class="host">
-									<span
-										v-if="station.type === 'official'"
-										title="Musare"
-										>Musare</span
-									>
-									<user-id-to-username
-										v-else
-										:user-id="station.owner"
-										:link="true"
-									/>
-								</span>
-							</p>
-							<div class="icons">
+							<div class="bottomBar">
 								<i
 									v-if="
-										station.type === 'community' &&
-											isOwner(station)
+										station.paused &&
+											station.currentSong.title
 									"
-									class="homeIcon material-icons"
-									content="This is your station."
+									class="material-icons"
+									content="Station Paused"
 									v-tippy
-									>home</i
+									>pause</i
 								>
 								<i
-									v-if="station.privacy === 'private'"
-									class="privateIcon material-icons"
-									content="This station is not visible to other users."
-									v-tippy
-									>lock</i
+									v-else-if="station.currentSong.title"
+									class="material-icons"
+									>music_note</i
+								>
+								<i v-else class="material-icons">music_off</i>
+								<span
+									v-if="station.currentSong.title"
+									class="songTitle"
+									:title="
+										station.currentSong.artists.length > 0
+											? 'Now Playing: ' +
+											  station.currentSong.title +
+											  ' by ' +
+											  station.currentSong.artists.join(
+													','
+											  )
+											: 'Now Playing: ' +
+											  station.currentSong.title
+									"
+									>{{ station.currentSong.title }}
+									{{
+										station.currentSong.artists.length > 0
+											? " by " +
+											  station.currentSong.artists.join(
+													","
+											  )
+											: ""
+									}}</span
+								>
+								<span v-else class="songTitle"
+									>No Songs Playing</span
 								>
 								<i
-									v-if="station.privacy === 'unlisted'"
-									class="unlistedIcon material-icons"
-									content="Unlisted Station"
+									class="material-icons stationMode"
+									:content="
+										station.partyMode
+											? 'Station in Party mode'
+											: 'Station in Playlist mode'
+									"
 									v-tippy
-									>link</i
+									>{{
+										station.partyMode
+											? "emoji_people"
+											: "playlist_play"
+									}}</i
 								>
 							</div>
-						</div>
-					</div>
-					<div class="bottomBar">
-						<i
-							v-if="station.paused && station.currentSong.title"
-							class="material-icons"
-							content="Station Paused"
-							v-tippy
-							>pause</i
-						>
-						<i
-							v-else-if="station.currentSong.title"
-							class="material-icons"
-							>music_note</i
-						>
-						<i v-else class="material-icons">music_off</i>
-						<span
-							v-if="station.currentSong.title"
-							class="songTitle"
-							:title="
-								station.currentSong.artists.length > 0
-									? 'Now Playing: ' +
-									  station.currentSong.title +
-									  ' by ' +
-									  station.currentSong.artists.join(',')
-									: 'Now Playing: ' +
-									  station.currentSong.title
-							"
-							>{{ station.currentSong.title }}
-							{{
-								station.currentSong.artists.length > 0
-									? " by " +
-									  station.currentSong.artists.join(",")
-									: ""
-							}}</span
-						>
-						<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-tippy
-							>{{
-								station.partyMode
-									? "emoji_people"
-									: "playlist_play"
-							}}</i
-						>
-					</div>
-				</router-link>
+						</router-link>
+					</transition-group>
+				</draggable>
 			</div>
 			<div class="group bottom">
 				<div class="group-title">
@@ -418,6 +458,7 @@
 
 <script>
 import { mapState, mapGetters, mapActions } from "vuex";
+import draggable from "vuedraggable";
 import Toast from "toasters";
 
 import MainHeader from "@/components/layout/MainHeader.vue";
@@ -434,14 +475,18 @@ export default {
 		SongThumbnail,
 		CreateCommunityStation: () =>
 			import("@/components/modals/CreateCommunityStation.vue"),
-		UserIdToUsername
+		UserIdToUsername,
+		draggable
 	},
 	data() {
 		return {
 			recaptcha: { key: "" },
 			stations: [],
+			favoriteStations: [],
 			searchQuery: "",
-			siteName: "Musare"
+			siteName: "Musare",
+			orderOfFavoriteStations: [],
+			drag: false
 		};
 	},
 	computed: {
@@ -472,10 +517,18 @@ export default {
 						b.userCount - a.userCount
 				);
 		},
-		favoriteStations() {
-			return this.filteredStations.filter(
-				station => station.isFavorited === true
-			);
+		dragOptions() {
+			return {
+				animation: 200,
+				group: "favoriteStations",
+				disabled: false,
+				ghostClass: "draggable-list-ghost"
+			};
+		}
+	},
+	watch: {
+		orderOfFavoriteStations() {
+			this.calculateFavoriteStations();
 		}
 	},
 	async mounted() {
@@ -504,135 +557,139 @@ export default {
 			}
 		});
 
-		this.socket.on("event:station.removed", response => {
-			const { stationId } = response;
+		this.socket.on("event:station.removed", res => {
+			const { stationId } = res;
 			const station = this.stations.find(
 				station => station._id === stationId
 			);
+
 			if (station) {
 				const stationIndex = this.stations.indexOf(station);
 				this.stations.splice(stationIndex, 1);
+
+				if (station.isFavorited)
+					this.orderOfFavoriteStations.filter(
+						favoritedId => favoritedId !== stationId
+					);
 			}
 		});
 
 		this.socket.on("event:userCount.updated", (stationId, userCount) => {
-			this.stations.forEach(s => {
-				const station = s;
-				if (station._id === stationId) {
-					station.userCount = userCount;
-				}
-			});
+			const station = this.stations.find(
+				station => station._id === stationId
+			);
+
+			if (station) station.userCount = userCount;
 		});
 
-		this.socket.on("event:station.updatePrivacy", response => {
-			const { stationId, privacy } = response;
-			this.stations.forEach(s => {
-				const station = s;
-				if (station._id === stationId) {
-					station.privacy = privacy;
-				}
-			});
+		this.socket.on("event:station.updatePrivacy", res => {
+			const station = this.stations.find(
+				station => station._id === res.stationId
+			);
+
+			if (station) station.privacy = res.privacy;
 		});
 
-		this.socket.on("event:station.updateName", response => {
-			const { stationId, name } = response;
-			this.stations.forEach(s => {
-				const station = s;
-				if (station._id === stationId) {
-					station.name = name;
-				}
-			});
+		this.socket.on("event:station.updateName", res => {
+			const station = this.stations.find(
+				station => station._id === res.stationId
+			);
+
+			if (station) station.name = res.name;
 		});
 
-		this.socket.on("event:station.updateDisplayName", response => {
-			const { stationId, displayName } = response;
-			this.stations.forEach(s => {
-				const station = s;
-				if (station._id === stationId) {
-					station.displayName = displayName;
-				}
-			});
+		this.socket.on("event:station.updateDisplayName", res => {
+			const station = this.stations.find(
+				station => station._id === res.stationId
+			);
+
+			if (station) station.displayName = res.displayName;
 		});
 
-		this.socket.on("event:station.updateDescription", response => {
-			const { stationId, description } = response;
-			this.stations.forEach(s => {
-				const station = s;
-				if (station._id === stationId) {
-					station.description = description;
-				}
-			});
+		this.socket.on("event:station.updateDescription", res => {
+			const station = this.stations.find(
+				station => station._id === res.stationId
+			);
+
+			if (station) station.description = res.description;
 		});
 
-		this.socket.on("event:station.updateTheme", response => {
-			const { stationId, theme } = response;
-			this.stations.forEach(s => {
-				const station = s;
-				if (station._id === stationId) {
-					station.theme = theme;
-				}
-			});
+		this.socket.on("event:station.updateTheme", res => {
+			const station = this.stations.find(
+				station => station._id === res.stationId
+			);
+
+			if (station) station.theme = res.theme;
 		});
 
 		this.socket.on("event:station.nextSong", (stationId, song) => {
-			let newSong = song;
-			this.stations.forEach(s => {
-				const station = s;
-				if (station._id === stationId) {
-					if (!newSong)
-						newSong = {
-							thumbnail: "/assets/notes-transparent.png"
-						};
-					station.currentSong = newSong;
-				}
-			});
+			const station = this.stations.find(
+				station => station._id === stationId
+			);
+
+			if (station) {
+				let newSong = song;
+
+				if (!newSong)
+					newSong = {
+						thumbnail: "/assets/notes-transparent.png"
+					};
+
+				station.currentSong = newSong;
+			}
 		});
 
-		this.socket.on("event:station.pause", response => {
-			const { stationId } = response;
-			this.stations.forEach(s => {
-				const station = s;
-				if (station._id === stationId) {
-					station.paused = true;
-				}
-			});
+		this.socket.on("event:station.pause", res => {
+			const station = this.stations.find(
+				station => station._id === res.stationId
+			);
+
+			if (station) station.paused = true;
 		});
 
-		this.socket.on("event:station.resume", response => {
-			const { stationId } = response;
-			this.stations.forEach(s => {
-				const station = s;
-				if (station._id === stationId) {
-					station.paused = false;
-				}
-			});
+		this.socket.on("event:station.resume", res => {
+			const station = this.stations.find(
+				station => station._id === res.stationId
+			);
+
+			if (station) station.paused = false;
 		});
 
 		this.socket.on("event:user.favoritedStation", stationId => {
-			this.stations.forEach(s => {
-				const station = s;
-				if (station._id === stationId) {
-					station.isFavorited = true;
-				}
-			});
+			const station = this.stations.find(
+				station => station._id === stationId
+			);
+
+			if (station) {
+				station.isFavorited = true;
+				this.orderOfFavoriteStations.push(stationId);
+			}
 		});
 
 		this.socket.on("event:user.unfavoritedStation", stationId => {
-			this.stations.forEach(s => {
-				const station = s;
-				if (station._id === stationId) {
-					station.isFavorited = false;
-				}
-			});
+			const station = this.stations.find(
+				station => station._id === stationId
+			);
+
+			if (station) {
+				station.isFavorited = false;
+				this.orderOfFavoriteStations = this.orderOfFavoriteStations.filter(
+					favoritedId => favoritedId !== stationId
+				);
+			}
+		});
+
+		this.socket.on("event:user.orderOfFavoriteStations.changed", order => {
+			this.orderOfFavoriteStations = order;
 		});
 	},
 	methods: {
 		init() {
-			this.socket.dispatch("stations.index", data => {
+			this.socket.dispatch("stations.index", res => {
 				this.stations = [];
 
-				if (data.status === "success")
-					data.stations.forEach(station => {
+				if (res.status === "success") {
+					res.data.stations.forEach(station => {
 						const modifiableStation = station;
 
 						if (!modifiableStation.currentSong)
@@ -648,6 +705,9 @@ export default {
 
 						this.stations.push(modifiableStation);
 					});
+
+					this.orderOfFavoriteStations = res.data.favorited;
+				}
 			});
 
 			this.socket.dispatch("apis.joinRoom", "home", () => {});
@@ -680,6 +740,29 @@ export default {
 				}
 			);
 		},
+		calculateFavoriteStations() {
+			this.favoriteStations = this.filteredStations
+				.filter(station => station.isFavorited === true)
+				.sort(
+					(a, b) =>
+						this.orderOfFavoriteStations.indexOf(a._id) -
+						this.orderOfFavoriteStations.indexOf(b._id)
+				);
+		},
+		changeFavoriteOrder() {
+			const recalculatedOrder = [];
+			this.favoriteStations.forEach(station =>
+				recalculatedOrder.push(station._id)
+			);
+
+			this.socket.dispatch(
+				"users.updateOrderOfFavoriteStations",
+				recalculatedOrder,
+				res => {
+					return new Toast(res.message);
+				}
+			);
+		},
 		...mapActions("modalVisibility", ["openModal"]),
 		...mapActions("station", ["updateIfStationIsFavorited"])
 	}