Browse Source

feat(Admin): Continued integrating advanced table

Owen Diffey 3 years ago
parent
commit
60eb611e94

+ 53 - 11
backend/logic/actions/dataRequests.js

@@ -21,30 +21,72 @@ CacheModule.runJob("SUB", {
 
 export default {
 	/**
-	 * Gets all unresolved data requests
+	 * Gets data requests, used in the admin users page by the AdvancedTable component
 	 *
 	 * @param {object} session - the session object automatically added by the websocket
-	 * @param {Function} cb - gets called with the result
+	 * @param page - the page
+	 * @param pageSize - the size per page
+	 * @param properties - the properties to return for each data request
+	 * @param sort - the sort object
+	 * @param queries - the queries array
+	 * @param operator - the operator for queries
+	 * @param cb
 	 */
-	index: isAdminRequired(async function index(session, cb) {
+	getData: isAdminRequired(async function getSet(session, page, pageSize, properties, sort, queries, operator, cb) {
 		const dataRequestModel = await DBModule.runJob("GET_MODEL", { modelName: "dataRequest" }, this);
 
+		const newQueries = queries.map(query => {
+			const { data, filter, filterType } = query;
+			const newQuery = {};
+			if (filterType === "regex") {
+				newQuery[filter.property] = new RegExp(`${data.slice(1, data.length - 1)}`, "i");
+			} else if (filterType === "contains") {
+				newQuery[filter.property] = new RegExp(`${data.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i");
+			} else if (filterType === "exact") {
+				newQuery[filter.property] = data.toString();
+			}
+			return newQuery;
+		});
+
+		const queryObject = {};
+		if (newQueries.length > 0) {
+			if (operator === "and") queryObject.$and = newQueries;
+			else if (operator === "or") queryObject.$or = newQueries;
+			else if (operator === "nor") queryObject.$nor = newQueries;
+		}
+
 		async.waterfall(
 			[
 				next => {
-					dataRequestModel.find({ resolved: false }).sort({ createdAt: "desc" }).exec(next);
+					dataRequestModel.find(queryObject).count((err, count) => {
+						next(err, count);
+					});
+				},
+
+				(count, next) => {
+					dataRequestModel
+						.find(queryObject)
+						.sort(sort)
+						.skip(pageSize * (page - 1))
+						.limit(pageSize)
+						.select(properties.join(" "))
+						.exec((err, dataRequests) => {
+							next(err, count, dataRequests);
+						});
 				}
 			],
-			async (err, requests) => {
-				if (err) {
+			async (err, count, dataRequests) => {
+				if (err && err !== true) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log("ERROR", "DATA_REQUESTS_INDEX", `Indexing data requests failed. "${err}"`);
+					this.log("ERROR", "DATA_REQUESTS_GET_DATA", `Failed to get data from data requests. "${err}"`);
 					return cb({ status: "error", message: err });
 				}
-
-				this.log("SUCCESS", "DATA_REQUESTS_INDEX", `Indexing data requests successful.`, false);
-
-				return cb({ status: "success", data: { requests } });
+				this.log("SUCCESS", "DATA_REQUESTS_GET_DATA", `Got data from data requests successfully.`);
+				return cb({
+					status: "success",
+					message: "Successfully got data from data requests.",
+					data: { data: dataRequests, count }
+				});
 			}
 		);
 	}),

+ 72 - 1
backend/logic/actions/news.js

@@ -56,13 +56,84 @@ CacheModule.runJob("SUB", {
 });
 
 export default {
+	/**
+	 * Gets news items, used in the admin news page by the AdvancedTable component
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param page - the page
+	 * @param pageSize - the size per page
+	 * @param properties - the properties to return for each news item
+	 * @param sort - the sort object
+	 * @param queries - the queries array
+	 * @param operator - the operator for queries
+	 * @param cb
+	 */
+	getData: isAdminRequired(async function getSet(session, page, pageSize, properties, sort, queries, operator, cb) {
+		const newsModel = await DBModule.runJob("GET_MODEL", { modelName: "news" }, this);
+
+		const newQueries = queries.map(query => {
+			const { data, filter, filterType } = query;
+			const newQuery = {};
+			if (filterType === "regex") {
+				newQuery[filter.property] = new RegExp(`${data.slice(1, data.length - 1)}`, "i");
+			} else if (filterType === "contains") {
+				newQuery[filter.property] = new RegExp(`${data.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i");
+			} else if (filterType === "exact") {
+				newQuery[filter.property] = data.toString();
+			}
+			return newQuery;
+		});
+
+		const queryObject = {};
+		if (newQueries.length > 0) {
+			if (operator === "and") queryObject.$and = newQueries;
+			else if (operator === "or") queryObject.$or = newQueries;
+			else if (operator === "nor") queryObject.$nor = newQueries;
+		}
+
+		async.waterfall(
+			[
+				next => {
+					newsModel.find(queryObject).count((err, count) => {
+						next(err, count);
+					});
+				},
+
+				(count, next) => {
+					newsModel
+						.find(queryObject)
+						.sort(sort)
+						.skip(pageSize * (page - 1))
+						.limit(pageSize)
+						.select(properties.join(" "))
+						.exec((err, news) => {
+							next(err, count, news);
+						});
+				}
+			],
+			async (err, count, news) => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "NEWS_GET_DATA", `Failed to get data from news. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "NEWS_GET_DATA", `Got data from news successfully.`);
+				return cb({
+					status: "success",
+					message: "Successfully got data from news.",
+					data: { data: news, count }
+				});
+			}
+		);
+	}),
+
 	/**
 	 * Gets all news items that are published
 	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {Function} cb - gets called with the result
 	 */
-	async index(session, cb) {
+	async getPublished(session, cb) {
 		const newsModel = await DBModule.runJob("GET_MODEL", { modelName: "news" }, this);
 		async.waterfall(
 			[

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

@@ -231,33 +231,6 @@ CacheModule.runJob("SUB", {
 });
 
 export default {
-	/**
-	 * Gets all playlists
-	 *
-	 * @param {object} session - the session object automatically added by the websocket
-	 * @param {Function} cb - gets called with the result
-	 */
-	index: isAdminRequired(async function index(session, cb) {
-		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
-
-		async.waterfall(
-			[
-				next => {
-					playlistModel.find({}).sort({ createdAt: "desc" }).exec(next);
-				}
-			],
-			async (err, playlists) => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log("ERROR", "PLAYLISTS_INDEX", `Indexing playlists failed. "${err}"`);
-					return cb({ status: "error", message: err });
-				}
-				this.log("SUCCESS", "PLAYLISTS_INDEX", "Indexing playlists successful.");
-				return cb({ status: "success", data: { playlists } });
-			}
-		);
-	}),
-
 	/**
 	 * Gets playlists, used in the admin playlists page by the AdvancedTable component
 	 *

+ 55 - 16
backend/logic/actions/punishments.js

@@ -28,33 +28,72 @@ CacheModule.runJob("SUB", {
 
 export default {
 	/**
-	 * Gets all punishments
+	 * Gets punishments, used in the admin punishments page by the AdvancedTable component
 	 *
 	 * @param {object} session - the session object automatically added by the websocket
-	 * @param {Function} cb - gets called with the result
+	 * @param page - the page
+	 * @param pageSize - the size per page
+	 * @param properties - the properties to return for each punishment
+	 * @param sort - the sort object
+	 * @param queries - the queries array
+	 * @param operator - the operator for queries
+	 * @param cb
 	 */
-	index: isAdminRequired(async function index(session, cb) {
-		const punishmentModel = await DBModule.runJob(
-			"GET_MODEL",
-			{
-				modelName: "punishment"
-			},
-			this
-		);
+	getData: isAdminRequired(async function getSet(session, page, pageSize, properties, sort, queries, operator, cb) {
+		const punishmentModel = await DBModule.runJob("GET_MODEL", { modelName: "punishment" }, this);
+
+		const newQueries = queries.map(query => {
+			const { data, filter, filterType } = query;
+			const newQuery = {};
+			if (filterType === "regex") {
+				newQuery[filter.property] = new RegExp(`${data.slice(1, data.length - 1)}`, "i");
+			} else if (filterType === "contains") {
+				newQuery[filter.property] = new RegExp(`${data.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i");
+			} else if (filterType === "exact") {
+				newQuery[filter.property] = data.toString();
+			}
+			return newQuery;
+		});
+
+		const queryObject = {};
+		if (newQueries.length > 0) {
+			if (operator === "and") queryObject.$and = newQueries;
+			else if (operator === "or") queryObject.$or = newQueries;
+			else if (operator === "nor") queryObject.$nor = newQueries;
+		}
+
 		async.waterfall(
 			[
 				next => {
-					punishmentModel.find({}, next);
+					punishmentModel.find(queryObject).count((err, count) => {
+						next(err, count);
+					});
+				},
+
+				(count, next) => {
+					punishmentModel
+						.find(queryObject)
+						.sort(sort)
+						.skip(pageSize * (page - 1))
+						.limit(pageSize)
+						.select(properties.join(" "))
+						.exec((err, punishments) => {
+							next(err, count, punishments);
+						});
 				}
 			],
-			async (err, punishments) => {
-				if (err) {
+			async (err, count, punishments) => {
+				if (err && err !== true) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log("ERROR", "PUNISHMENTS_INDEX", `Indexing punishments failed. "${err}"`);
+					this.log("ERROR", "PUNISHMENTS_GET_DATA", `Failed to get data from punishments. "${err}"`);
 					return cb({ status: "error", message: err });
 				}
-				this.log("SUCCESS", "PUNISHMENTS_INDEX", "Indexing punishments successful.");
-				return cb({ status: "success", data: { punishments } });
+				this.log("SUCCESS", "PUNISHMENTS_GET_DATA", `Got data from punishments successfully.`);
+				return cb({
+					status: "success",
+					message: "Successfully got data from punishments.",
+					data: { data: punishments, count }
+				});
 			}
 		);
 	}),

+ 53 - 41
backend/logic/actions/reports.js

@@ -60,60 +60,72 @@ CacheModule.runJob("SUB", {
 
 export default {
 	/**
-	 * Gets all reports that haven't been yet resolved
+	 * Gets reports, used in the admin reports page by the AdvancedTable component
 	 *
 	 * @param {object} session - the session object automatically added by the websocket
-	 * @param {Function} cb - gets called with the result
+	 * @param page - the page
+	 * @param pageSize - the size per page
+	 * @param properties - the properties to return for each user
+	 * @param sort - the sort object
+	 * @param queries - the queries array
+	 * @param operator - the operator for queries
+	 * @param cb
 	 */
-	index: isAdminRequired(async function index(session, cb) {
+	getData: isAdminRequired(async function getSet(session, page, pageSize, properties, sort, queries, operator, cb) {
 		const reportModel = await DBModule.runJob("GET_MODEL", { modelName: "report" }, this);
-		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		const newQueries = queries.map(query => {
+			const { data, filter, filterType } = query;
+			const newQuery = {};
+			if (filterType === "regex") {
+				newQuery[filter.property] = new RegExp(`${data.slice(1, data.length - 1)}`, "i");
+			} else if (filterType === "contains") {
+				newQuery[filter.property] = new RegExp(`${data.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i");
+			} else if (filterType === "exact") {
+				newQuery[filter.property] = data.toString();
+			}
+			return newQuery;
+		});
+
+		const queryObject = {};
+		if (newQueries.length > 0) {
+			if (operator === "and") queryObject.$and = newQueries;
+			else if (operator === "or") queryObject.$or = newQueries;
+			else if (operator === "nor") queryObject.$nor = newQueries;
+		}
 
 		async.waterfall(
 			[
-				next => reportModel.find({ resolved: false }).sort({ createdAt: "desc" }).exec(next),
-				(_reports, next) => {
-					const reports = [];
-
-					async.each(
-						_reports,
-						(report, cb) => {
-							userModel
-								.findById(report.createdBy)
-								.select({ avatar: -1, name: -1, username: -1 })
-								.exec((err, user) => {
-									if (!user)
-										reports.push({
-											...report._doc,
-											createdBy: { _id: report.createdBy }
-										});
-									else
-										reports.push({
-											...report._doc,
-											createdBy: {
-												avatar: user.avatar,
-												name: user.name,
-												username: user.username,
-												_id: report.createdBy
-											}
-										});
+				next => {
+					reportModel.find(queryObject).count((err, count) => {
+						next(err, count);
+					});
+				},
 
-									return cb(err);
-								});
-						},
-						err => next(err, reports)
-					);
+				(count, next) => {
+					reportModel
+						.find(queryObject)
+						.sort(sort)
+						.skip(pageSize * (page - 1))
+						.limit(pageSize)
+						.select(properties.join(" "))
+						.exec((err, reports) => {
+							next(err, count, reports);
+						});
 				}
 			],
-			async (err, reports) => {
-				if (err) {
+			async (err, count, reports) => {
+				if (err && err !== true) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log("ERROR", "REPORTS_INDEX", `Indexing reports failed. "${err}"`);
+					this.log("ERROR", "REPORTS_GET_DATA", `Failed to get data from reports. "${err}"`);
 					return cb({ status: "error", message: err });
 				}
-
-				this.log("SUCCESS", "REPORTS_INDEX", "Indexing reports successful.");
-				return cb({ status: "success", data: { reports } });
+				this.log("SUCCESS", "REPORTS_GET_DATA", `Got data from reports successfully.`);
+				return cb({
+					status: "success",
+					message: "Successfully got data from reports.",
+					data: { data: reports, count }
+				});
 			}
 		);
 	}),

+ 52 - 31
backend/logic/actions/users.js

@@ -169,51 +169,72 @@ CacheModule.runJob("SUB", {
 
 export default {
 	/**
-	 * Lists all Users
+	 * Gets users, used in the admin users page by the AdvancedTable component
 	 *
 	 * @param {object} session - the session object automatically added by the websocket
-	 * @param {Function} cb - gets called with the result
+	 * @param page - the page
+	 * @param pageSize - the size per page
+	 * @param properties - the properties to return for each user
+	 * @param sort - the sort object
+	 * @param queries - the queries array
+	 * @param operator - the operator for queries
+	 * @param cb
 	 */
-	index: isAdminRequired(async function index(session, cb) {
+	getData: isAdminRequired(async function getSet(session, page, pageSize, properties, sort, queries, operator, cb) {
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 
+		const newQueries = queries.map(query => {
+			const { data, filter, filterType } = query;
+			const newQuery = {};
+			if (filterType === "regex") {
+				newQuery[filter.property] = new RegExp(`${data.slice(1, data.length - 1)}`, "i");
+			} else if (filterType === "contains") {
+				newQuery[filter.property] = new RegExp(`${data.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i");
+			} else if (filterType === "exact") {
+				newQuery[filter.property] = data.toString();
+			}
+			return newQuery;
+		});
+
+		const queryObject = {};
+		if (newQueries.length > 0) {
+			if (operator === "and") queryObject.$and = newQueries;
+			else if (operator === "or") queryObject.$or = newQueries;
+			else if (operator === "nor") queryObject.$nor = newQueries;
+		}
+
 		async.waterfall(
 			[
 				next => {
-					userModel.find({}).exec(next);
+					userModel.find(queryObject).count((err, count) => {
+						next(err, count);
+					});
+				},
+
+				(count, next) => {
+					userModel
+						.find(queryObject)
+						.sort(sort)
+						.skip(pageSize * (page - 1))
+						.limit(pageSize)
+						.select(properties.join(" "))
+						.exec((err, users) => {
+							next(err, count, users);
+						});
 				}
 			],
-			async (err, users) => {
-				if (err) {
+			async (err, count, users) => {
+				if (err && err !== true) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log("ERROR", "USER_INDEX", `Indexing users failed. "${err}"`);
+					this.log("ERROR", "USERS_GET_DATA", `Failed to get data from users. "${err}"`);
 					return cb({ status: "error", message: err });
 				}
-				this.log("SUCCESS", "USER_INDEX", `Indexing users successful.`);
-				const filteredUsers = [];
-				users.forEach(user => {
-					filteredUsers.push({
-						_id: user._id,
-						name: user.name,
-						username: user.username,
-						role: user.role,
-						liked: user.liked,
-						disliked: user.disliked,
-						songsRequested: user.statistics.songsRequested,
-						email: {
-							address: user.email.address,
-							verified: user.email.verified
-						},
-						avatar: {
-							type: user.avatar.type,
-							url: user.avatar.url,
-							color: user.avatar.color
-						},
-						hasPassword: !!user.services.password,
-						services: { github: user.services.github }
-					});
+				this.log("SUCCESS", "USERS_GET_DATA", `Got data from users successfully.`);
+				return cb({
+					status: "success",
+					message: "Successfully got data from users.",
+					data: { data: users, count }
 				});
-				return cb({ status: "success", data: { users: filteredUsers } });
 			}
 		);
 	}),

+ 25 - 24
frontend/src/components/AdvancedTable.vue

@@ -642,7 +642,8 @@ export default {
 		columns: { type: Array, default: null },
 		filters: { type: Array, default: null },
 		dataAction: { type: String, default: null },
-		name: { type: String, default: null }
+		name: { type: String, default: null },
+		maxWidth: { type: Number, default: 1880 }
 	},
 	data() {
 		return {
@@ -890,7 +891,7 @@ export default {
 	methods: {
 		init() {
 			this.getData();
-			this.setQuery();
+			// this.setQuery();
 		},
 		getData() {
 			this.socket.dispatch(
@@ -1226,8 +1227,8 @@ export default {
 					}
 			});
 			calculatedWidth = Math.floor(
-				// max-width of table is 1880px
-				(Math.min(1880, document.body.clientWidth) - calculatedWidth) /
+				(Math.min(this.maxWidth, document.body.clientWidth) -
+					calculatedWidth) /
 					(noWidthCount - 1)
 			);
 			this.orderedColumns = this.orderedColumns.map(column => {
@@ -1296,26 +1297,26 @@ export default {
 
 			// Resizing calls this function a lot, so rather than saving dozens of times a second, use debouncing
 			this.storeTableSettingsDebounceTimeout = setTimeout(() => {
-				this.$router.push({
-					query: {
-						pageSize: this.pageSize,
-						filter: JSON.stringify({
-							appliedFilters: this.appliedFilters,
-							appliedFilterOperator: this.appliedFilterOperator
-						}),
-						columnSort: JSON.stringify(this.sort),
-						columnOrder: JSON.stringify(
-							this.orderedColumns.map(column => column.name)
-						),
-						columnWidths: JSON.stringify(
-							this.orderedColumns.map(column => ({
-								name: column.name,
-								width: column.width
-							}))
-						),
-						shownColumns: JSON.stringify(this.shownColumns)
-					}
-				});
+				// this.$router.push({
+				// 	query: {
+				// 		pageSize: this.pageSize,
+				// 		filter: JSON.stringify({
+				// 			appliedFilters: this.appliedFilters,
+				// 			appliedFilterOperator: this.appliedFilterOperator
+				// 		}),
+				// 		columnSort: JSON.stringify(this.sort),
+				// 		columnOrder: JSON.stringify(
+				// 			this.orderedColumns.map(column => column.name)
+				// 		),
+				// 		columnWidths: JSON.stringify(
+				// 			this.orderedColumns.map(column => ({
+				// 				name: column.name,
+				// 				width: column.width
+				// 			}))
+				// 		),
+				// 		shownColumns: JSON.stringify(this.shownColumns)
+				// 	}
+				// });
 
 				localStorage.setItem(
 					`advancedTableSettings:${this.name}`,

+ 156 - 145
frontend/src/pages/Admin/tabs/News.vue

@@ -7,48 +7,68 @@
 					Create News Item
 				</button>
 			</div>
-			<table class="table">
-				<thead>
-					<tr>
-						<td>Status</td>
-						<td>Title</td>
-						<td>Author</td>
-						<td>Markdown</td>
-						<td>Options</td>
-					</tr>
-				</thead>
-				<tbody>
-					<tr v-for="news in news" :key="news._id">
-						<td class="news-item-status">{{ news.status }}</td>
-						<td>
-							<strong>{{ news.title }}</strong>
-						</td>
-						<td>
-							<user-id-to-username
-								:user-id="news.createdBy"
-								:alt="news.createdBy"
-								:link="true"
-							/>
-						</td>
-						<td class="news-item-markdown">{{ news.markdown }}</td>
-						<td id="options-column">
-							<div>
-								<button
-									class="button is-primary"
-									@click="edit(news._id)"
-								>
-									Edit
-								</button>
-								<quick-confirm @confirm="remove(news._id)">
-									<button class="button is-danger">
-										Remove
-									</button>
-								</quick-confirm>
-							</div>
-						</td>
-					</tr>
-				</tbody>
-			</table>
+			<advanced-table
+				:column-default="columnDefault"
+				:columns="columns"
+				:filters="filters"
+				data-action="news.getData"
+				name="admin-news"
+				max-width="1200"
+			>
+				<template #column-options="slotProps">
+					<div class="row-options">
+						<button
+							class="
+								button
+								is-primary
+								icon-with-button
+								material-icons
+							"
+							@click="edit(slotProps.item._id)"
+							content="Edit News"
+							v-tippy
+						>
+							edit
+						</button>
+						<quick-confirm @confirm="remove(slotProps.item._id)">
+							<button
+								class="
+									button
+									is-danger
+									icon-with-button
+									material-icons
+								"
+								content="Remove News"
+								v-tippy
+							>
+								delete_forever
+							</button>
+						</quick-confirm>
+					</div>
+				</template>
+				<template #column-status="slotProps">
+					<span :title="slotProps.item.status">{{
+						slotProps.item.status
+					}}</span>
+				</template>
+				<template #column-title="slotProps">
+					<span :title="slotProps.item.title">{{
+						slotProps.item.title
+					}}</span>
+				</template>
+				<template #column-createdBy="slotProps">
+					<user-id-to-username
+						:user-id="slotProps.item.createdBy"
+						:alt="slotProps.item.createdBy"
+						:link="true"
+					/>
+				</template>
+				<template #column-markdown="slotProps">
+					<span :title="slotProps.item.markdown">{{
+						slotProps.item.markdown
+					}}</span>
+				</template>
+			</advanced-table>
 		</div>
 
 		<edit-news
@@ -64,13 +84,15 @@ import { mapActions, mapState, mapGetters } from "vuex";
 import { defineAsyncComponent } from "vue";
 import Toast from "toasters";
 
-import ws from "@/ws";
+// import ws from "@/ws";
 
+import AdvancedTable from "@/components/AdvancedTable.vue";
 import QuickConfirm from "@/components/QuickConfirm.vue";
 import UserIdToUsername from "@/components/UserIdToUsername.vue";
 
 export default {
 	components: {
+		AdvancedTable,
 		QuickConfirm,
 		UserIdToUsername,
 		EditNews: defineAsyncComponent(() =>
@@ -79,7 +101,84 @@ export default {
 	},
 	data() {
 		return {
-			editingNewsId: ""
+			editingNewsId: "",
+			columnDefault: {
+				sortable: true,
+				hidable: true,
+				defaultVisibility: "shown",
+				draggable: true,
+				resizable: true,
+				minWidth: 150,
+				maxWidth: 600
+			},
+			columns: [
+				{
+					name: "options",
+					displayName: "Edit",
+					properties: ["_id"],
+					sortable: false,
+					hidable: false,
+					resizable: false,
+					minWidth: 85,
+					defaultWidth: 85
+				},
+				{
+					name: "status",
+					displayName: "Status",
+					properties: ["status"],
+					sortProperty: "status",
+					defaultWidth: 150
+				},
+				{
+					name: "title",
+					displayName: "Title",
+					properties: ["title"],
+					sortProperty: "title"
+				},
+				{
+					name: "createdBy",
+					displayName: "Created By",
+					properties: ["createdBy"],
+					sortProperty: "createdBy",
+					defaultWidth: 150
+				},
+				{
+					name: "markdown",
+					displayName: "Markdown",
+					properties: ["markdown"],
+					sortProperty: "markdown"
+				}
+			],
+			filters: [
+				{
+					name: "status",
+					displayName: "Status",
+					property: "status",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "title",
+					displayName: "Title",
+					property: "title",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "createdBy",
+					displayName: "Created By",
+					property: "createdBy",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "markdown",
+					displayName: "Markdown",
+					property: "markdown",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				}
+			]
 		};
 	},
 	computed: {
@@ -94,19 +193,16 @@ export default {
 		})
 	},
 	mounted() {
-		this.socket.on("event:admin.news.created", res =>
-			this.addNews(res.data.news)
-		);
-
-		this.socket.on("event:admin.news.updated", res =>
-			this.updateNews(res.data.news)
-		);
-
-		this.socket.on("event:admin.news.deleted", res =>
-			this.removeNews(res.data.newsId)
-		);
-
-		ws.onConnect(this.init);
+		// this.socket.on("event:admin.news.created", res =>
+		// 	this.addNews(res.data.news)
+		// );
+		// this.socket.on("event:admin.news.updated", res =>
+		// 	this.updateNews(res.data.news)
+		// );
+		// this.socket.on("event:admin.news.deleted", res =>
+		// 	this.removeNews(res.data.newsId)
+		// );
+		// ws.onConnect(this.init);
 	},
 	methods: {
 		edit(id) {
@@ -121,13 +217,9 @@ export default {
 				res => new Toast(res.message)
 			);
 		},
-		init() {
-			this.socket.dispatch("news.index", res => {
-				if (res.status === "success") this.setNews(res.data.news);
-			});
-
-			this.socket.dispatch("apis.joinAdminRoom", "news");
-		},
+		// init() {
+		// 	this.socket.dispatch("apis.joinAdminRoom", "news");
+		// },
 		...mapActions("modalVisibility", ["openModal", "closeModal"]),
 		...mapActions("admin/news", [
 			"editNews",
@@ -139,84 +231,3 @@ export default {
 	}
 };
 </script>
-
-<style lang="scss" scoped>
-.night-mode {
-	.table {
-		color: var(--light-grey-2);
-		background-color: var(--dark-grey-3);
-
-		thead tr {
-			background: var(--dark-grey-3);
-			td {
-				color: var(--white);
-			}
-		}
-
-		tbody tr:hover {
-			background-color: var(--dark-grey-4) !important;
-		}
-
-		tbody tr:nth-child(even) {
-			background-color: var(--dark-grey-2);
-		}
-
-		strong {
-			color: var(--light-grey-2);
-		}
-	}
-
-	.card {
-		background: var(--dark-grey-3);
-
-		.card-header {
-			box-shadow: 0 1px 2px rgba(10, 10, 10, 0.8);
-		}
-
-		p,
-		.label {
-			color: var(--light-grey-2);
-		}
-	}
-}
-
-.tag:not(:last-child) {
-	margin-right: 5px;
-}
-
-td {
-	vertical-align: middle;
-
-	& > div {
-		display: inline-flex;
-	}
-}
-
-.is-info:focus {
-	background-color: var(--primary-color);
-}
-
-.card-footer-item {
-	color: var(--primary-color);
-}
-
-.news-item-status {
-	text-transform: capitalize;
-}
-
-.news-item-markdown {
-	text-overflow: ellipsis;
-	white-space: nowrap;
-	overflow: hidden;
-	max-width: 400px;
-}
-
-#options-column {
-	> div {
-		display: flex;
-		button {
-			margin-right: 5px;
-		}
-	}
-}
-</style>

+ 2 - 2
frontend/src/pages/Admin/tabs/Playlists.vue

@@ -197,7 +197,7 @@ export default {
 				},
 				{
 					name: "_id",
-					displayName: "ID",
+					displayName: "Playlist ID",
 					properties: ["_id"],
 					sortProperty: "_id",
 					minWidth: 230,
@@ -207,7 +207,7 @@ export default {
 			filters: [
 				{
 					name: "_id",
-					displayName: "ID",
+					displayName: "Playlist ID",
 					property: "_id",
 					filterTypes: ["exact"],
 					defaultFilterType: "exact"

+ 157 - 102
frontend/src/pages/Admin/tabs/Punishments.vue

@@ -2,61 +2,71 @@
 	<div>
 		<page-metadata title="Admin | Punishments" />
 		<div class="container">
-			<table class="table">
-				<thead>
-					<tr>
-						<td>Status</td>
-						<td>Type</td>
-						<td>Value</td>
-						<td>Reason</td>
-						<td>Options</td>
-					</tr>
-				</thead>
-				<tbody>
-					<tr
-						v-for="punishment in sortedPunishments"
-						:key="punishment._id"
+			<advanced-table
+				:column-default="columnDefault"
+				:columns="columns"
+				:filters="filters"
+				data-action="punishments.getData"
+				name="admin-punishments"
+				max-width="1200"
+			>
+				<template #column-options="slotProps">
+					<div class="row-options">
+						<button
+							class="
+								button
+								is-primary
+								icon-with-button
+								material-icons
+							"
+							@click="view(slotProps.item._id)"
+							content="View Punishment"
+							v-tippy
+						>
+							open_in_full
+						</button>
+					</div>
+				</template>
+				<template #column-status="slotProps">
+					<span>{{
+						slotProps.item.active &&
+						new Date(slotProps.item.expiresAt).getTime() >
+							Date.now()
+							? "Active"
+							: "Inactive"
+					}}</span>
+				</template>
+				<template #column-type="slotProps">
+					<span
+						:title="
+							slotProps.item.type === 'banUserId'
+								? 'User ID'
+								: 'IP Address'
+						"
+						>{{
+							slotProps.item.type === "banUserId"
+								? "User ID"
+								: "IP Address"
+						}}</span
 					>
-						<td>
-							{{
-								punishment.active &&
-								new Date(punishment.expiresAt).getTime() >
-									Date.now()
-									? "Active"
-									: "Inactive"
-							}}
-						</td>
-						<td v-if="punishment.type === 'banUserId'">User ID</td>
-						<td v-else>IP Address</td>
-						<td v-if="punishment.type === 'banUserId'">
-							<user-id-to-username
-								:user-id="punishment.value"
-								:alt="punishment.value"
-								:link="true"
-							/>
-							({{ punishment.value }})
-						</td>
-						<td v-else>
-							{{ punishment.value }}
-						</td>
-						<td>{{ punishment.reason }}</td>
-
-						<td>
-							<a
-								class="button is-primary"
-								@click="view(punishment)"
-								content="Expand"
-								v-tippy
-							>
-								<i class="material-icons icon-with-button">
-									open_in_full
-								</i>
-								Expand
-							</a>
-						</td>
-					</tr>
-				</tbody>
-			</table>
+				</template>
+				<template #column-value="slotProps">
+					<user-id-to-username
+						v-if="slotProps.item.type === 'banUserId'"
+						:user-id="slotProps.item.value"
+						:alt="slotProps.item.value"
+						:link="true"
+					/>
+					<span v-else :title="slotProps.item.value">{{
+						slotProps.item.value
+					}}</span>
+				</template>
+				<template #column-reason="slotProps">
+					<span :title="slotProps.item.reason">{{
+						slotProps.item.reason
+					}}</span>
+				</template>
+			</advanced-table>
 			<div class="card">
 				<header class="card-header">
 					<p>Ban an IP</p>
@@ -110,8 +120,9 @@ import { mapState, mapGetters, mapActions } from "vuex";
 import Toast from "toasters";
 import { defineAsyncComponent } from "vue";
 
-import ws from "@/ws";
+// import ws from "@/ws";
 
+import AdvancedTable from "@/components/AdvancedTable.vue";
 import UserIdToUsername from "@/components/UserIdToUsername.vue";
 
 export default {
@@ -119,15 +130,92 @@ export default {
 		ViewPunishment: defineAsyncComponent(() =>
 			import("@/components/modals/ViewPunishment.vue")
 		),
+		AdvancedTable,
 		UserIdToUsername
 	},
 	data() {
 		return {
 			viewingPunishmentId: "",
-			punishments: [],
 			ipBan: {
 				expiresAt: "1h"
-			}
+			},
+			columnDefault: {
+				sortable: true,
+				hidable: true,
+				defaultVisibility: "shown",
+				draggable: true,
+				resizable: true,
+				minWidth: 150,
+				maxWidth: 600
+			},
+			columns: [
+				{
+					name: "options",
+					displayName: "Edit",
+					properties: ["_id"],
+					sortable: false,
+					hidable: false,
+					resizable: false,
+					minWidth: 51,
+					defaultWidth: 51
+				},
+				{
+					name: "status",
+					displayName: "Status",
+					properties: ["active", "expiresAt"],
+					sortable: false,
+					defaultWidth: 150
+				},
+				{
+					name: "type",
+					displayName: "Type",
+					properties: ["type"],
+					sortProperty: "type"
+				},
+				{
+					name: "value",
+					displayName: "Value",
+					properties: ["value"],
+					sortProperty: "value",
+					defaultWidth: 150
+				},
+				{
+					name: "reason",
+					displayName: "Reason",
+					properties: ["reason"],
+					sortProperty: "reason"
+				}
+			],
+			filters: [
+				{
+					name: "status",
+					displayName: "Status",
+					property: "status",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "type",
+					displayName: "Type",
+					property: "type",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "value",
+					displayName: "Value",
+					property: "value",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "reason",
+					displayName: "Reason",
+					property: "reason",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				}
+			]
 		};
 	},
 	computed: {
@@ -142,15 +230,14 @@ export default {
 		})
 	},
 	mounted() {
-		ws.onConnect(this.init);
-
-		this.socket.on("event:admin.punishment.created", res =>
-			this.punishments.push(res.data.punishment)
-		);
+		// ws.onConnect(this.init);
+		// this.socket.on("event:admin.punishment.created", res =>
+		// 	this.punishments.push(res.data.punishment)
+		// );
 	},
 	methods: {
-		view(punishment) {
-			this.viewingPunishmentId = punishment._id;
+		view(punishmentId) {
+			this.viewingPunishmentId = punishmentId;
 			this.openModal("viewPunishment");
 		},
 		banIP() {
@@ -164,14 +251,9 @@ export default {
 				}
 			);
 		},
-		init() {
-			this.socket.dispatch("punishments.index", res => {
-				if (res.status === "success")
-					this.punishments = res.data.punishments;
-			});
-
-			this.socket.dispatch("apis.joinAdminRoom", "punishments", () => {});
-		},
+		// init() {
+		// 	this.socket.dispatch("apis.joinAdminRoom", "punishments", () => {});
+		// },
 		...mapActions("modalVisibility", ["openModal"]),
 		...mapActions("admin/punishments", ["viewPunishment"])
 	}
@@ -180,30 +262,6 @@ export default {
 
 <style lang="scss" scoped>
 .night-mode {
-	.table {
-		color: var(--light-grey-2);
-		background-color: var(--dark-grey-3);
-
-		thead tr {
-			background: var(--dark-grey-3);
-			td {
-				color: var(--white);
-			}
-		}
-
-		tbody tr:hover {
-			background-color: var(--dark-grey-4) !important;
-		}
-
-		tbody tr:nth-child(even) {
-			background-color: var(--dark-grey-2);
-		}
-
-		strong {
-			color: var(--light-grey-2);
-		}
-	}
-
 	.card {
 		background: var(--dark-grey-3);
 
@@ -233,12 +291,9 @@ export default {
 	.button.is-primary {
 		width: 100%;
 	}
-}
 
-td {
-	vertical-align: middle;
-}
-select {
-	margin-bottom: 10px;
+	select {
+		margin-bottom: 10px;
+	}
 }
 </style>

+ 223 - 152
frontend/src/pages/Admin/tabs/Reports.vue

@@ -2,78 +2,95 @@
 	<div>
 		<page-metadata title="Admin | Reports" />
 		<div class="container">
-			<table class="table">
-				<thead>
-					<tr>
-						<td>Summary</td>
-						<td>YouTube / Song ID</td>
-						<td>Categories Included</td>
-						<td>Options</td>
-					</tr>
-				</thead>
-				<tbody>
-					<tr v-for="report in reports" :key="report._id">
-						<td>
-							<report-info-item
-								:created-at="report.createdAt"
-								:created-by="report.createdBy"
-							/>
-						</td>
-						<td>
-							<span>
-								<a
-									:href="
-										'https://www.youtube.com/watch?v=' +
-										`${report.song.youtubeId}`
-									"
-									target="_blank"
-								>
-									{{ report.song.youtubeId }}</a
-								>
-								<br />
-								{{ report.song._id }}
-							</span>
-						</td>
-
-						<td id="categories-column">
-							<ul>
-								<li
-									v-for="category in getCategories(
-										report.issues
-									)"
-									:key="category"
-								>
-									{{ category }}
-								</li>
-							</ul>
-						</td>
-						<td id="options-column">
-							<button
-								class="button is-primary"
-								@click="view(report._id)"
-								content="Expand"
-								v-tippy
-							>
-								<i class="material-icons icon-with-button">
-									open_in_full
-								</i>
-								Expand
-							</button>
-							<button
-								class="button is-success"
-								@click="resolve(report._id)"
-								content="Resolve"
-								v-tippy
-							>
-								<i class="material-icons icon-with-button">
-									done_all
-								</i>
-								Resolve
-							</button>
-						</td>
-					</tr>
-				</tbody>
-			</table>
+			<advanced-table
+				:column-default="columnDefault"
+				:columns="columns"
+				:filters="filters"
+				data-action="reports.getData"
+				name="admin-reports"
+				max-width="1200"
+			>
+				<template #column-options="slotProps">
+					<div class="row-options">
+						<button
+							class="
+								button
+								is-primary
+								icon-with-button
+								material-icons
+							"
+							@click="view(slotProps.item._id)"
+							content="View Report"
+							v-tippy
+						>
+							open_in_full
+						</button>
+						<button
+							class="
+								button
+								is-success
+								icon-with-button
+								material-icons
+							"
+							@click="resolve(slotProps.item._id)"
+							content="Resolve Report"
+							v-tippy
+						>
+							done_all
+						</button>
+					</div>
+				</template>
+				<template #column-_id="slotProps">
+					<span :title="slotProps.item._id">{{
+						slotProps.item._id
+					}}</span>
+				</template>
+				<template #column-songId="slotProps">
+					<span :title="slotProps.item.song._id">{{
+						slotProps.item.song._id
+					}}</span>
+				</template>
+				<template #column-songYoutubeId="slotProps">
+					<a
+						:href="
+							'https://www.youtube.com/watch?v=' +
+							`${slotProps.item.song.youtubeId}`
+						"
+						target="_blank"
+					>
+						{{ slotProps.item.song.youtubeId }}
+					</a>
+				</template>
+				<template #column-categories="slotProps">
+					<span
+						:title="
+							slotProps.item.issues
+								.map(issue => issue.category)
+								.join(', ')
+						"
+						>{{
+							slotProps.item.issues
+								.map(issue => issue.category)
+								.join(", ")
+						}}</span
+					>
+				</template>
+				<template #column-createdBy="slotProps">
+					<span v-if="slotProps.item.createdBy === 'Musare'"
+						>Musare</span
+					>
+					<user-id-to-username
+						v-else
+						:user-id="slotProps.item.createdBy"
+						:link="true"
+					/>
+				</template>
+				<template #column-createdAt="slotProps">
+					<span :title="new Date(slotProps.item.createdAt)">{{
+						getDateFormatted(slotProps.item.createdAt)
+					}}</span>
+				</template>
+			</advanced-table>
 		</div>
 
 		<view-report v-if="modals.viewReport" sector="admin" />
@@ -87,9 +104,9 @@ import { mapState, mapActions, mapGetters } from "vuex";
 import { defineAsyncComponent } from "vue";
 
 import Toast from "toasters";
-import ws from "@/ws";
 
-import ReportInfoItem from "@/components/ReportInfoItem.vue";
+import AdvancedTable from "@/components/AdvancedTable.vue";
+import UserIdToUsername from "@/components/UserIdToUsername.vue";
 
 export default {
 	components: {
@@ -102,11 +119,120 @@ export default {
 		EditSong: defineAsyncComponent(() =>
 			import("@/components/modals/EditSong/index.vue")
 		),
-		ReportInfoItem
+		AdvancedTable,
+		UserIdToUsername
 	},
 	data() {
 		return {
-			reports: []
+			columnDefault: {
+				sortable: true,
+				hidable: true,
+				defaultVisibility: "shown",
+				draggable: true,
+				resizable: true,
+				minWidth: 150,
+				maxWidth: 600
+			},
+			columns: [
+				{
+					name: "options",
+					displayName: "Edit",
+					properties: ["_id"],
+					sortable: false,
+					hidable: false,
+					resizable: false,
+					minWidth: 85,
+					defaultWidth: 85
+				},
+				{
+					name: "_id",
+					displayName: "Report ID",
+					properties: ["_id"],
+					sortProperty: "_id",
+					minWidth: 215,
+					defaultWidth: 215
+				},
+				{
+					name: "songId",
+					displayName: "Song ID",
+					properties: ["song"],
+					sortProperty: "song._id",
+					minWidth: 215,
+					defaultWidth: 215
+				},
+				{
+					name: "songYoutubeId",
+					displayName: "Song YouTube ID",
+					properties: ["song"],
+					sortProperty: "song.youtubeId",
+					minWidth: 165,
+					defaultWidth: 165
+				},
+				{
+					name: "categories",
+					displayName: "Categories",
+					properties: ["issues"],
+					sortable: false
+				},
+				{
+					name: "createdBy",
+					displayName: "Created By",
+					properties: ["createdBy"],
+					sortProperty: "createdBy",
+					defaultWidth: 150
+				},
+				{
+					name: "createdAt",
+					displayName: "Created At",
+					properties: ["createdAt"],
+					sortProperty: "createdAt",
+					defaultWidth: 150
+				}
+			],
+			filters: [
+				{
+					name: "_id",
+					displayName: "Report ID",
+					property: "_id",
+					filterTypes: ["exact"],
+					defaultFilterType: "exact"
+				},
+				{
+					name: "songId",
+					displayName: "Song ID",
+					property: "song._id",
+					filterTypes: ["exact"],
+					defaultFilterType: "exact"
+				},
+				{
+					name: "songYoutubeId",
+					displayName: "Song YouTube ID",
+					property: "song.youtubeId",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "categories",
+					displayName: "Categories",
+					property: "issues.category",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "createdBy",
+					displayName: "Created By",
+					property: "createdBy",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "createdAt",
+					displayName: "Created At",
+					property: "createdAt",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				}
+			]
 		};
 	},
 	computed: {
@@ -118,36 +244,20 @@ export default {
 		})
 	},
 	mounted() {
-		ws.onConnect(this.init);
-
-		this.socket.on("event:admin.report.resolved", res => {
-			this.reports = this.reports.filter(
-				report => report._id !== res.data.reportId
-			);
-		});
-
-		this.socket.on("event:admin.report.created", res =>
-			this.reports.unshift(res.data.report)
-		);
+		// ws.onConnect(this.init);
+		// this.socket.on("event:admin.report.resolved", res => {
+		// 	this.reports = this.reports.filter(
+		// 		report => report._id !== res.data.reportId
+		// 	);
+		// });
+		// this.socket.on("event:admin.report.created", res =>
+		// 	this.reports.unshift(res.data.report)
+		// );
 	},
 	methods: {
-		init() {
-			this.socket.dispatch("reports.index", res => {
-				if (res.status === "success") this.reports = res.data.reports;
-			});
-
-			this.socket.dispatch("apis.joinAdminRoom", "reports", () => {});
-		},
-		getCategories(issues) {
-			const categories = [];
-
-			issues.forEach(issue => {
-				if (categories.indexOf(issue.category) === -1)
-					categories.push(issue.category);
-			});
-
-			return categories;
-		},
+		// init() {
+		// 	this.socket.dispatch("apis.joinAdminRoom", "reports", () => {});
+		// },
 		view(reportId) {
 			this.viewReport(reportId);
 			this.openModal("viewReport");
@@ -160,57 +270,18 @@ export default {
 				})
 				.catch(err => new Toast(err.message));
 		},
+		getDateFormatted(createdAt) {
+			const date = new Date(createdAt);
+			const year = date.getFullYear();
+			const month = `${date.getMonth() + 1}`.padStart(2, 0);
+			const day = `${date.getDate()}`.padStart(2, 0);
+			const hour = `${date.getHours()}`.padStart(2, 0);
+			const minute = `${date.getMinutes()}`.padStart(2, 0);
+			return `${year}-${month}-${day} ${hour}:${minute}`;
+		},
 		...mapActions("modalVisibility", ["openModal", "closeModal"]),
 		...mapActions("admin/reports", ["resolveReport"]),
 		...mapActions("modals/viewReport", ["viewReport"])
 	}
 };
 </script>
-
-<style lang="scss" scoped>
-.night-mode {
-	.table {
-		color: var(--light-grey-2);
-		background-color: var(--dark-grey-3);
-
-		thead tr {
-			background: var(--dark-grey-3);
-			td {
-				color: var(--white);
-			}
-		}
-
-		tbody tr:hover {
-			background-color: var(--dark-grey-4) !important;
-		}
-
-		tbody tr:nth-child(even) {
-			background-color: var(--dark-grey-2);
-		}
-
-		strong {
-			color: var(--light-grey-2);
-		}
-	}
-}
-
-#options-column {
-	button:not(:last-of-type) {
-		margin-right: 5px;
-	}
-}
-
-#categories-column {
-	text-transform: capitalize;
-}
-
-td {
-	word-wrap: break-word;
-	max-width: 10vw;
-	vertical-align: middle;
-}
-
-li {
-	list-style: inside;
-}
-</style>

+ 88 - 3
frontend/src/pages/Admin/tabs/Songs.vue

@@ -113,6 +113,22 @@
 						:link="true"
 					/>
 				</template>
+				<template #column-requestedAt="slotProps">
+					<span :title="new Date(slotProps.item.requestedAt)">{{
+						getDateFormatted(slotProps.item.requestedAt)
+					}}</span>
+				</template>
+				<template #column-verifiedBy="slotProps">
+					<user-id-to-username
+						:user-id="slotProps.item.verifiedBy"
+						:link="true"
+					/>
+				</template>
+				<template #column-verifiedAt="slotProps">
+					<span :title="new Date(slotProps.item.verifiedAt)">{{
+						getDateFormatted(slotProps.item.verifiedAt)
+					}}</span>
+				</template>
 				<template #bulk-actions="slotProps">
 					<div class="bulk-actions">
 						<i
@@ -364,7 +380,7 @@ export default {
 				},
 				{
 					name: "_id",
-					displayName: "Musare ID",
+					displayName: "Song ID",
 					properties: ["_id"],
 					sortProperty: "_id",
 					minWidth: 215,
@@ -399,13 +415,38 @@ export default {
 					displayName: "Requested By",
 					properties: ["requestedBy"],
 					sortProperty: "requestedBy",
-					defaultWidth: 200
+					defaultWidth: 200,
+					defaultVisibility: "hidden"
+				},
+				{
+					name: "requestedAt",
+					displayName: "Requested At",
+					properties: ["requestedAt"],
+					sortProperty: "requestedAt",
+					defaultWidth: 200,
+					defaultVisibility: "hidden"
+				},
+				{
+					name: "verifiedBy",
+					displayName: "Verified By",
+					properties: ["verifiedBy"],
+					sortProperty: "verifiedBy",
+					defaultWidth: 200,
+					defaultVisibility: "hidden"
+				},
+				{
+					name: "verifiedAt",
+					displayName: "Verified At",
+					properties: ["verifiedAt"],
+					sortProperty: "verifiedAt",
+					defaultWidth: 200,
+					defaultVisibility: "hidden"
 				}
 			],
 			filters: [
 				{
 					name: "_id",
-					displayName: "Musare ID",
+					displayName: "Song ID",
 					property: "_id",
 					filterTypes: ["exact"],
 					defaultFilterType: "exact"
@@ -452,6 +493,27 @@ export default {
 					filterTypes: ["contains", "exact", "regex"],
 					defaultFilterType: "contains"
 				},
+				{
+					name: "requestedAt",
+					displayName: "Requested At",
+					property: "requestedAt",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "verifiedBy",
+					displayName: "Verified By",
+					property: "verifiedBy",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "verifiedAt",
+					displayName: "Verified At",
+					property: "verifiedAt",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
 				{
 					name: "status",
 					displayName: "Status",
@@ -472,6 +534,20 @@ export default {
 					property: "dislikes",
 					filterTypes: ["contains", "exact", "regex"],
 					defaultFilterType: "exact"
+				},
+				{
+					name: "duration",
+					displayName: "Duration",
+					property: "duration",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "exact"
+				},
+				{
+					name: "skipDuration",
+					displayName: "Skip Duration",
+					property: "skipDuration",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "exact"
 				}
 			],
 			jobs: [
@@ -610,6 +686,15 @@ export default {
 		resetKeyboardShortcutsHelper() {
 			this.$refs.keyboardShortcutsHelper.resetBox();
 		},
+		getDateFormatted(createdAt) {
+			const date = new Date(createdAt);
+			const year = date.getFullYear();
+			const month = `${date.getMonth() + 1}`.padStart(2, 0);
+			const day = `${date.getDate()}`.padStart(2, 0);
+			const hour = `${date.getHours()}`.padStart(2, 0);
+			const minute = `${date.getMinutes()}`.padStart(2, 0);
+			return `${year}-${month}-${day} ${hour}:${minute}`;
+		},
 		...mapActions("modals/editSong", ["editSong"]),
 		...mapActions("modalVisibility", ["openModal"])
 	}

+ 2 - 2
frontend/src/pages/Admin/tabs/Stations.vue

@@ -170,7 +170,7 @@ export default {
 				},
 				{
 					name: "_id",
-					displayName: "ID",
+					displayName: "Station ID",
 					properties: ["_id"],
 					sortProperty: "_id",
 					minWidth: 230,
@@ -239,7 +239,7 @@ export default {
 			filters: [
 				{
 					name: "_id",
-					displayName: "ID",
+					displayName: "Station ID",
 					property: "_id",
 					filterTypes: ["exact"],
 					defaultFilterType: "exact"

+ 397 - 157
frontend/src/pages/Admin/tabs/Users.vue

@@ -2,90 +2,148 @@
 	<div>
 		<page-metadata title="Admin | Users" />
 		<div class="container">
-			<h2 v-if="dataRequests.length > 0">Data Requests</h2>
+			<h2>Data Requests</h2>
 
-			<table class="table" v-if="dataRequests.length > 0">
-				<thead>
-					<tr>
-						<td>User ID</td>
-						<td>Request Type</td>
-						<td>Options</td>
-					</tr>
-				</thead>
-				<tbody>
-					<tr v-for="(request, index) in dataRequests" :key="index">
-						<td>{{ request.userId }}</td>
-						<td>
-							{{
-								request.type === "remove"
-									? "Remove all associated data"
-									: request.type
-							}}
-						</td>
-						<td>
+			<advanced-table
+				:column-default="dataRequests.columnDefault"
+				:columns="dataRequests.columns"
+				:filters="dataRequests.filters"
+				data-action="dataRequests.getData"
+				name="admin-data-requests"
+				max-width="1200"
+			>
+				<template #column-options="slotProps">
+					<div class="row-options">
+						<quick-confirm
+							placement="right"
+							@confirm="resolveDataRequest(slotProps.item._id)"
+						>
 							<button
-								class="button is-primary"
-								@click="resolveDataRequest(request._id)"
+								class="
+									button
+									is-success
+									icon-with-button
+									material-icons
+								"
+								content="Resolve Data Request"
+								v-tippy
 							>
-								Resolve
+								done_all
 							</button>
-						</td>
-					</tr>
-				</tbody>
-			</table>
+						</quick-confirm>
+					</div>
+				</template>
+				<template #column-type="slotProps">
+					<span
+						:title="
+							slotProps.item.type
+								? 'Remove all associated data'
+								: slotProps.item.type
+						"
+						>{{
+							slotProps.item.type
+								? "Remove all associated data"
+								: slotProps.item.type
+						}}</span
+					>
+				</template>
+				<template #column-userId="slotProps">
+					<span :title="slotProps.item.userId">{{
+						slotProps.item.userId
+					}}</span>
+				</template>
+				<template #column-_id="slotProps">
+					<span :title="slotProps.item._id">{{
+						slotProps.item._id
+					}}</span>
+				</template>
+			</advanced-table>
 
 			<h1 id="page-title">Users</h1>
 
-			<table class="table">
-				<thead>
-					<tr>
-						<td class="ppRow">Profile Picture</td>
-						<td>User ID</td>
-						<td>GitHub ID</td>
-						<td>Password</td>
-						<td>Username</td>
-						<td>Role</td>
-						<td>Email Address</td>
-						<td>Email Verified</td>
-						<td>Songs Requested</td>
-						<td>Options</td>
-					</tr>
-				</thead>
-				<tbody>
-					<tr v-for="user in users" :key="user._id">
-						<td>
-							<profile-picture
-								:avatar="user.avatar"
-								:name="user.name ? user.name : user.username"
-							/>
-						</td>
-						<td>{{ user._id }}</td>
-						<td v-if="user.services.github">
-							{{ user.services.github.id }}
-						</td>
-						<td v-else>Not Linked</td>
-						<td v-if="user.hasPassword">Yes</td>
-						<td v-else>Not Linked</td>
-						<td>
-							<a :href="'/u/' + user.username" target="_blank">{{
-								user.username
-							}}</a>
-						</td>
-						<td>{{ user.role }}</td>
-						<td>{{ user.email.address }}</td>
-						<td>{{ user.email.verified }}</td>
-						<td>{{ user.songsRequested }}</td>
-						<td>
-							<button
-								class="button is-primary"
-								@click="edit(user)"
-							>
-								Edit
-							</button>
-						</td>
-					</tr>
-				</tbody>
-			</table>
+			<advanced-table
+				:column-default="users.columnDefault"
+				:columns="users.columns"
+				:filters="users.filters"
+				data-action="users.getData"
+				name="admin-users"
+				max-width="1200"
+			>
+				<template #column-options="slotProps">
+					<div class="row-options">
+						<button
+							class="
+								button
+								is-primary
+								icon-with-button
+								material-icons
+							"
+							@click="edit(slotProps.item._id)"
+							content="Edit User"
+							v-tippy
+						>
+							edit
+						</button>
+					</div>
+				</template>
+				<template #column-profilePicture="slotProps">
+					<profile-picture
+						:avatar="slotProps.item.avatar"
+						:name="
+							slotProps.item.name
+								? slotProps.item.name
+								: slotProps.item.username
+						"
+					/>
+				</template>
+				<template #column-name="slotProps">
+					<span :title="slotProps.item.name">{{
+						slotProps.item.name
+					}}</span>
+				</template>
+				<template #column-username="slotProps">
+					<span :title="slotProps.item.username">{{
+						slotProps.item.username
+					}}</span>
+				</template>
+				<template #column-_id="slotProps">
+					<span :title="slotProps.item._id">{{
+						slotProps.item._id
+					}}</span>
+				</template>
+				<template #column-githubId="slotProps">
+					<span
+						v-if="slotProps.item.services.github"
+						:title="slotProps.item.services.github.id"
+						>{{ slotProps.item.services.github.id }}</span
+					>
+				</template>
+				<!-- <template #column-hasPassword="slotProps">
+					<span :title="slotProps.item.hasPassword">{{
+						slotProps.item.hasPassword
+					}}</span>
+				</template> -->
+				<template #column-role="slotProps">
+					<span :title="slotProps.item.role">{{
+						slotProps.item.role
+					}}</span>
+				</template>
+				<template #column-emailAddress="slotProps">
+					<span :title="slotProps.item.email.address">{{
+						slotProps.item.email.address
+					}}</span>
+				</template>
+				<template #column-emailVerified="slotProps">
+					<span :title="slotProps.item.email.verified">{{
+						slotProps.item.email.verified
+					}}</span>
+				</template>
+				<template #column-songsRequested="slotProps">
+					<span :title="slotProps.item.statistics.songsRequested">{{
+						slotProps.item.statistics.songsRequested
+					}}</span>
+				</template>
+			</advanced-table>
 		</div>
 		<edit-user
 			v-if="modals.editUser"
@@ -100,21 +158,250 @@ import { mapState, mapActions, mapGetters } from "vuex";
 import { defineAsyncComponent } from "vue";
 import Toast from "toasters";
 
+import AdvancedTable from "@/components/AdvancedTable.vue";
 import ProfilePicture from "@/components/ProfilePicture.vue";
-import ws from "@/ws";
+import QuickConfirm from "@/components/QuickConfirm.vue";
+// import ws from "@/ws";
 
 export default {
 	components: {
 		EditUser: defineAsyncComponent(() =>
 			import("@/components/modals/EditUser.vue")
 		),
-		ProfilePicture
+		AdvancedTable,
+		ProfilePicture,
+		QuickConfirm
 	},
 	data() {
 		return {
 			editingUserId: "",
-			dataRequests: [],
-			users: []
+			dataRequests: {
+				columnDefault: {
+					sortable: true,
+					hidable: true,
+					defaultVisibility: "shown",
+					draggable: true,
+					resizable: true,
+					minWidth: 150,
+					maxWidth: 600
+				},
+				columns: [
+					{
+						name: "options",
+						displayName: "Edit",
+						properties: ["_id"],
+						sortable: false,
+						hidable: false,
+						resizable: false,
+						minWidth: 51,
+						defaultWidth: 51
+					},
+					{
+						name: "type",
+						displayName: "Type",
+						properties: ["type"],
+						sortProperty: "type"
+					},
+					{
+						name: "userId",
+						displayName: "User ID",
+						properties: ["userId"],
+						sortProperty: "userId"
+					},
+					{
+						name: "_id",
+						displayName: "Request ID",
+						properties: ["_id"],
+						sortProperty: "_id"
+					}
+				],
+				filters: [
+					{
+						name: "_id",
+						displayName: "Request ID",
+						property: "_id",
+						filterTypes: ["exact"],
+						defaultFilterType: "exact"
+					},
+					{
+						name: "userId",
+						displayName: "User ID",
+						property: "userId",
+						filterTypes: ["contains", "exact", "regex"],
+						defaultFilterType: "contains"
+					},
+					{
+						name: "type",
+						displayName: "Type",
+						property: "type",
+						filterTypes: ["contains", "exact", "regex"],
+						defaultFilterType: "contains"
+					}
+				]
+			},
+			users: {
+				columnDefault: {
+					sortable: true,
+					hidable: true,
+					defaultVisibility: "shown",
+					draggable: true,
+					resizable: true,
+					minWidth: 150,
+					maxWidth: 600
+				},
+				columns: [
+					{
+						name: "options",
+						displayName: "Edit",
+						properties: ["_id"],
+						sortable: false,
+						hidable: false,
+						resizable: false,
+						minWidth: 51,
+						defaultWidth: 51
+					},
+					{
+						name: "profilePicture",
+						displayName: "Image",
+						properties: ["avatar", "name", "username"],
+						sortable: false,
+						resizable: false,
+						minWidth: 71,
+						defaultWidth: 71
+					},
+					{
+						name: "name",
+						displayName: "Display Name",
+						properties: ["name"],
+						sortProperty: "name"
+					},
+					{
+						name: "username",
+						displayName: "Username",
+						properties: ["username"],
+						sortProperty: "username"
+					},
+					{
+						name: "_id",
+						displayName: "User ID",
+						properties: ["_id"],
+						sortProperty: "_id",
+						minWidth: 230,
+						defaultWidth: 230
+					},
+					{
+						name: "githubId",
+						displayName: "GitHub ID",
+						properties: ["services"],
+						sortProperty: "services.github.id",
+						minWidth: 115,
+						defaultWidth: 115
+					},
+					// {
+					// 	name: "hasPassword",
+					// 	displayName: "Has Password",
+					// 	properties: ["hasPassword"],
+					// 	sortProperty: "hasPassword"
+					// }
+					{
+						name: "role",
+						displayName: "Role",
+						properties: ["role"],
+						sortProperty: "role",
+						minWidth: 90,
+						defaultWidth: 90
+					},
+					{
+						name: "emailAddress",
+						displayName: "Email Address",
+						properties: ["email"],
+						sortProperty: "email.address",
+						defaultVisibility: "hidden"
+					},
+					{
+						name: "emailVerified",
+						displayName: "Email Verified",
+						properties: ["email"],
+						sortProperty: "email.verified",
+						defaultVisibility: "hidden",
+						minWidth: 140,
+						defaultWidth: 140
+					},
+					{
+						name: "songsRequested",
+						displayName: "Songs Requested",
+						properties: ["statistics"],
+						sortProperty: "statistics.songsRequested",
+						minWidth: 170,
+						defaultWidth: 170
+					}
+				],
+				filters: [
+					{
+						name: "_id",
+						displayName: "User ID",
+						property: "_id",
+						filterTypes: ["exact"],
+						defaultFilterType: "exact"
+					},
+					{
+						name: "name",
+						displayName: "Display Name",
+						property: "name",
+						filterTypes: ["contains", "exact", "regex"],
+						defaultFilterType: "contains"
+					},
+					{
+						name: "username",
+						displayName: "Username",
+						property: "username",
+						filterTypes: ["contains", "exact", "regex"],
+						defaultFilterType: "contains"
+					},
+					{
+						name: "githubId",
+						displayName: "GitHub ID",
+						property: "services.github.id",
+						filterTypes: ["contains", "exact", "regex"],
+						defaultFilterType: "contains"
+					},
+					// {
+					// 	name: "hasPassword",
+					// 	displayName: "Has Password",
+					// 	property: "hasPassword",
+					// 	filterTypes: ["contains", "exact", "regex"],
+					// 	defaultFilterType: "contains"
+					// },
+					{
+						name: "role",
+						displayName: "Role",
+						property: "role",
+						filterTypes: ["contains", "exact", "regex"],
+						defaultFilterType: "contains"
+					},
+					{
+						name: "emailAddress",
+						displayName: "Email Address",
+						property: "email.address",
+						filterTypes: ["contains", "exact", "regex"],
+						defaultFilterType: "contains"
+					},
+					{
+						name: "emailVerified",
+						displayName: "Email Verified",
+						property: "email.verified",
+						filterTypes: ["contains", "exact", "regex"],
+						defaultFilterType: "contains"
+					},
+					{
+						name: "songsRequested",
+						displayName: "Songs Requested",
+						property: "statistics.songsRequested",
+						filterTypes: ["contains", "exact", "regex"],
+						defaultFilterType: "contains"
+					}
+				]
+			}
 		};
 	},
 	computed: {
@@ -126,49 +413,40 @@ export default {
 		})
 	},
 	mounted() {
-		ws.onConnect(this.init);
-
-		this.socket.on("event:admin.dataRequests.created", res =>
-			this.dataRequests.push(res.data.request)
-		);
-
-		this.socket.on("event:admin.dataRequests.resolved", res => {
-			this.dataRequests = this.dataRequests.filter(
-				request => request._id !== res.data.dataRequestId
-			);
-		});
-
-		this.socket.on("event:user.removed", res => {
-			this.users = this.users.filter(
-				user => user._id !== res.data.userId
-			);
-		});
+		// ws.onConnect(this.init);
+		// this.socket.on("event:admin.dataRequests.created", res =>
+		// 	this.dataRequests.push(res.data.request)
+		// );
+		// this.socket.on("event:admin.dataRequests.resolved", res => {
+		// 	this.dataRequests = this.dataRequests.filter(
+		// 		request => request._id !== res.data.dataRequestId
+		// 	);
+		// });
+		// this.socket.on("event:user.removed", res => {
+		// 	this.users = this.users.filter(
+		// 		user => user._id !== res.data.userId
+		// 	);
+		// });
 	},
 	methods: {
-		edit(user) {
-			this.editingUserId = user._id;
+		edit(userId) {
+			this.editingUserId = userId;
 			this.openModal("editUser");
 		},
-		init() {
-			this.socket.dispatch("users.index", res => {
-				if (res.status === "success") {
-					this.users = res.data.users;
-					if (this.$route.query.userId) {
-						const user = this.users.find(
-							user => user._id === this.$route.query.userId
-						);
-						if (user) this.edit(user);
-					}
-				}
-			});
-
-			this.socket.dispatch("dataRequests.index", res => {
-				if (res.status === "success")
-					this.dataRequests = res.data.requests;
-			});
+		// init() {
+		// 	this.socket.dispatch("users.index", res => {
+		// 		if (res.status === "success") {
+		// 			if (this.$route.query.userId) {
+		// 				const user = res.data.users.find(
+		// 					user => user._id === this.$route.query.userId
+		// 				);
+		// 				if (user) this.edit(user._id);
+		// 			}
+		// 		}
+		// 	});
 
-			this.socket.dispatch("apis.joinAdminRoom", "users", () => {});
-		},
+		// 	this.socket.dispatch("apis.joinAdminRoom", "users", () => {});
+		// },
 		resolveDataRequest(id) {
 			this.socket.dispatch("dataRequests.resolve", id, res => {
 				if (res.status === "success") new Toast(res.message);
@@ -180,32 +458,6 @@ export default {
 </script>
 
 <style lang="scss" scoped>
-.night-mode {
-	.table {
-		color: var(--light-grey-2);
-		background-color: var(--dark-grey-3);
-
-		thead tr {
-			background: var(--dark-grey-3);
-			td {
-				color: var(--white);
-			}
-		}
-
-		tbody tr:hover {
-			background-color: var(--dark-grey-4) !important;
-		}
-
-		tbody tr:nth-child(even) {
-			background-color: var(--dark-grey-2);
-		}
-
-		strong {
-			color: var(--light-grey-2);
-		}
-	}
-}
-
 #page-title {
 	margin: 30px 0;
 }
@@ -227,16 +479,4 @@ h2 {
 /deep/ .profile-picture.using-initials span {
 	font-size: 20px; // 2/5th of .profile-picture height/width
 }
-
-td {
-	vertical-align: middle;
-
-	&.ppRow {
-		max-width: 50px;
-	}
-}
-
-.is-primary:focus {
-	background-color: var(--primary-color) !important;
-}
 </style>

+ 1 - 1
frontend/src/pages/News.vue

@@ -102,7 +102,7 @@ export default {
 		sanitize,
 		formatDistance,
 		init() {
-			this.socket.dispatch("news.index", res => {
+			this.socket.dispatch("news.getPublished", res => {
 				if (res.status === "success") this.news = res.data.news;
 			});