Sfoglia il codice sorgente

refactor(Admin): Replaced secondary nav with sidebar and reorganised pages

Owen Diffey 3 anni fa
parent
commit
ee483d404c

+ 1 - 0
.wiki/Configuration.md

@@ -70,6 +70,7 @@ Location: `frontend/dist/config/default.json`
 | `cookie.SIDname` | Name of the cookie stored for sessions. |
 | `siteSettings.logo_white` | Path to the white logo image, by default it is `/assets/white_wordmark.png`. |
 | `siteSettings.logo_blue` | Path to the blue logo image, by default it is `/assets/blue_wordmark.png`. |
+| `siteSettings.logo_small` | Path to the small white logo image, by default it is `/assets/favicon/mstile-144x144.png`. |
 | `siteSettings.sitename` | Should be the name of the site. |
 | `siteSettings.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`. |
 | `siteSettings.mediasession` | Whether to enable mediasession functionality. |

+ 2 - 1
frontend/dist/config/template.json

@@ -21,6 +21,7 @@
 	"siteSettings": {
 		"logo_white": "/assets/white_wordmark.png",
 		"logo_blue": "/assets/blue_wordmark.png",
+		"logo_small": "/assets/favicon/mstile-144x144.png",
 		"sitename": "Musare",
 		"footerLinks": {
 			"GitHub": "https://github.com/Musare/Musare"
@@ -52,5 +53,5 @@
 		"version": true
 	},
 	"skipConfigVersionCheck": false,
-	"configVersion": 10
+	"configVersion": 11
 }

+ 3 - 3
frontend/src/main.js

@@ -11,7 +11,7 @@ import store from "./store";
 
 import AppComponent from "./App.vue";
 
