Browse Source

feat: Toggle whether a playlist is featured in Edit Playlist modal

Owen Diffey 11 months ago
parent
commit
c5fa972a76

+ 0 - 1
.wiki/Configuration.md

@@ -134,7 +134,6 @@ For more information on configuration files please refer to the
 | `mongo.port` | MongoDB port. |
 | `mongo.database` | MongoDB database name. |
 | `blacklistedCommunityStationNames` | Array of blacklisted community station names. |
-| `featuredPlaylists` | Array of featured playlist id's. Playlist privacy must be public. |
 | `messages.accountRemoval` | Message to display to users when they request their account to be removed. |
 | `siteSettings.christmas` | Whether to enable christmas theme. |
 | `footerLinks` | Add custom links to footer by specifying `"title": "url"`, e.g. `"GitHub": "https://github.com/Musare/Musare"`. You can disable about, team and news links (but not the pages themselves) by setting them to false, e.g. `"about": false`. |

+ 0 - 1
backend/config/default.json

@@ -92,7 +92,6 @@
 		"database": "musare"
 	},
 	"blacklistedCommunityStationNames": ["musare"],
-	"featuredPlaylists": [],
 	"messages": {
 		"accountRemoval": "Your account will be deactivated instantly and your data will shortly be deleted by an admin."
 	},

+ 98 - 38
backend/logic/actions/playlists.js

