Browse Source

feat: Added user search to add DJs to stations, and other tweaks

Owen Diffey 2 years ago
parent
commit
23b5ce9564

+ 3 - 14
backend/logic/actions/stations.js

@@ -2,7 +2,7 @@ import async from "async";
 import mongoose from "mongoose";
 import config from "config";
 
-import { hasPermission, useHasPermission, getUserPermissions } from "../hooks/hasPermission";
+import { hasPermission, useHasPermission } from "../hooks/hasPermission";
 import isLoginRequired from "../hooks/loginRequired";
 
 // eslint-disable-next-line
@@ -962,22 +962,11 @@ export default {
 						theme: station.theme,
 						paused: station.paused,
 						currentSong: station.currentSong,
-						isFavorited: station.isFavorited
+						isFavorited: station.isFavorited,
+						djs: station.djs
 					};
 
 					next(null, data);
-				},
-
-				(data, next) => {
-					getUserPermissions(session.userId, data._id)
-						.then(permissions => {
-							data.permissions = permissions;
-							next(null, data);
-						})
-						.catch(() => {
-							data.permissions = {};
-							next(null, data);
-						});
 				}
 			],
 			async (err, data) => {

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

@@ -3337,5 +3337,64 @@ export default {
 				});
 			}
 		);
+	}),
+
+	/**
+	 * Search for a user by username or name
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} query - the query
+	 * @param {string} page - page
+	 * @param {Function} cb - gets called with the result
+	 */
+	search: isLoginRequired(async function search(session, query, page, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		async.waterfall(
+			[
+				next => {
+					if ((!query && query !== "") || typeof query !== "string") next("Invalid query.");
+					else next();
+				},
+
+				next => {
+					const findQuery = {
+						$or: [{ name: new RegExp(`${query}`, "i"), username: new RegExp(`${query}`, "i") }]
+					};
+					const pageSize = 15;
+					const skipAmount = pageSize * (page - 1);
+
+					userModel.find(findQuery).count((err, count) => {
+						if (err) next(err);
+						else {
+							userModel
+								.find(findQuery, { _id: true, name: true, username: true, avatar: true })
+								.skip(skipAmount)
+								.limit(pageSize)
+								.exec((err, users) => {
+									if (err) next(err);
+									else {
+										next(null, {
+											users,
+											page,
+											pageSize,
+											skipAmount,
+											count
+										});
+									}
+								});
+						}
+					});
+				}
+			],
+			async (err, data) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "USERS_SEARCH", `Searching users failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "USERS_SEARCH", "Searching users successful.");
+				return cb({ status: "success", data });
+			}
+		);
 	})
 };

+ 25 - 12
frontend/src/components/modals/ManageStation/index.vue

@@ -105,23 +105,36 @@ const resetQueue = () => {
 	});
 };
 
+const findTabOrClose = () => {
+	if (hasPermission("stations.update")) return showTab("settings");
+	if (hasPermission("stations.request")) return showTab("request");
+	if (hasPermission("stations.autofill")) return showTab("autofill");
+	if (hasPermission("stations.blacklist")) return showTab("blacklist");
+	return closeCurrentModal();
+};
+
 watch(
-	() => station.value.requests,
-	() => {
-		if (tab.value === "request" && !canRequest()) {
-			if (hasPermission("stations.update")) showTab("settings");
-			else if (
-				!(sector.value === "home" && !hasPermission("stations.update"))
-			)
-				closeCurrentModal();
-		}
+	() => hasPermission("stations.update"),
+	value => {
+		if (!value && tab.value === "settings") findTabOrClose();
+	}
+);
+watch(
+	() => hasPermission("stations.request") && station.value.requests.enabled,
+	value => {
+		if (!value && tab.value === "request") findTabOrClose();
+	}
+);
+watch(
+	() => hasPermission("stations.autofill") && station.value.autofill.enabled,
+	value => {
+		if (!value && tab.value === "autofill") findTabOrClose();
 	}
 );
 watch(
-	() => station.value.autofill,
+	() => hasPermission("stations.blacklist"),
 	value => {
-		if (tab.value === "autofill" && value && !value.enabled)
-			showTab("settings");
+		if (!value && tab.value === "blacklist") findTabOrClose();
 	}
 );
 

+ 229 - 8
frontend/src/pages/Station/Sidebar/Users.vue

@@ -1,6 +1,13 @@
 <script setup lang="ts">
 import { useRoute } from "vue-router";
-import { defineAsyncComponent, ref, onMounted } from "vue";
+import {
+	defineAsyncComponent,
+	ref,
+	reactive,
+	computed,
+	watch,
+	onMounted
+} from "vue";
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
 import { useWebsocketsStore } from "@/stores/websockets";
@@ -18,11 +25,44 @@ const notesUri = ref("");
 const frontendDomain = ref("");
 const tab = ref("active");
 const tabs = ref([]);
+const search = reactive({
+	query: "",
+	searchedQuery: "",
+	page: 0,
+	count: 0,
+	resultsLeft: 0,
+	pageSize: 0,
+	results: []
+});
 
 const { socket } = useWebsocketsStore();
 
 const { station, users, userCount } = storeToRefs(stationStore);
 
+const sortedUsers = computed(() =>
+	users.value && users.value.loggedIn
+		? users.value.loggedIn
+				.slice()
+				.sort(
+					(a, b) =>
+						Number(station.value.owner === b._id) -
+							Number(station.value.owner === a._id) ||
+						Number(
+							!station.value.djs.find(dj => dj._id === a._id)
+						) -
+							Number(
+								!station.value.djs.find(dj => dj._id === b._id)
+							)
+				)
+		: []
+);
+
+const resultsLeftCount = computed(() => search.count - search.results.length);
+
+const nextPageResultsCount = computed(() =>
+	Math.min(search.pageSize, resultsLeftCount.value)
+);
+
 const { hasPermission } = useUserAuthStore();
 
 const copyToClipboard = async () => {
@@ -52,6 +92,43 @@ const removeDj = userId => {
 	});
 };
 
+const searchForUser = page => {
+	if (search.page >= page || search.searchedQuery !== search.query) {
+		search.results = [];
+		search.page = 0;
+		search.count = 0;
+		search.resultsLeft = 0;
+		search.pageSize = 0;
+	}
+
+	search.searchedQuery = search.query;
+	socket.dispatch("users.search", search.query, page, res => {
+		const { data } = res;
+		if (res.status === "success") {
+			const { count, pageSize, users } = data;
+			search.results = [...search.results, ...users];
+			search.page = page;
+			search.count = count;
+			search.resultsLeft = count - search.results.length;
+			search.pageSize = pageSize;
+		} else if (res.status === "error") {
+			search.results = [];
+			search.page = 0;
+			search.count = 0;
+			search.resultsLeft = 0;
+			search.pageSize = 0;
+			new Toast(res.message);
+		}
+	});
+};
+
+watch(
+	() => hasPermission("stations.update"),
+	value => {
+		if (!value && tab.value === "djs") showTab("active");
+	}
+);
+
 onMounted(async () => {
 	frontendDomain.value = await lofig.get("frontendDomain");
 	notesUri.value = encodeURI(`${frontendDomain.value}/assets/notes.png`);
@@ -61,7 +138,13 @@ onMounted(async () => {
 <template>
 	<div id="users">
 		<div class="tabs-container">
-			<div v-if="hasPermission('stations.update')" class="tab-selection">
+			<div
+				v-if="
+					hasPermission('stations.update') &&
+					station.type === 'community'
+				"
+				class="tab-selection"
+			>
 				<button
 					class="button is-default"
 					:ref="el => (tabs['active-tab'] = el)"
@@ -78,6 +161,14 @@ onMounted(async () => {
 				>
 					DJs
 				</button>
+				<button
+					class="button is-default"
+					:ref="el => (tabs['add-dj-tab'] = el)"
+					:class="{ selected: tab === 'add-dj' }"
+					@click="showTab('add-dj')"
+				>
+					Add DJ
+				</button>
 			</div>
 			<div class="tab" v-show="tab === 'active'">
 				<h5 class="has-text-centered">Total users: {{ userCount }}</h5>
@@ -116,7 +207,7 @@ onMounted(async () => {
 
 				<aside class="menu">
 					<ul class="menu-list scrollable-list">
-						<li v-for="user in users.loggedIn" :key="user.username">
+						<li v-for="user in sortedUsers" :key="user.username">
 							<router-link
 								:to="{
 									name: 'profile',
@@ -153,6 +244,7 @@ onMounted(async () => {
 								<button
 									v-if="
 										hasPermission('stations.djs.add') &&
+										station.type === 'community' &&
 										!station.djs.find(
 											dj => dj._id === user._id
 										) &&
@@ -168,6 +260,7 @@ onMounted(async () => {
 								<button
 									v-else-if="
 										hasPermission('stations.djs.remove') &&
+										station.type === 'community' &&
 										station.djs.find(
 											dj => dj._id === user._id
 										)
@@ -190,8 +283,8 @@ onMounted(async () => {
 				v-show="tab === 'djs'"
 			>
 				<h5 class="has-text-centered">Station DJs</h5>
-				<h6 class="has-text-centered">
-					Add/remove DJs, who can manage the station and queue.
+				<h6 v-if="station.djs.length === 0" class="has-text-centered">
+					There are currently no DJs.
 				</h6>
 				<aside class="menu">
 					<ul class="menu-list scrollable-list">
@@ -231,6 +324,116 @@ onMounted(async () => {
 					</ul>
 				</aside>
 			</div>
+			<div
+				v-if="hasPermission('stations.update')"
+				class="tab"
+				v-show="tab === 'add-dj'"
+			>
+				<h5 class="has-text-centered">Add Station DJ</h5>
+				<h6 class="has-text-centered">
+					Search for users to promote to DJ.
+				</h6>
+
+				<div class="control is-grouped input-with-button">
+					<p class="control is-expanded">
+						<input
+							class="input"
+							type="text"
+							placeholder="Enter your user query here..."
+							v-model="search.query"
+							@keyup.enter="searchForUser(1)"
+						/>
+					</p>
+					<p class="control">
+						<button
+							class="button is-primary"
+							@click="searchForUser(1)"
+						>
+							<i class="material-icons icon-with-button">search</i
+							>Search
+						</button>
+					</p>
+				</div>
+
+				<aside class="menu">
+					<ul class="menu-list scrollable-list">
+						<li v-for="user in search.results" :key="user.username">
+							<router-link
+								:to="{
+									name: 'profile',
+									params: { username: user.username }
+								}"
+								target="_blank"
+							>
+								<profile-picture
+									:avatar="user.avatar"
+									:name="user.name || user.username"
+								/>
+
+								{{ user.name || user.username }}
+
+								<span
+									v-if="user._id === station.owner"
+									class="material-icons user-rank"
+									content="Station Owner"
+									v-tippy="{ theme: 'info' }"
+									>local_police</span
+								>
+								<span
+									v-else-if="
+										station.djs.find(
+											dj => dj._id === user._id
+										)
+									"
+									class="material-icons user-rank"
+									content="Station DJ"
+									v-tippy="{ theme: 'info' }"
+									>shield</span
+								>
+
+								<button
+									v-if="
+										hasPermission('stations.djs.add') &&
+										station.type === 'community' &&
+										!station.djs.find(
+											dj => dj._id === user._id
+										) &&
+										station.owner !== user._id
+									"
+									class="button is-primary material-icons"
+									@click.prevent="addDj(user._id)"
+									content="Promote user to DJ"
+									v-tippy
+								>
+									add_moderator
+								</button>
+								<button
+									v-else-if="
+										hasPermission('stations.djs.remove') &&
+										station.type === 'community' &&
+										station.djs.find(
+											dj => dj._id === user._id
+										)
+									"
+									class="button is-danger material-icons"
+									@click.prevent="removeDj(user._id)"
+									content="Demote user from DJ"
+									v-tippy
+								>
+									remove_moderator
+								</button>
+							</router-link>
+						</li>
+						<button
+							v-if="resultsLeftCount > 0"
+							class="button is-primary load-more-button"
+							@click="searchForUser(search.page + 1)"
+						>
+							Load {{ nextPageResultsCount }} more results
+						</button>
+					</ul>
+				</aside>
+			</div>
 		</div>
 
 		<button
@@ -309,15 +512,21 @@ onMounted(async () => {
 			}
 		}
 		.tab {
+			position: absolute;
+			height: calc(100% - 120px);
+			width: calc(100% - 20px);
+			overflow-y: auto;
+
 			.menu {
-				margin-top: 20px;
+				margin-top: 10px;
 				width: 100%;
-				overflow: auto;
-				height: calc(100% - 20px - 40px);
 
 				.menu-list {
 					margin-left: 0;
 					padding: 0;
+					&.scrollable-list {
+						max-height: unset;
+					}
 				}
 
 				li {
@@ -368,6 +577,18 @@ onMounted(async () => {
 			h5 {
 				font-size: 20px;
 			}
+
+			.control.is-grouped.input-with-button {
+				margin: 10px 0 0 0 !important;
+				& > .control {
+					margin-bottom: 0 !important;
+				}
+			}
+
+			.load-more-button {
+				width: 100%;
+				margin-top: 10px;
+			}
 		}
 	}
 }

+ 6 - 6
frontend/src/pages/Station/Sidebar/index.vue

@@ -32,7 +32,7 @@ const canRequest = (requireLogin = true) =>
 			hasPermission("stations.request")));
 
 watch(
-	() => station.value.requests,
+	() => [station.value.requests, hasPermission("stations.request")],
 	() => {
 		if (tab.value === "request" && !canRequest()) showTab("queue");
 	}
@@ -49,8 +49,8 @@ onMounted(() => {
 </script>
 
 <template>
-	<div id="tabs-container">
-		<div id="tab-selection">
+	<div class="tabs-container">
+		<div class="tab-selection">
 			<button
 				class="button is-default"
 				:class="{ selected: tab === 'queue' }"
@@ -95,7 +95,7 @@ onMounted(() => {
 
 <style lang="less" scoped>
 .night-mode {
-	#tab-selection .button {
+	.tab-selection .button {
 		background: var(--dark-grey);
 		color: var(--white);
 	}
@@ -106,7 +106,7 @@ onMounted(() => {
 	}
 }
 
-#tabs-container .tab {
+.tabs-container .tab {
 	width: 100%;
 	height: calc(100% - 36px);
 	position: absolute;
@@ -114,7 +114,7 @@ onMounted(() => {
 	border-top: 0;
 }
 
-#tab-selection {
+.tab-selection {
 	display: flex;
 	overflow-x: auto;