-const REQUIRED_CONFIG_VERSION = 10;
+const REQUIRED_CONFIG_VERSION = 11;
 
 const handleMetadata = attrs => {
 	document.title = `Musare | ${attrs.title}`;
@@ -148,7 +148,7 @@ const router = createRouter({
 			}
 		},
 		{
-			path: "/admin/:page",
+			path: "/admin/:page(.*)",
 			component: () => import("@/pages//Admin/index.vue"),
 			meta: {
 				adminRequired: true
@@ -202,7 +202,7 @@ router.beforeEach((to, from, next) => {
 
 app.use(router);
 
-lofig.folder = "../config/default.json";
+lofig.folder = "/config/default.json";
 
 (async () => {
 	lofig.fetchConfig().then(config => {

+ 0 - 0
frontend/src/pages/Admin/tabs/News.vue → frontend/src/pages/Admin/News.vue


+ 1 - 1
frontend/src/pages/Admin/tabs/Playlists.vue → frontend/src/pages/Admin/Playlists.vue

@@ -93,7 +93,7 @@ import AdvancedTable from "@/components/AdvancedTable.vue";
 import RunJobDropdown from "@/components/RunJobDropdown.vue";
 import UserIdToUsername from "@/components/UserIdToUsername.vue";
 
-import utils from "../../../../js/utils";
+import utils from "../../../js/utils";
 
 export default {
 	components: {

+ 1 - 1
frontend/src/pages/Admin/tabs/Reports.vue → frontend/src/pages/Admin/Songs/Reports.vue

@@ -1,6 +1,6 @@
 <template>
 	<div>
-		<page-metadata title="Admin | Reports" />
+		<page-metadata title="Admin | Songs | Reports" />
 		<div class="container">
 			<advanced-table
 				:column-default="columnDefault"

+ 0 - 0
frontend/src/pages/Admin/tabs/Songs.vue → frontend/src/pages/Admin/Songs/index.vue


+ 0 - 0
frontend/src/pages/Admin/tabs/Stations.vue → frontend/src/pages/Admin/Stations.vue


+ 0 - 0
frontend/src/pages/Admin/tabs/Statistics.vue → frontend/src/pages/Admin/Statistics.vue


+ 151 - 0
frontend/src/pages/Admin/Users/DataRequests.vue

@@ -0,0 +1,151 @@
+<template>
+	<div>
+		<page-metadata title="Admin | Users | Data Requests" />
+		<div class="container">
+			<advanced-table
+				:column-default="columnDefault"
+				:columns="columns"
+				:filters="filters"
+				data-action="dataRequests.getData"
+				name="admin-data-requests"
+				max-width="1200"
+				:events="events"
+			>
+				<template #column-options="slotProps">
+					<div class="row-options">
+						<quick-confirm
+							placement="right"
+							@confirm="resolveDataRequest(slotProps.item._id)"
+							:disabled="slotProps.item.removed"
+						>
+							<button
+								class="button is-success icon-with-button material-icons"
+								content="Resolve Data Request"
+								v-tippy
+							>
+								done_all
+							</button>
+						</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>
+		</div>
+	</div>
+</template>
+
+<script>
+import { mapGetters } from "vuex";
+import Toast from "toasters";
+
+import AdvancedTable from "@/components/AdvancedTable.vue";
+import QuickConfirm from "@/components/QuickConfirm.vue";
+
+export default {
+	components: {
+		AdvancedTable,
+		QuickConfirm
+	},
+	data() {
+		return {
+			columnDefault: {
+				sortable: true,
+				hidable: true,
+				defaultVisibility: "shown",
+				draggable: true,
+				resizable: true,
+				minWidth: 150,
+				maxWidth: 600
+			},
+			columns: [
+				{
+					name: "options",
+					displayName: "Options",
+					properties: ["_id"],
+					sortable: false,
+					hidable: false,
+					resizable: false,
+					minWidth: 76,
+					defaultWidth: 76
+				},
+				{
+					name: "type",
+					displayName: "Type",
+					properties: ["type"],
+					sortable: false
+				},
+				{
+					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"
+				}
+			],
+			events: {
+				adminRoom: "users",
+				removed: {
+					event: "admin.dataRequests.resolved",
+					id: "dataRequestId"
+				}
+			}
+		};
+	},
+	computed: {
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	methods: {
+		resolveDataRequest(id) {
+			this.socket.dispatch("dataRequests.resolve", id, res => {
+				if (res.status === "success") new Toast(res.message);
+			});
+		}
+	}
+};
+</script>

+ 1 - 1
frontend/src/pages/Admin/tabs/Punishments.vue → frontend/src/pages/Admin/Users/Punishments.vue

@@ -1,6 +1,6 @@
 <template>
 	<div>
-		<page-metadata title="Admin | Punishments" />
+		<page-metadata title="Admin | Users | Punishments" />
 		<div class="container">
 			<advanced-table
 				:column-default="columnDefault"

+ 324 - 0
frontend/src/pages/Admin/Users/index.vue

@@ -0,0 +1,324 @@
+<template>
+	<div>
+		<page-metadata title="Admin | Users" />
+		<div class="container">
+			<advanced-table
+				:column-default="columnDefault"
+				:columns="columns"
+				:filters="filters"
+				data-action="users.getData"
+				name="admin-users"
+				max-width="1200"
+				:events="events"
+			>
+				<template #column-options="slotProps">
+					<div class="row-options">
+						<button
+							class="button is-primary icon-with-button material-icons"
+							@click="edit(slotProps.item._id)"
+							:disabled="slotProps.item.removed"
+							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"
+			:user-id="editingUserId"
+			sector="admin"
+		/>
+	</div>
+</template>
+
+<script>
+import { mapState, mapActions } from "vuex";
+import { defineAsyncComponent } from "vue";
+
+import AdvancedTable from "@/components/AdvancedTable.vue";
+import ProfilePicture from "@/components/ProfilePicture.vue";
+
+export default {
+	components: {
+		EditUser: defineAsyncComponent(() =>
+			import("@/components/modals/EditUser.vue")
+		),
+		AdvancedTable,
+		ProfilePicture
+	},
+	data() {
+		return {
+			editingUserId: "",
+			columnDefault: {
+				sortable: true,
+				hidable: true,
+				defaultVisibility: "shown",
+				draggable: true,
+				resizable: true,
+				minWidth: 150,
+				maxWidth: 600
+			},
+			columns: [
+				{
+					name: "options",
+					displayName: "Options",
+					properties: ["_id"],
+					sortable: false,
+					hidable: false,
+					resizable: false,
+					minWidth: 76,
+					defaultWidth: 76
+				},
+				{
+					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.github.id"],
+					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.address"],
+					sortProperty: "email.address",
+					defaultVisibility: "hidden"
+				},
+				{
+					name: "emailVerified",
+					displayName: "Email Verified",
+					properties: ["email.verified"],
+					sortProperty: "email.verified",
+					defaultVisibility: "hidden",
+					minWidth: 140,
+					defaultWidth: 140
+				},
+				{
+					name: "songsRequested",
+					displayName: "Songs Requested",
+					properties: ["statistics.songsRequested"],
+					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: ["boolean"],
+					defaultFilterType: "boolean"
+				},
+				{
+					name: "role",
+					displayName: "Role",
+					property: "role",
+					filterTypes: ["exact"],
+					defaultFilterType: "exact",
+					dropdown: [
+						["admin", "Admin"],
+						["default", "Default"]
+					]
+				},
+				{
+					name: "emailAddress",
+					displayName: "Email Address",
+					property: "email.address",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "emailVerified",
+					displayName: "Email Verified",
+					property: "email.verified",
+					filterTypes: ["boolean"],
+					defaultFilterType: "boolean"
+				},
+				{
+					name: "songsRequested",
+					displayName: "Songs Requested",
+					property: "statistics.songsRequested",
+					filterTypes: [
+						"numberLesserEqual",
+						"numberLesser",
+						"numberGreater",
+						"numberGreaterEqual",
+						"numberEquals"
+					],
+					defaultFilterType: "numberLesser"
+				}
+			],
+			events: {
+				adminRoom: "users",
+				updated: {
+					event: "admin.user.updated",
+					id: "user._id",
+					item: "user"
+				},
+				removed: {
+					event: "user.removed",
+					id: "userId"
+				}
+			}
+		};
+	},
+	computed: {
+		...mapState("modalVisibility", {
+			modals: state => state.modals
+		})
+	},
+	mounted() {
+		if (this.$route.query.userId) this.edit(this.$route.query.userId);
+	},
+	methods: {
+		edit(userId) {
+			this.editingUserId = userId;
+			this.openModal("editUser");
+		},
+		...mapActions("modalVisibility", ["openModal"])
+	}
+};
+</script>
+
+<style lang="less" scoped>
+.profile-picture {
+	max-width: 50px !important;
+	max-height: 50px !important;
+}
+
+:deep(.profile-picture.using-initials span) {
+	font-size: 20px; // 2/5th of .profile-picture height/width
+}
+</style>

+ 437 - 228
frontend/src/pages/Admin/index.vue

@@ -1,103 +1,190 @@
 <template>
-	<div class="app admin-area">
-		<main-header />
-		<div class="tabs is-centered">
-			<ul>
-				<li
-					:class="{ 'is-active': currentTab == 'songs' }"
-					ref="songs-tab"
-					@click="showTab('songs')"
+	<div class="app">
+		<div class="admin-area">
+			<main-header :hide-logo="true" />
+			<div class="admin-content">
+				<div
+					class="admin-sidebar"
+					:class="{ minimised: !sidebarActive }"
 				>
-					<router-link class="tab songs" to="/admin/songs">
-						<i class="material-icons">music_note</i>
-						<span>&nbsp;Songs</span>
-					</router-link>
-				</li>
-				<li
-					:class="{ 'is-active': currentTab == 'stations' }"
-					ref="stations-tab"
-					@click="showTab('stations')"
-				>
-					<router-link class="tab stations" to="/admin/stations">
-						<i class="material-icons">radio</i>
-						<span>&nbsp;Stations</span>
-					</router-link>
-				</li>
-				<li
-					:class="{ 'is-active': currentTab == 'playlists' }"
-					ref="playlists-tab"
-					@click="showTab('playlists')"
-				>
-					<router-link class="tab playlists" to="/admin/playlists">
-						<i class="material-icons">library_music</i>
-						<span>&nbsp;Playlists</span>
-					</router-link>
-				</li>
-				<li
-					:class="{ 'is-active': currentTab == 'reports' }"
-					ref="reports-tab"
-					@click="showTab('reports')"
-				>
-					<router-link class="tab reports" to="/admin/reports">
-						<i class="material-icons">flag</i>
-						<span>&nbsp;Reports</span>
-					</router-link>
-				</li>
-				<li
-					:class="{ 'is-active': currentTab == 'news' }"
-					ref="news-tab"
-					@click="showTab('news')"
-				>
-					<router-link class="tab news" to="/admin/news">
-						<i class="material-icons">chrome_reader_mode</i>
-						<span>&nbsp;News</span>
-					</router-link>
-				</li>
-				<li
-					:class="{ 'is-active': currentTab == 'users' }"
-					ref="users-tab"
-					@click="showTab('users')"
-				>
-					<router-link class="tab users" to="/admin/users">
-						<i class="material-icons">people</i>
-						<span>&nbsp;Users</span>
-					</router-link>
-				</li>
-				<li
-					:class="{ 'is-active': currentTab == 'statistics' }"
-					ref="statistics-tab"
-					@click="showTab('statistics')"
-				>
-					<router-link class="tab statistics" to="/admin/statistics">
-						<i class="material-icons">show_chart</i>
-						<span>&nbsp;Statistics</span>
-					</router-link>
-				</li>
-				<li
-					:class="{ 'is-active': currentTab == 'punishments' }"
-					ref="punishments-tab"
-					@click="showTab('punishments')"
-				>
-					<router-link
-						class="tab punishments"
-						to="/admin/punishments"
-					>
-						<i class="material-icons">gavel</i>
-						<span>&nbsp;Punishments</span>
-					</router-link>
-				</li>
-			</ul>
-		</div>
+					<div class="inner">
+						<div class="top">
+							<router-link class="sidebar-logo" to="/">
+								<img
+									class="full-logo"
+									:src="siteSettings.logo_white"
+									:alt="siteSettings.sitename || `Musare`"
+								/>
+								<img
+									class="minimised-logo"
+									:src="siteSettings.logo_small"
+									:alt="siteSettings.sitename[0] || `M`"
+								/>
+							</router-link>
+						</div>
+						<div class="bottom">
+							<div
+								class="sidebar-item toggle-sidebar"
+								@click="toggleSidebar()"
+								content="Expand"
+								v-tippy="{ onShow: () => !sidebarActive }"
+							>
+								<i class="material-icons">menu_open</i>
+								<span>Minimise</span>
+							</div>
+							<div
+								v-if="
+									sidebarActive &&
+									currentTab.startsWith('songs')
+								"
+								class="sidebar-item with-children is-active"
+							>
+								<span>
+									<i class="material-icons">music_note</i>
+									<span>Songs</span>
+								</span>
+								<div class="sidebar-item-children">
+									<router-link
+										class="sidebar-item-child"
+										to="/admin/songs"
+									>
+										Songs
+									</router-link>
+									<router-link
+										class="sidebar-item-child"
+										to="/admin/songs/reports"
+									>
+										Reports
+									</router-link>
+								</div>
+							</div>
+							<router-link
+								v-else
+								class="sidebar-item songs"
+								to="/admin/songs"
+								content="Songs"
+								v-tippy="{
+									theme: 'info',
+									onShow: () => !sidebarActive
+								}"
+							>
+								<i class="material-icons">music_note</i>
+								<span>Songs</span>
+							</router-link>
+							<router-link
+								class="sidebar-item stations"
+								to="/admin/stations"
+								content="Stations"
+								v-tippy="{
+									theme: 'info',
+									onShow: () => !sidebarActive
+								}"
+							>
+								<i class="material-icons">radio</i>
+								<span>Stations</span>
+							</router-link>
+							<router-link
+								class="sidebar-item playlists"
+								to="/admin/playlists"
+								content="Playlists"
+								v-tippy="{
+									theme: 'info',
+									onShow: () => !sidebarActive
+								}"
+							>
+								<i class="material-icons">library_music</i>
+								<span>Playlists</span>
+							</router-link>
+							<router-link
+								class="sidebar-item news"
+								to="/admin/news"
+								content="News"
+								v-tippy="{
+									theme: 'info',
+									onShow: () => !sidebarActive
+								}"
+							>
+								<i class="material-icons">chrome_reader_mode</i>
+								<span>News</span>
+							</router-link>
+							<div
+								v-if="
+									sidebarActive &&
+									currentTab.startsWith('users')
+								"
+								class="sidebar-item with-children is-active"
+							>
+								<span>
+									<i class="material-icons">people</i>
+									<span>Users</span>
+								</span>
+								<div class="sidebar-item-children">
+									<router-link
+										class="sidebar-item-child"
+										to="/admin/users"
+									>
+										Users
+									</router-link>
+									<router-link
+										class="sidebar-item-child"
+										to="/admin/users/data-requests"
+									>
+										Data Requests
+									</router-link>
+									<router-link
+										class="sidebar-item-child"
+										to="/admin/users/punishments"
+									>
+										Punishments
+									</router-link>
+								</div>
+							</div>
+							<router-link
+								v-else
+								class="sidebar-item users"
+								to="/admin/users"
+								content="Users"
+								v-tippy="{
+									theme: 'info',
+									onShow: () => !sidebarActive
+								}"
+							>
+								<i class="material-icons">people</i>
+								<span>Users</span>
+							</router-link>
+							<router-link
+								class="sidebar-item statistics"
+								to="/admin/statistics"
+								content="Statistics"
+								v-tippy="{
+									theme: 'info',
+									onShow: () => !sidebarActive
+								}"
+							>
+								<i class="material-icons">show_chart</i>
+								<span>Statistics</span>
+							</router-link>
+						</div>
+					</div>
+				</div>
+				<div class="admin-container">
+					<div class="admin-tab-container">
+						<songs v-if="currentTab == 'songs'" />
+						<reports v-if="currentTab == 'songs/reports'" />
+						<stations v-if="currentTab == 'stations'" />
+						<playlists v-if="currentTab == 'playlists'" />
+						<news v-if="currentTab == 'news'" />
+						<users v-if="currentTab == 'users'" />
+						<punishments v-if="currentTab == 'users/punishments'" />
+						<data-requests
+							v-if="currentTab == 'users/data-requests'"
+						/>
+						<statistics v-if="currentTab == 'statistics'" />
+					</div>
 
-		<div class="admin-container">
-			<songs v-if="currentTab == 'songs'" />
-			<stations v-if="currentTab == 'stations'" />
-			<playlists v-if="currentTab == 'playlists'" />
-			<reports v-if="currentTab == 'reports'" />
-			<news v-if="currentTab == 'news'" />
-			<users v-if="currentTab == 'users'" />
-			<statistics v-if="currentTab == 'statistics'" />
-			<punishments v-if="currentTab == 'punishments'" />
+					<main-footer />
+				</div>
+			</div>
 		</div>
 
 		<floating-box
@@ -169,8 +256,6 @@
 				</div>
 			</template>
 		</floating-box>
-
-		<main-footer />
 	</div>
 </template>
 
@@ -189,20 +274,28 @@ export default {
 		MainHeader,
 		MainFooter,
 		FloatingBox,
-		Songs: defineAsyncComponent(() => import("./tabs/Songs.vue")),
-		Stations: defineAsyncComponent(() => import("./tabs/Stations.vue")),
-		Playlists: defineAsyncComponent(() => import("./tabs/Playlists.vue")),
-		Reports: defineAsyncComponent(() => import("./tabs/Reports.vue")),
-		News: defineAsyncComponent(() => import("./tabs/News.vue")),
-		Users: defineAsyncComponent(() => import("./tabs/Users.vue")),
-		Statistics: defineAsyncComponent(() => import("./tabs/Statistics.vue")),
+		Songs: defineAsyncComponent(() => import("./Songs/index.vue")),
+		Reports: defineAsyncComponent(() => import("./Songs/Reports.vue")),
+		Stations: defineAsyncComponent(() => import("./Stations.vue")),
+		Playlists: defineAsyncComponent(() => import("./Playlists.vue")),
+		News: defineAsyncComponent(() => import("./News.vue")),
+		Users: defineAsyncComponent(() => import("./Users/index.vue")),
+		DataRequests: defineAsyncComponent(() =>
+			import("./Users/DataRequests.vue")
+		),
 		Punishments: defineAsyncComponent(() =>
-			import("./tabs/Punishments.vue")
-		)
+			import("./Users/Punishments.vue")
+		),
+		Statistics: defineAsyncComponent(() => import("./Statistics.vue"))
 	},
 	data() {
 		return {
-			currentTab: ""
+			currentTab: "",
+			siteSettings: {
+				logo: "",
+				sitename: ""
+			},
+			sidebarActive: true
 		};
 	},
 	computed: mapGetters({
@@ -213,9 +306,17 @@ export default {
 			this.changeTab(route.path);
 		}
 	},
-	mounted() {
+	async mounted() {
 		this.changeTab(this.$route.path);
 
+		this.siteSettings = await lofig.get("siteSettings");
+
+		this.sidebarActive = JSON.parse(
+			localStorage.getItem("admin-sidebar-active")
+		);
+		if (this.sidebarActive === null)
+			this.sidebarActive = !(document.body.clientWidth <= 768);
+
 		keyboardShortcuts.registerShortcut(
 			"admin.toggleKeyboardShortcutsHelper",
 			{
@@ -256,6 +357,9 @@ export default {
 	methods: {
 		changeTab(path) {
 			switch (path) {
+				case "/admin/songs/reports":
+					this.showTab("songs/reports");
+					break;
 				case "/admin/songs":
 					this.showTab("songs");
 					break;
@@ -265,21 +369,21 @@ export default {
 				case "/admin/playlists":
 					this.showTab("playlists");
 					break;
-				case "/admin/reports":
-					this.showTab("reports");
-					break;
 				case "/admin/news":
 					this.showTab("news");
 					break;
+				case "/admin/users/data-requests":
+					this.showTab("users/data-requests");
+					break;
+				case "/admin/users/punishments":
+					this.showTab("users/punishments");
+					break;
 				case "/admin/users":
 					this.showTab("users");
 					break;
 				case "/admin/statistics":
 					this.showTab("statistics");
 					break;
-				case "/admin/punishments":
-					this.showTab("punishments");
-					break;
 				default:
 					if (path.startsWith("/admin")) {
 						if (localStorage.getItem("lastAdminPage")) {
@@ -308,50 +412,236 @@ export default {
 		},
 		resetKeyboardShortcutsHelper() {
 			this.$refs.keyboardShortcutsHelper.resetBox();
+		},
+		toggleSidebar() {
+			this.sidebarActive = !this.sidebarActive;
+			localStorage.setItem("admin-sidebar-active", this.sidebarActive);
 		}
 	}
 };
 </script>
 
-<style lang="less">
-.christmas-mode .admin-area .christmas-lights {
-	top: 102px !important;
-}
+<style lang="less" scoped>
+.night-mode {
+	.main-container .admin-area .admin-sidebar .inner {
+		.top {
+			background-color: var(--dark-grey-3);
+		}
 
-.main-container .admin-tab,
-.main-container .container {
-	.button-row {
-		display: flex;
-		flex-direction: row;
-		flex-wrap: wrap;
-		justify-content: center;
-		margin-bottom: 5px;
-
-		& > .button,
-		& > span {
-			margin: 5px 0;
-			&:not(:first-child) {
-				margin-left: 5px;
+		.bottom {
+			background-color: var(--dark-grey-2);
+
+			.sidebar-item {
+				background-color: var(--dark-grey-2);
+				border-color: var(--dark-grey-3);
+				color: var(--white);
+
+				&.with-children .sidebar-item-child {
+					color: var(--white);
+				}
 			}
 		}
 	}
 }
 
-.main-container .admin-container .admin-tab {
-	max-width: 1900px;
-	margin: 0 auto;
-	padding: 0 10px;
-}
-</style>
+.main-container {
+	height: auto;
 
-<style lang="less" scoped>
-.night-mode {
-	.tabs {
-		background-color: var(--dark-grey-2);
-		border: 0;
+	.admin-area {
+		display: flex;
+		flex-direction: column;
+		min-height: 100vh;
+
+		.admin-sidebar {
+			display: flex;
+			min-width: 200px;
+			width: 200px;
+
+			.inner {
+				display: flex;
+				flex-direction: column;
+				max-height: 100vh;
+				overflow-y: auto;
+				width: 100%;
+				max-width: 200px;
+				position: fixed;
+				top: 0;
+				bottom: 0;
+				left: 0;
+				z-index: 5;
+				box-shadow: @box-shadow;
+
+				.top {
+					display: flex;
+					background-color: var(--primary-color);
+					height: 64px;
+					min-height: 64px;
+
+					.sidebar-logo {
+						font-size: 2.1rem !important;
+						line-height: 38px !important;
+						font-family: Pacifico, cursive;
+						display: flex;
+						align-items: center;
+
+						img {
+							max-height: 38px;
+							color: var(--primary-color);
+							user-select: none;
+							-webkit-user-drag: none;
+						}
+
+						.full-logo {
+							padding: 0 20px;
+						}
+
+						.minimised-logo {
+							display: none;
+						}
+					}
+				}
+
+				.bottom {
+					display: flex;
+					flex-direction: column;
+					flex: 1 0 auto;
+					background-color: var(--white);
+
+					.sidebar-item {
+						display: flex;
+						padding: 0 20px;
+						line-height: 40px;
+						font-size: 16px;
+						font-weight: 600;
+						color: var(--primary-color);
+						background-color: var(--white);
+						border-bottom: 1px solid var(--light-grey-2);
+						transition: all 0.2s ease-in-out;
+
+						& > .material-icons {
+							line-height: 40px;
+							margin-right: 5px;
+						}
+
+						&:hover,
+						&:focus,
+						&.router-link-active,
+						&.is-active {
+							filter: brightness(95%);
+						}
+
+						&.toggle-sidebar {
+							cursor: pointer;
+							font-weight: 400;
+						}
+
+						&.with-children {
+							flex-direction: column;
+							& > span {
+								display: flex;
+								line-height: 40px;
 
-		ul {
-			border-bottom: 0;
+								& > .material-icons {
+									line-height: 40px;
+									margin-right: 5px;
+								}
+							}
+
+							.sidebar-item-children {
+								display: none;
+							}
+
+							&.is-active .sidebar-item-children {
+								display: flex;
+								flex-direction: column;
+
+								.sidebar-item-child {
+									display: flex;
+									flex-direction: column;
+									margin-left: 30px;
+									font-size: 14px;
+									line-height: 30px;
+								}
+							}
+						}
+					}
+				}
+			}
+
+			&.minimised {
+				min-width: 45px;
+				width: 45px;
+
+				.inner {
+					max-width: 45px;
+
+					.top {
+						justify-content: center;
+
+						.full-logo {
+							display: none;
+						}
+
+						.minimised-logo {
+							display: flex;
+						}
+					}
+
+					.sidebar-item {
+						justify-content: center;
+						padding: 0;
+
+						& > span {
+							display: none;
+						}
+					}
+				}
+			}
+		}
+
+		.admin-content {
+			display: flex;
+			flex-direction: row;
+			flex-grow: 1;
+
+			.admin-container {
+				display: flex;
+				flex-direction: column;
+				flex-grow: 1;
+				overflow: hidden;
+
+				:deep(.admin-tab-container) {
+					display: flex;
+					flex-direction: column;
+					flex: 1 0 auto;
+					padding: 10px 10px 20px 10px;
+
+					.admin-tab {
+						max-width: 1900px;
+						margin: 0 auto;
+						padding: 0 10px;
+					}
+
+					.admin-tab,
+					.container {
+						.button-row {
+							display: flex;
+							flex-direction: row;
+							flex-wrap: wrap;
+							justify-content: center;
+							margin-bottom: 5px;
+
+							& > .button,
+							& > span {
+								margin: 5px 0;
+								&:not(:first-child) {
+									margin-left: 5px;
+								}
+							}
+						}
+					}
+				}
+			}
 		}
 	}
 }
@@ -369,87 +659,6 @@ export default {
 	}
 }
 
-.main-container {
-	height: auto;
-
-	.admin-container {
-		flex: 1 0 auto;
-		margin-bottom: 20px;
-	}
-}
-
-.tabs {
-	padding-top: 10px;
-	margin-top: -10px;
-	background-color: var(--white);
-	display: flex;
-	line-height: 24px;
-	overflow-y: hidden;
-	overflow-x: auto;
-	margin-bottom: 20px;
-	user-select: none;
-
-	ul {
-		display: flex;
-		align-items: center;
-		/* -webkit-box-flex: 1; */
-		flex-grow: 1;
-		flex-shrink: 0;
-		justify-content: center;
-		border-bottom: 1px solid var(--light-grey-2);
-	}
-
-	.songs {
-		color: var(--primary-color);
-		border-color: var(--primary-color);
-	}
-	.stations {
-		color: var(--purple);
-		border-color: var(--purple);
-	}
-	.playlists {
-		color: var(--light-purple);
-		border-color: var(--light-purple);
-	}
-	.reports {
-		color: var(--yellow);
-		border-color: var(--yellow);
-	}
-	.news {
-		color: var(--light-pink);
-		border-color: var(--light-pink);
-	}
-	.users {
-		color: var(--dark-pink);
-		border-color: var(--dark-pink);
-	}
-	.statistics {
-		color: var(--orange);
-		border-color: var(--orange);
-	}
-	.punishments {
-		color: var(--dark-orange);
-		border-color: var(--dark-orange);
-	}
-	.tab {
-		transition: all 0.2s ease-in-out;
-		font-weight: 500;
-		border-bottom: solid 0px;
-		padding: 6px 12px;
-		display: flex;
-		margin-bottom: -1px;
-	}
-	.tab:hover {
-		border-width: 3px;
-		transition: all 0.2s ease-in-out;
-		font-weight: 600;
-	}
-	.is-active .tab {
-		font-weight: 600;
-		border-width: 3px;
-	}
-}
-
 #keyboardShortcutsHelper {
 	.box-body {
 		.biggest {

+ 0 - 471
frontend/src/pages/Admin/tabs/Users.vue

@@ -1,471 +0,0 @@
-<template>
-	<div>
-		<page-metadata title="Admin | Users" />
-		<div class="container">
-			<h2>Data Requests</h2>
-
-			<advanced-table
-				:column-default="dataRequests.columnDefault"
-				:columns="dataRequests.columns"
-				:filters="dataRequests.filters"
-				data-action="dataRequests.getData"
-				name="admin-data-requests"
-				max-width="1200"
-				:query="false"
-				:events="dataRequests.events"
-			>
-				<template #column-options="slotProps">
-					<div class="row-options">
-						<quick-confirm
-							placement="right"
-							@confirm="resolveDataRequest(slotProps.item._id)"
-							:disabled="slotProps.item.removed"
-						>
-							<button
-								class="button is-success icon-with-button material-icons"
-								content="Resolve Data Request"
-								v-tippy
-							>
-								done_all
-							</button>
-						</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>
-
-			<advanced-table
-				:column-default="users.columnDefault"
-				:columns="users.columns"
-				:filters="users.filters"
-				data-action="users.getData"
-				name="admin-users"
-				max-width="1200"
-				:events="users.events"
-			>
-				<template #column-options="slotProps">
-					<div class="row-options">
-						<button
-							class="button is-primary icon-with-button material-icons"
-							@click="edit(slotProps.item._id)"
-							:disabled="slotProps.item.removed"
-							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"
-			:user-id="editingUserId"
-			sector="admin"
-		/>
-	</div>
-</template>
-
-<script>
-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 QuickConfirm from "@/components/QuickConfirm.vue";
-
-export default {
-	components: {
-		EditUser: defineAsyncComponent(() =>
-			import("@/components/modals/EditUser.vue")
-		),
-		AdvancedTable,
-		ProfilePicture,
-		QuickConfirm
-	},
-	data() {
-		return {
-			editingUserId: "",
-			dataRequests: {
-				columnDefault: {
-					sortable: true,
-					hidable: true,
-					defaultVisibility: "shown",
-					draggable: true,
-					resizable: true,
-					minWidth: 150,
-					maxWidth: 600
-				},
-				columns: [
-					{
-						name: "options",
-						displayName: "Options",
-						properties: ["_id"],
-						sortable: false,
-						hidable: false,
-						resizable: false,
-						minWidth: 76,
-						defaultWidth: 76
-					},
-					{
-						name: "type",
-						displayName: "Type",
-						properties: ["type"],
-						sortable: false
-					},
-					{
-						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"
-					}
-				],
-				events: {
-					adminRoom: "users",
-					removed: {
-						event: "admin.dataRequests.resolved",
-						id: "dataRequestId"
-					}
-				}
-			},
-			users: {
-				columnDefault: {
-					sortable: true,
-					hidable: true,
-					defaultVisibility: "shown",
-					draggable: true,
-					resizable: true,
-					minWidth: 150,
-					maxWidth: 600
-				},
-				columns: [
-					{
-						name: "options",
-						displayName: "Options",
-						properties: ["_id"],
-						sortable: false,
-						hidable: false,
-						resizable: false,
-						minWidth: 76,
-						defaultWidth: 76
-					},
-					{
-						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.github.id"],
-						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.address"],
-						sortProperty: "email.address",
-						defaultVisibility: "hidden"
-					},
-					{
-						name: "emailVerified",
-						displayName: "Email Verified",
-						properties: ["email.verified"],
-						sortProperty: "email.verified",
-						defaultVisibility: "hidden",
-						minWidth: 140,
-						defaultWidth: 140
-					},
-					{
-						name: "songsRequested",
-						displayName: "Songs Requested",
-						properties: ["statistics.songsRequested"],
-						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: ["boolean"],
-						defaultFilterType: "boolean"
-					},
-					{
-						name: "role",
-						displayName: "Role",
-						property: "role",
-						filterTypes: ["exact"],
-						defaultFilterType: "exact",
-						dropdown: [
-							["admin", "Admin"],
-							["default", "Default"]
-						]
-					},
-					{
-						name: "emailAddress",
-						displayName: "Email Address",
-						property: "email.address",
-						filterTypes: ["contains", "exact", "regex"],
-						defaultFilterType: "contains"
-					},
-					{
-						name: "emailVerified",
-						displayName: "Email Verified",
-						property: "email.verified",
-						filterTypes: ["boolean"],
-						defaultFilterType: "boolean"
-					},
-					{
-						name: "songsRequested",
-						displayName: "Songs Requested",
-						property: "statistics.songsRequested",
-						filterTypes: [
-							"numberLesserEqual",
-							"numberLesser",
-							"numberGreater",
-							"numberGreaterEqual",
-							"numberEquals"
-						],
-						defaultFilterType: "numberLesser"
-					}
-				],
-				events: {
-					adminRoom: "users",
-					updated: {
-						event: "admin.user.updated",
-						id: "user._id",
-						item: "user"
-					},
-					removed: {
-						event: "user.removed",
-						id: "userId"
-					}
-				}
-			}
-		};
-	},
-	computed: {
-		...mapState("modalVisibility", {
-			modals: state => state.modals
-		}),
-		...mapGetters({
-			socket: "websockets/getSocket"
-		})
-	},
-	mounted() {
-		if (this.$route.query.userId) this.edit(this.$route.query.userId);
-	},
-	methods: {
-		edit(userId) {
-			this.editingUserId = userId;
-			this.openModal("editUser");
-		},
-		resolveDataRequest(id) {
-			this.socket.dispatch("dataRequests.resolve", id, res => {
-				if (res.status === "success") new Toast(res.message);
-			});
-		},
-		...mapActions("modalVisibility", ["openModal"])
-	}
-};
-</script>
-
-<style lang="less" scoped>
-#page-title {
-	margin: 30px 0;
-}
-
-h2 {
-	font-size: 30px;
-	text-align: center;
-
-	@media only screen and (min-width: 700px) {
-		font-size: 35px;
-	}
-}
-
-.profile-picture {
-	max-width: 50px !important;
-	max-height: 50px !important;
-}
-
-:deep(.profile-picture.using-initials span) {
-	font-size: 20px; // 2/5th of .profile-picture height/width
-}
-</style>