@@ -300,7 +300,7 @@ CacheModule.runJob("SUB", {
 
 		playlistModel.findOne(
 			{ _id: data.playlistId },
-			["_id", "displayName", "type", "privacy", "songs", "createdBy", "createdAt", "createdFor"],
+			["_id", "displayName", "type", "privacy", "songs", "createdBy", "createdAt", "createdFor", "featured"],
 			(err, playlist) => {
 				const newPlaylist = {
 					...playlist._doc,
@@ -764,39 +764,16 @@ export default {
 	}),
 
 	/**
-	 * Gets all playlists playlists
+	 * Fetch 3 featured playlists at random
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {Function} cb - gets called with the result
 	 */
 	indexFeaturedPlaylists: isLoginRequired(async function indexMyPlaylists(session, cb) {
-		async.waterfall(
-			[
-				next => {
-					const featuredPlaylistIds = config.get("featuredPlaylists");
-					if (featuredPlaylistIds.length === 0) next(true, []);
-					else next(null, featuredPlaylistIds);
-				},
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
 
-				(featuredPlaylistIds, next) => {
-					const featuredPlaylists = [];
-					async.eachLimit(
-						featuredPlaylistIds,
-						1,
-						(playlistId, next) => {
-							PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
-								.then(playlist => {
-									if (playlist.privacy === "public") featuredPlaylists.push(playlist);
-									next();
-								})
-								.catch(next);
-						},
-						err => {
-							next(err, featuredPlaylists);
-						}
-					);
-				}
-			],
-			async (err, playlists) => {
+		playlistModel
+			.aggregate([{ $match: { featured: true } }, { $sample: { size: 3 } }])
+			.exec(async (err, playlists) => {
 				if (err && err !== true) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 					this.log("ERROR", "PLAYLIST_INDEX_FEATURED", `Indexing featured playlists failed. "${err}"`);
@@ -807,8 +784,7 @@ export default {
 					status: "success",
 					data: { playlists }
 				});
-			}
-		);
+			});
 	}),
 
 	/**
@@ -2848,9 +2824,12 @@ export default {
 		async.waterfall(
 			[
 				next => {
+					const update = { $set: { privacy } };
+					if (privacy !== "public") update.$set.featured = false;
+
 					playlistModel.updateOne(
 						{ _id: playlistId, createdBy: session.userId },
-						{ $set: { privacy } },
+						update,
 						{ runValidators: true },
 						next
 					);
@@ -2930,12 +2909,10 @@ export default {
 			async.waterfall(
 				[
 					next => {
-						playlistModel.updateOne(
-							{ _id: playlistId },
-							{ $set: { privacy } },
-							{ runValidators: true },
-							next
-						);
+						const update = { $set: { privacy } };
+						if (privacy !== "public") update.$set.featured = false;
+
+						playlistModel.updateOne({ _id: playlistId }, update, { runValidators: true }, next);
 					},
 
 					(res, next) => {
@@ -2991,6 +2968,89 @@ export default {
 		}
 	),
 
+	/**
+	 * Updates whether a playlist is featured
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} playlistId - the id of the playlist we are updating
+	 * @param {boolean} featured - whether playlist is featured
+	 * @param {Function} cb - gets called with the result
+	 */
+	updateFeatured: useHasPermission(
+		"playlists.update.featured",
+		async function updateFeatured(session, playlistId, featured, cb) {
+			const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+			async.waterfall(
+				[
+					next => {
+						PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+							.then(playlist => next(null, playlist))
+							.catch(next);
+					},
+
+					(playlist, next) => {
+						if (playlist.type === "station") next("Station playlists can not be featured.");
+						else if (playlist.privacy !== "public") next("Only public playlists can be featured.");
+						else next();
+					},
+
+					next => {
+						playlistModel.updateOne(
+							{ _id: playlistId },
+							{ $set: { featured } },
+							{ runValidators: true },
+							next
+						);
+					},
+
+					(res, next) => {
+						if (res.n === 0) next("No playlist found with that id.");
+						else if (res.nModified === 0)
+							next(`Nothing changed, the playlist was already ${featured ? "true" : "false"}.`);
+						else {
+							PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
+								.then(() => next())
+								.catch(next);
+						}
+					}
+				],
+				async err => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+						this.log(
+							"ERROR",
+							"PLAYLIST_UPDATE_FEATURED",
+							`Updating featured to "${
+								featured ? "true" : "false"
+							}" for playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+						);
+
+						return cb({ status: "error", message: err });
+					}
+
+					this.log(
+						"SUCCESS",
+						"PLAYLIST_UPDATE_FEATURED",
+						`Successfully updated featured to "${
+							featured ? "true" : "false"
+						}" for playlist "${playlistId}" for user "${session.userId}".`
+					);
+
+					CacheModule.runJob("PUB", {
+						channel: "playlist.updated",
+						value: { playlistId }
+					});
+
+					return cb({
+						status: "success",
+						message: "Playlist has been successfully updated"
+					});
+				}
+			);
+		}
+	),
+
 	/**
 	 * Deletes all orphaned station playlists
 	 * @param {object} session - the session object automatically added by socket.io

+ 1 - 0
backend/logic/db/schemas/playlist.js

@@ -26,5 +26,6 @@ export default {
 			replacedAt: { type: Date, required: true }
 		}
 	],
+	featured: { type: Boolean, default: false },
 	documentVersion: { type: Number, default: 7, required: true }
 };

+ 1 - 0
backend/logic/hooks/hasPermission.js

@@ -43,6 +43,7 @@ permissions.moderator = {
 	"playlists.create.admin": true,
 	"playlists.get": true,
 	"playlists.update.displayName": true,
+	"playlists.update.featured": true,
 	"playlists.update.privacy": true,
 	"playlists.songs.add": true,
 	"playlists.songs.remove": true,

+ 11 - 1
backend/logic/playlists.js

@@ -42,7 +42,17 @@ class _PlaylistsModule extends CoreClass {
 			cb: async data => {
 				PlaylistsModule.playlistModel.findOne(
 					{ _id: data.playlistId },
-					["_id", "displayName", "type", "privacy", "songs", "createdBy", "createdAt", "createdFor"],
+					[
+						"_id",
+						"displayName",
+						"type",
+						"privacy",
+						"songs",
+						"createdBy",
+						"createdAt",
+						"createdFor",
+						"featured"
+					],
 					(err, playlist) => {
 						const newPlaylist = {
 							...playlist._doc,

+ 1 - 0
frontend/src/App.vue

@@ -2070,6 +2070,7 @@ h4.section-title {
 		border-radius: 34px;
 
 		&.disabled {
+			filter: grayscale(1);
 			cursor: not-allowed;
 		}
 	}

+ 56 - 3
frontend/src/components/modals/EditPlaylist/Tabs/Settings.vue

@@ -1,7 +1,7 @@
 <script setup lang="ts">
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
-import { onBeforeUnmount, onMounted, watch } from "vue";
+import { ref, onBeforeUnmount, onMounted, watch } from "vue";
 import validation from "@/validation";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useUserAuthStore } from "@/stores/userAuth";
@@ -24,6 +24,8 @@ const { playlist } = storeToRefs(editPlaylistStore);
 
 const { preventCloseUnsaved } = useModalsStore();
 
+const featured = ref(playlist.value.featured);
+
 const isOwner = () =>
 	loggedIn.value && userId.value === playlist.value.createdBy;
 
@@ -34,7 +36,9 @@ const isEditable = permission =>
 		playlist.value.type === "admin") &&
 		(isOwner() || hasPermission(permission))) ||
 	(playlist.value.type === "genre" &&
-		permission === "playlists.update.privacy" &&
+		["playlists.update.privacy", "playlists.update.featured"].includes(
+			permission
+		) &&
 		hasPermission(permission));
 
 const {
@@ -100,6 +104,7 @@ const {
 				values.privacy,
 				res => {
 					playlist.value.privacy = values.privacy;
+					if (values.privacy !== "public") featured.value = false;
 					if (res.status === "success") {
 						resolve();
 						new Toast(res.message);
@@ -117,11 +122,28 @@ const {
 	}
 );
 
+const toggleFeatured = () => {
+	if (playlist.value.privacy !== "public") return;
+	featured.value = !featured.value;
+	socket.dispatch(
+		"playlists.updateFeatured",
+		playlist.value._id,
+		featured.value,
+		res => {
+			playlist.value.featured = featured.value;
+			new Toast(res.message);
+		}
+	);
+};
+
 watch(playlist, (value, oldValue) => {
 	if (value.displayName !== oldValue.displayName)
 		setDisplayName({ displayName: value.displayName });
-	if (value.privacy !== oldValue.privacy)
+	if (value.privacy !== oldValue.privacy) {
 		setPrivacy({ privacy: value.privacy });
+		if (value.privacy !== "public") featured.value = false;
+	}
+	if (value.featured !== oldValue.featured) featured.value = value.featured;
 });
 
 onMounted(() => {
@@ -187,10 +209,41 @@ onBeforeUnmount(() => {
 				</p>
 			</div>
 		</div>
+
+		<div
+			v-if="isEditable('playlists.update.featured')"
+			class="control is-expanded checkbox-control"
+		>
+			<label class="switch">
+				<input
+					type="checkbox"
+					id="featured"
+					:checked="featured"
+					@click="toggleFeatured"
+					:disabled="playlist.privacy !== 'public'"
+				/>
+				<span
+					v-if="playlist.privacy === 'public'"
+					class="slider round"
+				></span>
+				<span
+					v-else
+					class="slider round disabled"
+					content="Only public playlists can be featured"
+					v-tippy
+				></span>
+			</label>
+
+			<label class="label" for="featured">Featured Playlist</label>
+		</div>
 	</div>
 </template>
 
 <style lang="less" scoped>
+.checkbox-control label.label {
+	margin-left: 10px;
+}
+
 @media screen and (max-width: 1300px) {
 	.section {
 		max-width: 100% !important;

+ 18 - 0
frontend/src/pages/Admin/Playlists.vue

@@ -55,6 +55,12 @@ const columns = ref<TableColumn[]>([
 		properties: ["privacy"],
 		sortProperty: "privacy"
 	},
+	{
+		name: "featured",
+		displayName: "Featured",
+		properties: ["featured"],
+		sortProperty: "featured"
+	},
 	{
 		name: "songsCount",
 		displayName: "Songs #",
@@ -143,6 +149,13 @@ const filters = ref<TableFilter[]>([
 			["private", "Private"]
 		]
 	},
+	{
+		name: "featured",
+		displayName: "Featured",
+		property: "featured",
+		filterTypes: ["boolean"],
+		defaultFilterType: "boolean"
+	},
 	{
 		name: "songsCount",
 		displayName: "Songs Count",
@@ -305,6 +318,11 @@ const create = () => {
 					slotProps.item.privacy
 				}}</span>
 			</template>
+			<template #column-featured="slotProps">
+				<span :title="slotProps.item.featured">{{
+					slotProps.item.featured
+				}}</span>
+			</template>
 			<template #column-songsCount="slotProps">
 				<span :title="slotProps.item.songsCount">{{
 					slotProps.item.songsCount

+ 1 - 0
frontend/src/types/playlist.ts

@@ -9,4 +9,5 @@ export interface Playlist {
 	createdFor: string;
 	privacy: string;
 	type: string;
+	featured: boolean;
 }