7 Commits 522d4842f6 ... d57675cc49

Author SHA1 Message Date
  Owen Diffey d57675cc49 refactor: Update user settings form usage 3 weeks ago
  Owen Diffey d0bfbc0341 refactor: Move profile picture avatar attributes to props 3 weeks ago
  Owen Diffey 2ea1a071c0 refactor: Add on-demand validation support to useForm 3 weeks ago
  Owen Diffey a769fb4df2 refactor: Add save button handling to useForm 3 weeks ago
  Owen Diffey 6d4dbaf8fe refactor: Use current user model to get and set nightmode status 3 weeks ago
  Owen Diffey fb265e5264 fix: Model being removed with 1 remaining usage 3 weeks ago
  Owen Diffey 1680fd92bd refactor: Limit get socket method to id 3 weeks ago

+ 3 - 5
backend/src/modules/DataModule/models/User/jobs/FindById.ts

@@ -1,12 +1,10 @@
 import FindByIdJob from "@/modules/DataModule/FindByIdJob";
 import User from "../../User";
+import isAdmin from "@/modules/DataModule/permissions/isAdmin";
+import isSelf from "@/modules/DataModule/permissions/modelPermissions/isSelf";
 
 export default class FindById extends FindByIdJob {
 	protected static _model = User;
 
-	protected static _hasModelPermission = this._isSelf;
-
-	protected static _isSelf(model: User, user?: User) {
-		return model._id === user?._id;
-	}
+	protected static _hasModelPermission = [isAdmin, isSelf];
 }

+ 29 - 0
backend/src/modules/DataModule/models/User/jobs/UpdateById.ts

@@ -1,6 +1,35 @@
+import Joi from "joi";
 import UpdateByIdJob from "@/modules/DataModule/UpdateByIdJob";
 import User from "../../User";
+import { UserAvatarType } from "../UserAvatarType";
+import { UserAvatarColor } from "../UserAvatarColor";
+import isAdmin from "@/modules/DataModule/permissions/isAdmin";
+import isSelf from "@/modules/DataModule/permissions/modelPermissions/isSelf";
 
 export default class UpdateById extends UpdateByIdJob {
 	protected static _model = User;
+
+	protected static _hasModelPermission = [isAdmin, isSelf];
+
+	protected static _payloadSchema = Joi.object({
+		_id: Joi.string()
+			.pattern(/^[0-9a-fA-F]{24}$/)
+			.required(),
+		query: Joi.object({
+			username: Joi.string().max(64).optional(), // TODO: format and unique validation
+			emailAddress: Joi.string().max(64).optional(), // TODO: format and unique validation
+			name: Joi.string().max(64).optional(),
+			location: Joi.string().max(50).optional().allow(null, ""),
+			bio: Joi.string().max(200).optional().allow(null, ""), // TODO: Nullify empty strings
+			avatarType: Joi.valid(...Object.values(UserAvatarType)).optional(),
+			avatarColor: Joi.valid(...Object.values(UserAvatarColor))
+				.optional()
+				.allow(null),
+			nightmode: Joi.boolean().optional(),
+			autoSkipDisliked: Joi.boolean().optional(),
+			activityLogPublic: Joi.boolean().optional(),
+			anonymousSongRequests: Joi.boolean().optional(),
+			activityWatch: Joi.boolean().optional()
+		}).required()
+	});
 }

+ 7 - 9
backend/src/modules/WebSocketModule.ts

@@ -2,7 +2,7 @@ import config from "config";
 import express from "express";
 import http, { Server, IncomingMessage } from "node:http";
 import { RawData, WebSocketServer } from "ws";
-import { Types, isObjectIdOrHexString } from "mongoose";
+import { isObjectIdOrHexString } from "mongoose";
 import { forEachIn } from "@common/utils/forEachIn";
 import { getErrorMessage } from "@common/utils/getErrorMessage";
 import BaseModule from "@/BaseModule";
@@ -300,19 +300,17 @@ export class WebSocketModule extends BaseModule {
 	}
 
 	/**
-	 * getSocket - Get websocket client
+	 * getSocket - Get websocket client by id
 	 */
-	public async getSocket(socketId?: string, sessionId?: Types.ObjectId) {
+	public async getSocketById(socketId: string) {
 		if (!this._wsServer) return null;
 
 		for (const clients of this._wsServer.clients.entries() as IterableIterator<
 			[WebSocket, WebSocket]
 		>) {
-			const socket = clients.find(socket => {
-				if (socket.getSocketId() === socketId) return true;
-				if (socket.getSessionId() === sessionId) return true;
-				return false;
-			});
+			const socket = clients.find(
+				socket => socket.getSocketId() === socketId
+			);
 
 			if (socket) return socket;
 		}
@@ -328,7 +326,7 @@ export class WebSocketModule extends BaseModule {
 		channel: string,
 		...values: unknown[]
 	) {
-		const socket = await this.getSocket(socketId);
+		const socket = await this.getSocketById(socketId);
 
 		if (!socket) return;
 

+ 20 - 41
frontend/src/App.vue

@@ -3,12 +3,11 @@ import { useRouter } from "vue-router";
 import { defineAsyncComponent, ref, watch, onMounted } from "vue";
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
-import { GenericResponse } from "@musare_types/actions/GenericActions";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useConfigStore } from "@/stores/config";
 import { useUserAuthStore } from "@/stores/userAuth";
-import { useUserPreferencesStore } from "@/stores/userPreferences";
 import { useModalsStore } from "@/stores/modals";
+import { useEvents } from "@/composables/useEvents";
 import aw from "@/aw";
 import keyboardShortcuts from "@/keyboardShortcuts";
 
@@ -28,7 +27,6 @@ const router = useRouter();
 const { socket } = useWebsocketsStore();
 const configStore = useConfigStore();
 const userAuthStore = useUserAuthStore();
-const userPreferencesStore = useUserPreferencesStore();
 const modalsStore = useModalsStore();
 
 const socketConnected = ref(true);
@@ -40,27 +38,10 @@ const broadcastChannel = ref({
 const disconnectedMessage = ref();
 
 const { christmas } = storeToRefs(configStore);
-const { currentUser, loggedIn, banned } = storeToRefs(userAuthStore);
-const { nightmode, activityWatch } = storeToRefs(userPreferencesStore);
-const {
-	changeNightmode,
-} = userPreferencesStore;
+const { currentUser, loggedIn, banned, nightmode } = storeToRefs(userAuthStore);
 const { activeModals } = storeToRefs(modalsStore);
 const { openModal, closeCurrentModal } = modalsStore;
-
-const toggleNightMode = () => {
-	if (loggedIn.value) {
-		socket.dispatch(
-			"users.updatePreferences",
-			{ nightmode: !nightmode.value },
-			(res: GenericResponse) => {
-				if (res.status !== "success") new Toast(res.message);
-			}
-		);
-	} else {
-		broadcastChannel.value.nightmode.postMessage(!nightmode.value);
-	}
-};
+const { onReady } = useEvents();
 
 const enableNightmode = () => {
 	document.getElementsByTagName("html")[0].classList.add("night-mode");
@@ -85,23 +66,23 @@ watch(socketConnected, connected => {
 watch(banned, () => {
 	disconnectedMessage.value.hide();
 });
-watch(nightmode, enabled => {
-	if (enabled) enableNightmode();
+watch(nightmode, value => {
+	if (value) enableNightmode();
 	else disableNightmode();
+
+	localStorage.setItem("nightmode", value.toString());
 });
-watch(activityWatch, enabled => {
-	if (enabled) aw.enable();
-	else aw.disable();
-});
+watch(
+	() => currentUser.value?.activityWatch,
+	enabled => {
+		if (enabled) aw.enable();
+		else aw.disable();
+	}
+);
 watch(christmas, enabled => {
 	if (enabled) enableChristmasMode();
 	else disableChristmasMode();
 });
-watch(currentUser, user => {
-	if (!user) return;
-
-	changeNightmode(user.nightmode);
-});
 
 onMounted(async () => {
 	document.getElementsByTagName("html")[0].style.cssText =
@@ -110,7 +91,7 @@ onMounted(async () => {
 	window
 		.matchMedia("(prefers-color-scheme: dark)")
 		.addEventListener("change", e => {
-			if (e.matches === !nightmode.value) changeNightmode(true);
+			if (e.matches === !nightmode.value) nightmode.value = true;
 		});
 
 	disconnectedMessage.value = new Toast({
@@ -121,7 +102,7 @@ onMounted(async () => {
 
 	disconnectedMessage.value.hide();
 
-	socket.onConnect(() => {
+	await onReady(async () => {
 		socketConnected.value = true;
 
 		document.getElementsByTagName("html")[0].style.cssText =
@@ -142,7 +123,7 @@ onMounted(async () => {
 				`${configStore.cookie}.nightmode`
 			);
 			broadcastChannel.value.nightmode.onmessage = res => {
-				changeNightmode(!!res.data);
+				nightmode.value = !!res.data;
 			};
 		}
 
@@ -170,7 +151,7 @@ onMounted(async () => {
 			keyCode: 78,
 			ctrl: true,
 			alt: true,
-			handler: () => toggleNightMode()
+			handler: userAuthStore.toggleNightmode
 		});
 
 		keyboardShortcuts.registerShortcut("closeModal", {
@@ -194,7 +175,7 @@ onMounted(async () => {
 				localStorage.removeItem("github_redirect");
 			}
 		});
-	}, true);
+	});
 
 	socket.onDisconnect(() => {
 		socketConnected.value = false;
@@ -204,9 +185,7 @@ onMounted(async () => {
 		window.location.reload()
 	);
 
-	if (localStorage.getItem("nightmode") === "true") {
-		changeNightmode(true);
-	} else changeNightmode(false);
+	nightmode.value = localStorage.getItem("nightmode") === "true";
 });
 </script>
 

+ 8 - 45
frontend/src/components/MainHeader.vue

@@ -1,11 +1,8 @@
 <script setup lang="ts">
-import { defineAsyncComponent, ref, onMounted, watch, nextTick } from "vue";
-import Toast from "toasters";
+import { defineAsyncComponent, ref, onMounted, nextTick } from "vue";
 import { storeToRefs } from "pinia";
-import { useWebsocketsStore } from "@/stores/websockets";
 import { useConfigStore } from "@/stores/config";
 import { useUserAuthStore } from "@/stores/userAuth";
-import { useUserPreferencesStore } from "@/stores/userPreferences";
 import { useModalsStore } from "@/stores/modals";
 
 const ChristmasLights = defineAsyncComponent(
@@ -20,57 +17,25 @@ defineProps({
 
 const userAuthStore = useUserAuthStore();
 
-const localNightmode = ref(false);
 const isMobile = ref(false);
 const windowWidth = ref(0);
-const broadcastChannel = ref();
-
-const { socket } = useWebsocketsStore();
 
 const configStore = useConfigStore();
-const { cookie, sitename, registrationDisabled, christmas } =
-	storeToRefs(configStore);
+const { sitename, registrationDisabled, christmas } = storeToRefs(configStore);
 
-const { loggedIn, currentUser } = storeToRefs(userAuthStore);
-const { logout, hasPermission } = userAuthStore;
-const userPreferencesStore = useUserPreferencesStore();
-const { nightmode } = storeToRefs(userPreferencesStore);
+const { loggedIn, currentUser, nightmode } = storeToRefs(userAuthStore);
+const { logout, hasPermission, toggleNightmode } = userAuthStore;
 
 const { openModal } = useModalsStore();
 
-const toggleNightmode = toggle => {
-	localNightmode.value =
-		toggle === undefined ? !localNightmode.value : toggle;
-
-	if (loggedIn.value) {
-		socket.dispatch(
-			"users.updatePreferences",
-			{ nightmode: localNightmode.value },
-			res => {
-				if (res.status !== "success") new Toast(res.message);
-			}
-		);
-	} else {
-		broadcastChannel.value.postMessage(localNightmode.value);
-	}
-};
-
 const onResize = () => {
 	windowWidth.value = window.innerWidth;
 };
 
-watch(nightmode, value => {
-	localNightmode.value = value;
-});
-
 onMounted(async () => {
-	localNightmode.value = nightmode.value;
-
 	await nextTick();
 	onResize();
 	window.addEventListener("resize", onResize);
-
-	broadcastChannel.value = new BroadcastChannel(`${cookie.value}.nightmode`);
 });
 </script>
 
@@ -107,20 +72,18 @@ onMounted(async () => {
 			<div
 				class="nav-item"
 				id="nightmode-toggle"
-				@click="toggleNightmode(!localNightmode)"
+				@click="toggleNightmode"
 			>
 				<span
 					:class="{
 						'material-icons': true,
 						'night-mode-toggle': true,
-						'night-mode-on': localNightmode
+						'night-mode-on': nightmode
 					}"
-					:content="`${
-						localNightmode ? 'Disable' : 'Enable'
-					} Nightmode`"
+					:content="`${nightmode ? 'Disable' : 'Enable'} Nightmode`"
 					v-tippy
 				>
-					{{ localNightmode ? "dark_mode" : "light_mode" }}
+					{{ nightmode ? "dark_mode" : "light_mode" }}
 				</span>
 				<span class="night-mode-label">Toggle Nightmode</span>
 			</div>

+ 16 - 8
frontend/src/components/ProfilePicture.vue

@@ -3,9 +3,19 @@ import { ref, computed, onMounted } from "vue";
 import { useConfigStore } from "@/stores/config";
 
 const props = defineProps({
-	avatar: {
-		type: Object,
-		default: () => {}
+	type: {
+		type: String,
+		default: "initials"
+	},
+	color: {
+		type: String,
+		required: false,
+		default: "blue"
+	},
+	url: {
+		type: String,
+		required: false,
+		default: null
 	},
 	name: {
 		type: String,
@@ -36,13 +46,11 @@ onMounted(async () => {
 <template>
 	<img
 		class="profile-picture using-gravatar"
-		v-if="avatar.type === 'gravatar'"
-		:src="
-			avatar.url ? `${avatar.url}?d=${notes}&s=250` : '/assets/notes.png'
-		"
+		v-if="type === 'gravatar'"
+		:src="url ? `${url}?d=${notes}&s=250` : '/assets/notes.png'"
 		onerror="this.src='/assets/notes.png'; this.onerror=''"
 	/>
-	<div class="profile-picture using-initials" :class="avatar.color" v-else>
+	<div class="profile-picture using-initials" :class="color" v-else>
 		<span>{{ initials }}</span>
 	</div>
 </template>

+ 9 - 10
frontend/src/components/modals/EditNews.vue

@@ -64,7 +64,7 @@ const getTitle = () => {
 	return title;
 };
 
-const { inputs, save, setModelValues } = useForm(
+const { inputs, saveButton, save, setModelValues } = useForm(
 	{
 		markdown: {
 			value: "# Header\n## Sub-Header\n- **So**\n- _Many_\n- ~Points~\n\nOther things you want to say and [link](https://example.com).\n\n### Sub-Sub-Header\n> Oh look, a quote!\n\n`lil code`\n\n```\nbig code\n```\n",
@@ -156,23 +156,21 @@ onMounted(async () => {
 		<template #body>
 			<div class="left-section" v-show="canShow">
 				<p><strong>Markdown</strong></p>
-				<textarea v-model="inputs['markdown'].value"></textarea>
+				<textarea v-model="inputs.markdown.value"></textarea>
 			</div>
 			<div class="right-section" v-show="canShow">
 				<p><strong>Preview</strong></p>
 				<div
 					class="news-item"
 					id="preview"
-					v-html="
-						DOMPurify.sanitize(marked(inputs['markdown'].value))
-					"
+					v-html="DOMPurify.sanitize(marked(inputs.markdown.value))"
 				></div>
 			</div>
 		</template>
 		<template #footer>
 			<div>
 				<p class="control select">
-					<select v-model="inputs['status'].value">
+					<select v-model="inputs.status.value">
 						<option value="draft">Draft</option>
 						<option value="published" selected>Publish</option>
 					</select>
@@ -183,7 +181,7 @@ onMounted(async () => {
 						<input
 							type="checkbox"
 							id="show-to-new-users"
-							v-model="inputs['showToNewUsers'].value"
+							v-model="inputs.showToNewUsers.value"
 						/>
 						<span class="slider round"></span>
 					</label>
@@ -193,17 +191,18 @@ onMounted(async () => {
 					</label>
 				</p>
 
-				<save-button
-					ref="saveButton"
+				<SaveButton
 					v-if="createNews"
+					ref="saveButton"
 					@clicked="save()"
 				/>
 
-				<save-button
+				<SaveButton
 					ref="saveAndCloseButton"
 					default-message="Save and close"
 					@clicked="save(closeCurrentModal)"
 				/>
+
 				<div class="right" v-if="createdAt > 0">
 					<span>
 						By&nbsp;

+ 43 - 21
frontend/src/composables/useForm.ts

@@ -72,6 +72,8 @@ export const useForm = (
 		return _sourceChanged;
 	});
 
+	const saveButton = ref();
+
 	const useCallback = (status: string, messages?: Record<string, string>) =>
 		new Promise((resolve, reject: (reason: Error) => void) => {
 			cb(
@@ -103,43 +105,56 @@ export const useForm = (
 		);
 	};
 
-	const validate = () => {
+	const validate = (keys: string | string[] = []) => {
+		const inputNames = Array.isArray(keys) ? keys : [keys];
 		const invalid: Record<string, string[]> = {};
-		Object.entries(inputs.value).forEach(([name, input]) => {
-			input.errors = [];
-			if (
-				input.required &&
-				(input.value === undefined ||
-					input.value === "" ||
-					input.value === null)
+		Object.entries(inputs.value)
+			.filter(
+				([name]) => inputNames.length === 0 || inputNames.includes(name)
 			)
-				input.errors.push(`Invalid ${name}. Please provide value`);
-			if (input.validate) {
-				const valid = input.validate(input.value, inputs);
-				if (valid !== true) {
-					input.errors.push(
-						valid === false ? `Invalid ${name}` : valid
-					);
+			.forEach(([name, input]) => {
+				input.errors = [];
+				if (
+					input.required &&
+					(input.value === undefined ||
+						input.value === "" ||
+						input.value === null)
+				)
+					input.errors.push(`Invalid ${name}. Please provide value`);
+				if (input.validate) {
+					const valid = input.validate(input.value, inputs);
+					if (valid !== true) {
+						input.errors.push(
+							valid === false ? `Invalid ${name}` : valid
+						);
+					}
 				}
-			}
-			if (input.errors.length > 0)
-				invalid[name] = input.errors.join(", ");
-		});
+				if (input.errors.length > 0)
+					invalid[name] = input.errors.join(", ");
+			});
 		return invalid;
 	};
 
 	const save = (saveCb?: () => void) => {
+		if (saveButton.value) saveButton.value.status = "disabled";
+
 		const errors = validate();
 		const errorCount = Object.keys(errors).length;
+
 		if (errorCount === 0 && unsavedChanges.value.length > 0) {
 			const onSave = () => {
 				useCallback("success")
 					.then(() => {
 						resetOriginalValues();
 						if (saveCb) saveCb();
+						saveButton.value?.handleSuccessfulSave();
 					})
 					.catch((err: Error) =>
-						useCallback("error", { error: err.message })
+						useCallback("error", { error: err.message }).then(
+							() => {
+								saveButton.value?.handleFailedSave();
+							}
+						)
 					);
 			};
 			if (sourceChanged.value.length > 0)
@@ -156,9 +171,12 @@ export const useForm = (
 			useCallback("unchanged", { unchanged: "No changes have been made" })
 				.then(() => {
 					if (saveCb) saveCb();
+					saveButton.value?.handleSuccessfulSave();
 				})
 				.catch((err: Error) =>
-					useCallback("error", { error: err.message })
+					useCallback("error", { error: err.message }).then(() => {
+						saveButton.value?.handleFailedSave();
+					})
 				);
 		} else {
 			useCallback("error", {
@@ -166,6 +184,8 @@ export const useForm = (
 				error: `${errorCount} ${
 					errorCount === 1 ? "input" : "inputs"
 				} failed validation.`
+			}).then(() => {
+				saveButton.value?.handleFailedSave();
 			});
 		}
 	};
@@ -251,6 +271,8 @@ export const useForm = (
 	return {
 		inputs,
 		unsavedChanges,
+		saveButton,
+		validate,
 		save,
 		setValue,
 		setOriginalValue,

+ 78 - 177
frontend/src/pages/Settings/Tabs/Account.vue

@@ -1,13 +1,16 @@
 <script setup lang="ts">
-import { defineAsyncComponent, ref, watch, reactive, onMounted } from "vue";
+import { defineAsyncComponent, onMounted } from "vue";
 import { useRoute } from "vue-router";
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
-import { useSettingsStore } from "@/stores/settings";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useUserAuthStore } from "@/stores/userAuth";
 import { useModalsStore } from "@/stores/modals";
-import _validation from "@/validation";
+import validation from "@/validation";
+import { useEvents } from "@/composables/useEvents";
+import { useModels } from "@/composables/useModels";
+import { useWebsocketStore } from "@/stores/websocket";
+import { useForm } from "@/composables/useForm";
 
 const InputHelpBox = defineAsyncComponent(
 	() => import("@/components/InputHelpBox.vue")
@@ -19,127 +22,66 @@ const QuickConfirm = defineAsyncComponent(
 	() => import("@/components/QuickConfirm.vue")
 );
 
-const settingsStore = useSettingsStore();
-const userAuthStore = useUserAuthStore();
 const route = useRoute();
 
 const { socket } = useWebsocketsStore();
 
-const saveButton = ref();
-
-const { currentUser } = storeToRefs(userAuthStore);
-const { originalUser, modifiedUser } = settingsStore;
-
-const validation = reactive({
-	username: {
-		entered: false,
-		valid: false,
-		message: "Please enter a valid username."
-	},
-	email: {
-		entered: false,
-		valid: false,
-		message: "Please enter a valid email address."
-	}
-});
-
-const { updateOriginalUser } = settingsStore;
-
 const { openModal } = useModalsStore();
 
-const onInput = inputName => {
-	validation[inputName].entered = true;
-};
-
-const changeEmail = () => {
-	const email = modifiedUser.email.address;
-	if (!_validation.isLength(email, 3, 254))
-		return new Toast("Email must have between 3 and 254 characters.");
-	if (
-		email.indexOf("@") !== email.lastIndexOf("@") ||
-		!_validation.regex.emailSimple.test(email)
-	)
-		return new Toast("Invalid email format.");
-
-	saveButton.value.saveStatus = "disabled";
+const { runJob } = useWebsocketStore();
+const { onReady } = useEvents();
+const { registerModel } = useModels();
 
-	return socket.dispatch(
-		"users.updateEmail",
-		currentUser.value?._id,
-		email,
-		res => {
-			if (res.status !== "success") {
-				new Toast(res.message);
-				saveButton.value.handleFailedSave();
-			} else {
-				new Toast("Successfully changed email address");
+const userAuthStore = useUserAuthStore();
 
-				updateOriginalUser({
-					property: "email.address",
-					value: email
-				});
+const { currentUser } = storeToRefs(userAuthStore);
 
-				saveButton.value.handleSuccessfulSave();
+const { inputs, saveButton, validate, save, setModelValues } = useForm(
+	{
+		username: {
+			value: null,
+			validate: (value: string) => {
+				if (!validation.isLength(value, 2, 32))
+					return "Username must have between 2 and 32 characters.";
+				if (!validation.regex.azAZ09_.test(value))
+					return "Invalid username format. Allowed characters: a-z, A-Z, 0-9 and _.";
+				if (value.replaceAll(/[_]/g, "").length === 0)
+					return "Invalid username format. Allowed characters: a-z, A-Z, 0-9 and _, and there has to be at least one letter or number.";
+				return true;
+			}
+		},
+		emailAddress: {
+			value: null,
+			validate: (value: string) => {
+				if (!validation.isLength(value, 3, 254))
+					return "Email address must have between 3 and 254 characters.";
+				if (
+					value.indexOf("@") !== value.lastIndexOf("@") ||
+					!validation.regex.emailSimple.test(value)
+				)
+					return "Invalid email address format.";
+				return true;
 			}
 		}
-	);
-};
-
-const changeUsername = () => {
-	const { username } = modifiedUser;
-
-	if (!_validation.isLength(username, 2, 32))
-		return new Toast("Username must have between 2 and 32 characters.");
-
-	if (!_validation.regex.azAZ09_.test(username))
-		return new Toast(
-			"Invalid username format. Allowed characters: a-z, A-Z, 0-9 and _."
-		);
-
-	if (username.replaceAll(/[_]/g, "").length === 0)
-		return new Toast(
-			"Invalid username format. Allowed characters: a-z, A-Z, 0-9 and _, and there has to be at least one letter or number."
-		);
-
-	saveButton.value.saveStatus = "disabled";
-
-	return socket.dispatch(
-		"users.updateUsername",
-		currentUser.value?._id,
-		username,
-		res => {
-			if (res.status !== "success") {
-				new Toast(res.message);
-				saveButton.value.handleFailedSave();
-			} else {
-				new Toast("Successfully changed username");
-
-				updateOriginalUser({
-					property: "username",
-					value: username
+	},
+	({ status, messages, values }, resolve, reject) => {
+		if (status === "success") {
+			runJob(`data.users.updateById`, {
+				_id: currentUser.value._id,
+				query: values
+			})
+				.then(resolve)
+				.catch(reject);
+		} else {
+			if (status === "unchanged") new Toast(messages.unchanged);
+			else if (status === "error")
+				Object.values(messages).forEach(message => {
+					new Toast({ content: message, timeout: 8000 });
 				});
-
-				saveButton.value.handleSuccessfulSave();
-			}
+			resolve();
 		}
-	);
-};
-
-const saveChanges = () => {
-	const usernameChanged = modifiedUser.username !== originalUser.username;
-	const emailAddressChanged =
-		modifiedUser.email.address !== originalUser.email.address;
-
-	if (usernameChanged) changeUsername();
-
-	if (emailAddressChanged) changeEmail();
-
-	if (!usernameChanged && !emailAddressChanged) {
-		saveButton.value.handleFailedSave();
-
-		new Toast("Please make a change before saving.");
 	}
-};
+);
 
 const removeActivities = () => {
 	socket.dispatch("activities.removeAllForUser", res => {
@@ -147,7 +89,14 @@ const removeActivities = () => {
 	});
 };
 
-onMounted(() => {
+onMounted(async () => {
+	await onReady(async () => {
+		setModelValues(await registerModel(currentUser.value), [
+			"username",
+			"emailAddress"
+		]);
+	});
+
 	if (
 		route.query.removeAccount === "relinked-github" &&
 		!localStorage.getItem("github_redirect")
@@ -158,55 +107,6 @@ onMounted(() => {
 		});
 	}
 });
-
-watch(
-	() => modifiedUser.username,
-	value => {
-		// const value = newModifiedUser.username;
-
-		if (!_validation.isLength(value, 2, 32)) {
-			validation.username.message =
-				"Username must have between 2 and 32 characters.";
-			validation.username.valid = false;
-		} else if (
-			!_validation.regex.azAZ09_.test(value) &&
-			value !== originalUser.username // Sometimes a username pulled from GitHub won't succeed validation
-		) {
-			validation.username.message =
-				"Invalid format. Allowed characters: a-z, A-Z, 0-9 and _.";
-			validation.username.valid = false;
-		} else if (value.replaceAll(/[_]/g, "").length === 0) {
-			validation.username.message =
-				"Invalid format. Allowed characters: a-z, A-Z, 0-9 and _, and there has to be at least one letter or number.";
-			validation.username.valid = false;
-		} else {
-			validation.username.message = "Everything looks great!";
-			validation.username.valid = true;
-		}
-	}
-);
-
-watch(
-	() => modifiedUser.email.address,
-	value => {
-		// const value = newModifiedUser.email.address;
-
-		if (!_validation.isLength(value, 3, 254)) {
-			validation.email.message =
-				"Email must have between 3 and 254 characters.";
-			validation.email.valid = false;
-		} else if (
-			value.indexOf("@") !== value.lastIndexOf("@") ||
-			!_validation.regex.emailSimple.test(value)
-		) {
-			validation.email.message = "Invalid format.";
-			validation.email.valid = false;
-		} else {
-			validation.email.message = "Everything looks great!";
-			validation.email.valid = true;
-		}
-	}
-);
 </script>
 
 <template>
@@ -224,21 +124,22 @@ watch(
 				id="username"
 				type="text"
 				placeholder="Enter username here..."
-				v-model="modifiedUser.username"
+				v-model="inputs.username.value"
+				@input="validate('username')"
 				maxlength="32"
 				autocomplete="off"
-				@keypress="onInput('username')"
-				@paste="onInput('username')"
 			/>
-			<span v-if="modifiedUser.username" class="character-counter"
-				>{{ modifiedUser.username.length }}/32</span
-			>
+			<span v-if="inputs.username.value" class="character-counter">
+				{{ inputs.username.value.length }}/32
+			</span>
 		</p>
 		<transition name="fadein-helpbox">
 			<input-help-box
-				:entered="validation.username.entered"
-				:valid="validation.username.valid"
-				:message="validation.username.message"
+				:entered="inputs.username.value?.length > 1"
+				:valid="inputs.username.errors.length === 0"
+				:message="
+					inputs.username.errors[0] ?? 'Everything looks great!'
+				"
 			/>
 		</transition>
 
@@ -249,22 +150,22 @@ watch(
 				id="email"
 				type="text"
 				placeholder="Enter email address here..."
-				v-if="modifiedUser.email"
-				v-model="modifiedUser.email.address"
-				@keypress="onInput('email')"
-				@paste="onInput('email')"
+				v-model="inputs.emailAddress.value"
+				@input="validate('emailAddress')"
 				autocomplete="off"
 			/>
 		</p>
 		<transition name="fadein-helpbox">
 			<input-help-box
-				:entered="validation.email.entered"
-				:valid="validation.email.valid"
-				:message="validation.email.message"
+				:entered="inputs.emailAddress.value?.length > 1"
+				:valid="inputs.emailAddress.errors.length === 0"
+				:message="
+					inputs.emailAddress.errors[0] ?? 'Everything looks great!'
+				"
 			/>
 		</transition>
 
-		<SaveButton ref="saveButton" @clicked="saveChanges()" />
+		<SaveButton ref="saveButton" @clicked="save()" />
 
 		<div class="section-margin-bottom" />
 

+ 54 - 96
frontend/src/pages/Settings/Tabs/Preferences.vue

@@ -1,103 +1,61 @@
 <script setup lang="ts">
-import { defineAsyncComponent, ref, onMounted } from "vue";
+import { defineAsyncComponent, onMounted } from "vue";
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
-import { useWebsocketsStore } from "@/stores/websockets";
-import { useUserPreferencesStore } from "@/stores/userPreferences";
+import { useForm } from "@/composables/useForm";
+import { useEvents } from "@/composables/useEvents";
+import { useModels } from "@/composables/useModels";
+import { useWebsocketStore } from "@/stores/websocket";
+import { useUserAuthStore } from "@/stores/userAuth";
 
 const SaveButton = defineAsyncComponent(
 	() => import("@/components/SaveButton.vue")
 );
 
-const { socket } = useWebsocketsStore();
-const userPreferencesStore = useUserPreferencesStore();
-
-const saveButton = ref();
-
-const localNightmode = ref(false);
-const localAutoSkipDisliked = ref(false);
-const localActivityLogPublic = ref(false);
-const localAnonymousSongRequests = ref(false);
-const localActivityWatch = ref(false);
-
-const {
-	nightmode,
-	autoSkipDisliked,
-	activityLogPublic,
-	anonymousSongRequests,
-	activityWatch
-} = storeToRefs(userPreferencesStore);
-
-const saveChanges = () => {
-	if (
-		localNightmode.value === nightmode.value &&
-		localAutoSkipDisliked.value === autoSkipDisliked.value &&
-		localActivityLogPublic.value === activityLogPublic.value &&
-		localAnonymousSongRequests.value === anonymousSongRequests.value &&
-		localActivityWatch.value === activityWatch.value
-	) {
-		new Toast("Please make a change before saving.");
-
-		return saveButton.value.handleFailedSave();
-	}
-
-	saveButton.value.status = "disabled";
-
-	return socket.dispatch(
-		"users.updatePreferences",
-		{
-			nightmode: localNightmode.value,
-			autoSkipDisliked: localAutoSkipDisliked.value,
-			activityLogPublic: localActivityLogPublic.value,
-			anonymousSongRequests: localAnonymousSongRequests.value,
-			activityWatch: localActivityWatch.value
-		},
-		res => {
-			if (res.status !== "success") {
-				new Toast(res.message);
-				return saveButton.value.handleFailedSave();
-			}
-
-			new Toast("Successfully updated preferences");
-			return saveButton.value.handleSuccessfulSave();
+const { runJob } = useWebsocketStore();
+const { onReady } = useEvents();
+const { registerModel } = useModels();
+
+const userAuthStore = useUserAuthStore();
+
+const { currentUser } = storeToRefs(userAuthStore);
+
+const { inputs, saveButton, save, setModelValues } = useForm(
+	{
+		nightmode: false,
+		autoSkipDisliked: false,
+		activityLogPublic: false,
+		anonymousSongRequests: false,
+		activityWatch: false
+	},
+	({ status, messages, values }, resolve, reject) => {
+		if (status === "success") {
+			runJob(`data.users.updateById`, {
+				_id: currentUser.value._id,
+				query: values
+			})
+				.then(resolve)
+				.catch(reject);
+		} else {
+			if (status === "unchanged") new Toast(messages.unchanged);
+			else if (status === "error")
+				Object.values(messages).forEach(message => {
+					new Toast({ content: message, timeout: 8000 });
+				});
+			resolve();
 		}
-	);
-};
-
-onMounted(() => {
-	socket.onConnect(() => {
-		socket.dispatch("users.getPreferences", res => {
-			const { preferences } = res.data;
-
-			if (res.status === "success") {
-				localNightmode.value = preferences.nightmode;
-				localAutoSkipDisliked.value = preferences.autoSkipDisliked;
-				localActivityLogPublic.value = preferences.activityLogPublic;
-				localAnonymousSongRequests.value =
-					preferences.anonymousSongRequests;
-				localActivityWatch.value = preferences.activityWatch;
-			}
-		});
-	});
-
-	socket.on("keep.event:user.preferences.updated", res => {
-		const { preferences } = res.data;
-
-		if (preferences.nightmode !== undefined)
-			localNightmode.value = preferences.nightmode;
-
-		if (preferences.autoSkipDisliked !== undefined)
-			localAutoSkipDisliked.value = preferences.autoSkipDisliked;
-
-		if (preferences.activityLogPublic !== undefined)
-			localActivityLogPublic.value = preferences.activityLogPublic;
-
-		if (preferences.anonymousSongRequests !== undefined)
-			localAnonymousSongRequests.value =
-				preferences.anonymousSongRequests;
+	}
+);
 
-		if (preferences.activityWatch !== undefined)
-			localActivityWatch.value = preferences.activityWatch;
+onMounted(async () => {
+	await onReady(async () => {
+		setModelValues(await registerModel(currentUser.value), [
+			"nightmode",
+			"autoSkipDisliked",
+			"activityLogPublic",
+			"anonymousSongRequests",
+			"activityWatch"
+		]);
 	});
 });
 </script>
@@ -115,7 +73,7 @@ onMounted(() => {
 				<input
 					type="checkbox"
 					id="nightmode"
-					v-model="localNightmode"
+					v-model="inputs.nightmode.value"
 				/>
 				<span class="slider round"></span>
 			</label>
@@ -130,7 +88,7 @@ onMounted(() => {
 				<input
 					type="checkbox"
 					id="autoSkipDisliked"
-					v-model="localAutoSkipDisliked"
+					v-model="inputs.autoSkipDisliked.value"
 				/>
 				<span class="slider round"></span>
 			</label>
@@ -145,7 +103,7 @@ onMounted(() => {
 				<input
 					type="checkbox"
 					id="activityLogPublic"
-					v-model="localActivityLogPublic"
+					v-model="inputs.activityLogPublic.value"
 				/>
 				<span class="slider round"></span>
 			</label>
@@ -160,7 +118,7 @@ onMounted(() => {
 				<input
 					type="checkbox"
 					id="anonymousSongRequests"
-					v-model="localAnonymousSongRequests"
+					v-model="inputs.anonymousSongRequests.value"
 				/>
 				<span class="slider round"></span>
 			</label>
@@ -176,7 +134,7 @@ onMounted(() => {
 				<input
 					type="checkbox"
 					id="activityWatch"
-					v-model="localActivityWatch"
+					v-model="inputs.activityWatch.value"
 				/>
 				<span class="slider round"></span>
 			</label>
@@ -187,6 +145,6 @@ onMounted(() => {
 			</label>
 		</p>
 
-		<SaveButton ref="saveButton" @clicked="saveChanges()" />
+		<SaveButton ref="saveButton" @clicked="save()" />
 	</div>
 </template>

+ 93 - 175
frontend/src/pages/Settings/Tabs/Profile.vue

@@ -1,11 +1,13 @@
 <script setup lang="ts">
-import { defineAsyncComponent, ref } from "vue";
+import { defineAsyncComponent, onMounted } from "vue";
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
-import { useSettingsStore } from "@/stores/settings";
-import { useWebsocketsStore } from "@/stores/websockets";
 import { useUserAuthStore } from "@/stores/userAuth";
 import validation from "@/validation";
+import { useForm } from "@/composables/useForm";
+import { useEvents } from "@/composables/useEvents";
+import { useModels } from "@/composables/useModels";
+import { useWebsocketStore } from "@/stores/websocket";
 
 const ProfilePicture = defineAsyncComponent(
 	() => import("@/components/ProfilePicture.vue")
@@ -14,164 +16,81 @@ const SaveButton = defineAsyncComponent(
 	() => import("@/components/SaveButton.vue")
 );
 
-const settingsStore = useSettingsStore();
-const userAuthStore = useUserAuthStore();
-
-const { socket } = useWebsocketsStore();
+const { runJob } = useWebsocketStore();
+const { onReady } = useEvents();
+const { registerModel } = useModels();
 
-const saveButton = ref();
+const userAuthStore = useUserAuthStore();
 
 const { currentUser } = storeToRefs(userAuthStore);
-const { originalUser, modifiedUser } = settingsStore;
-
-const { updateOriginalUser } = settingsStore;
-
-const changeName = () => {
-	modifiedUser.name = modifiedUser.name.replaceAll(/ +/g, " ").trim();
-	const { name } = modifiedUser;
-
-	if (!validation.isLength(name, 1, 64))
-		return new Toast("Name must have between 1 and 64 characters.");
-
-	if (!validation.regex.name.test(name))
-		return new Toast(
-			"Invalid name format. Only letters, numbers, spaces, apostrophes, underscores and hyphens are allowed."
-		);
-	if (name.replaceAll(/[ .'_-]/g, "").length === 0)
-		return new Toast(
-			"Invalid name format. Only letters, numbers, spaces, apostrophes, underscores and hyphens are allowed, and there has to be at least one letter or number."
-		);
-
-	saveButton.value.status = "disabled";
-
-	return socket.dispatch(
-		"users.updateName",
-		currentUser.value?._id,
-		name,
-		res => {
-			if (res.status !== "success") {
-				new Toast(res.message);
-				saveButton.value.handleFailedSave();
-			} else {
-				new Toast("Successfully changed name");
-
-				updateOriginalUser({
-					property: "name",
-					value: name
-				});
 
-				saveButton.value.handleSuccessfulSave();
+const { inputs, saveButton, save, setModelValues } = useForm(
+	{
+		name: {
+			value: null,
+			validate: (value: string) => {
+				if (!validation.isLength(value, 1, 64))
+					return "Name must have between 1 and 64 characters.";
+				if (!validation.regex.name.test(value))
+					return "Invalid name format. Only letters, numbers, spaces, apostrophes, underscores and hyphens are allowed.";
+				if (value.replaceAll(/[ .'_-]/g, "").length === 0)
+					return "Invalid name format. Only letters, numbers, spaces, apostrophes, underscores and hyphens are allowed, and there has to be at least one letter or number.";
+				return true;
 			}
-		}
-	);
-};
-
-const changeLocation = () => {
-	const { location } = modifiedUser;
-
-	if (!validation.isLength(location, 0, 50))
-		return new Toast("Location must have between 0 and 50 characters.");
-
-	saveButton.value.status = "disabled";
-
-	return socket.dispatch(
-		"users.updateLocation",
-		currentUser.value?._id,
-		location,
-		res => {
-			if (res.status !== "success") {
-				new Toast(res.message);
-				saveButton.value.handleFailedSave();
-			} else {
-				new Toast("Successfully changed location");
-
-				updateOriginalUser({
-					property: "location",
-					value: location
-				});
-
-				saveButton.value.handleSuccessfulSave();
+		},
+		location: {
+			value: null,
+			required: false,
+			validate: (value?: string) => {
+				if (value === null) return true;
+				if (!validation.isLength(value, 0, 50))
+					return "Location must be less than 50 characters.";
+				return true;
 			}
-		}
-	);
-};
-
-const changeBio = () => {
-	const { bio } = modifiedUser;
-
-	if (!validation.isLength(bio, 0, 200))
-		return new Toast("Bio must have between 0 and 200 characters.");
-
-	saveButton.value.status = "disabled";
-
-	return socket.dispatch(
-		"users.updateBio",
-		currentUser.value?._id,
-		bio,
-		res => {
-			if (res.status !== "success") {
-				new Toast(res.message);
-				saveButton.value.handleFailedSave();
-			} else {
-				new Toast("Successfully changed bio");
-
-				updateOriginalUser({
-					property: "bio",
-					value: bio
-				});
-
-				saveButton.value.handleSuccessfulSave();
+		},
+		bio: {
+			value: null,
+			required: false,
+			validate: (value?: string) => {
+				if (value === null) return true;
+				if (!validation.isLength(value, 0, 200))
+					return "Bio must be less than 200 characters.";
+				return true;
 			}
-		}
-	);
-};
-
-const changeAvatar = () => {
-	const { avatar } = modifiedUser;
-
-	saveButton.value.status = "disabled";
-
-	return socket.dispatch(
-		"users.updateAvatar",
-		currentUser.value?._id,
-		avatar,
-		res => {
-			if (res.status !== "success") {
-				new Toast(res.message);
-				saveButton.value.handleFailedSave();
-			} else {
-				new Toast("Successfully updated avatar");
-
-				updateOriginalUser({
-					property: "avatar",
-					value: avatar
+		},
+		avatarType: "initials",
+		avatarColor: "blue"
+	},
+	({ status, messages, values }, resolve, reject) => {
+		if (status === "success") {
+			runJob(`data.users.updateById`, {
+				_id: currentUser.value._id,
+				query: values
+			})
+				.then(resolve)
+				.catch(reject);
+		} else {
+			if (status === "unchanged") new Toast(messages.unchanged);
+			else if (status === "error")
+				Object.values(messages).forEach(message => {
+					new Toast({ content: message, timeout: 8000 });
 				});
-
-				saveButton.value.handleSuccessfulSave();
-			}
+			resolve();
 		}
-	);
-};
-
-const saveChanges = () => {
-	const nameChanged = modifiedUser.name !== originalUser.name;
-	const locationChanged = modifiedUser.location !== originalUser.location;
-	const bioChanged = modifiedUser.bio !== originalUser.bio;
-	const avatarChanged =
-		modifiedUser.avatar.type !== originalUser.avatar.type ||
-		modifiedUser.avatar.color !== originalUser.avatar.color;
-
-	if (nameChanged) changeName();
-	if (locationChanged) changeLocation();
-	if (bioChanged) changeBio();
-	if (avatarChanged) changeAvatar();
-
-	if (!avatarChanged && !bioChanged && !locationChanged && !nameChanged) {
-		saveButton.value.handleFailedSave();
-
-		new Toast("Please make a change before saving.");
 	}
-};
+);
+
+onMounted(async () => {
+	await onReady(async () => {
+		setModelValues(await registerModel(currentUser.value), [
+			"name",
+			"location",
+			"bio",
+			"avatarType",
+			"avatarColor"
+		]);
+	});
+});
 </script>
 
 <template>
@@ -183,31 +102,30 @@ const saveChanges = () => {
 
 		<hr class="section-horizontal-rule" />
 
-		<div
-			class="control is-expanded avatar-selection-outer-container"
-			v-if="modifiedUser.avatar"
-		>
+		<div class="control is-expanded avatar-selection-outer-container">
 			<label>Avatar</label>
 			<div id="avatar-selection-inner-container">
 				<profile-picture
-					:avatar="modifiedUser.avatar"
+					:type="inputs.avatarType.value"
+					:color="inputs.avatarColor.value"
+					:url="currentUser.avatarUrl"
 					:name="
-						modifiedUser.name
-							? modifiedUser.name
-							: modifiedUser.username
+						currentUser.name
+							? currentUser.name
+							: currentUser.username
 					"
 				/>
 				<div class="select">
-					<select v-model="modifiedUser.avatar.type">
+					<select v-model="inputs.avatarType.value">
 						<option value="gravatar">Using Gravatar</option>
 						<option value="initials">Based on initials</option>
 					</select>
 				</div>
 				<div
 					class="select"
-					v-if="modifiedUser.avatar.type === 'initials'"
+					v-if="inputs.avatarType.value === 'initials'"
 				>
-					<select v-model="modifiedUser.avatar.color">
+					<select v-model="inputs.avatarColor.value">
 						<option value="blue">Blue</option>
 						<option value="orange">Orange</option>
 						<option value="green">Green</option>
@@ -225,11 +143,11 @@ const saveChanges = () => {
 				type="text"
 				placeholder="Enter name here..."
 				maxlength="64"
-				v-model="modifiedUser.name"
+				v-model="inputs.name.value"
 			/>
-			<span v-if="modifiedUser.name" class="character-counter"
-				>{{ modifiedUser.name.length }}/64</span
-			>
+			<span v-if="inputs.name.value" class="character-counter">
+				{{ inputs.name.value.length }}/64
+			</span>
 		</p>
 		<p class="control is-expanded">
 			<label for="location">Location</label>
@@ -239,11 +157,11 @@ const saveChanges = () => {
 				type="text"
 				placeholder="Enter location here..."
 				maxlength="50"
-				v-model="modifiedUser.location"
+				v-model="inputs.location.value"
 			/>
-			<span v-if="modifiedUser.location" class="character-counter"
-				>{{ modifiedUser.location.length }}/50</span
-			>
+			<span v-if="inputs.location.value" class="character-counter">
+				{{ inputs.location.value.length }}/50
+			</span>
 		</p>
 		<p class="control is-expanded">
 			<label for="bio">Bio</label>
@@ -253,14 +171,14 @@ const saveChanges = () => {
 				placeholder="Enter bio here..."
 				maxlength="200"
 				autocomplete="off"
-				v-model="modifiedUser.bio"
+				v-model="inputs.bio.value"
 			/>
-			<span v-if="modifiedUser.bio" class="character-counter"
-				>{{ modifiedUser.bio.length }}/200</span
-			>
+			<span v-if="inputs.bio.value" class="character-counter">
+				{{ inputs.bio.value.length }}/200
+			</span>
 		</p>
 
-		<SaveButton ref="saveButton" @clicked="saveChanges()" />
+		<SaveButton ref="saveButton" @clicked="save()" />
 	</div>
 </template>
 

+ 1 - 46
frontend/src/pages/Settings/index.vue

@@ -1,9 +1,6 @@
 <script setup lang="ts">
 import { useRoute } from "vue-router";
 import { onMounted, defineAsyncComponent } from "vue";
-import Toast from "toasters";
-import { useSettingsStore } from "@/stores/settings";
-import { useWebsocketsStore } from "@/stores/websockets";
 import { useTabQueryHandler } from "@/composables/useTabQueryHandler";
 
 const MainHeader = defineAsyncComponent(
@@ -25,15 +22,10 @@ const PreferencesSettings = defineAsyncComponent(
 	() => import("./Tabs/Preferences.vue")
 );
 
-const settingsStore = useSettingsStore();
 const route = useRoute();
 const { tab, showTab } = useTabQueryHandler("");
 
-const { socket } = useWebsocketsStore();
-
-const { setUser, updateOriginalUser } = settingsStore;
-
-onMounted(() => {
+onMounted(async () => {
 	if (
 		route.query.tab === "profile" ||
 		route.query.tab === "security" ||
@@ -42,43 +34,6 @@ onMounted(() => {
 	)
 		tab.value = route.query.tab;
 	else tab.value = "profile";
-
-	// this.localNightmode = this.nightmode;
-
-	socket.onConnect(() => {
-		socket.dispatch("users.findBySession", res => {
-			if (res.status === "success") setUser(res.data.user);
-			else new Toast("You're not currently signed in.");
-		});
-	});
-
-	socket.on("event:user.password.linked", () =>
-		updateOriginalUser({
-			property: "password",
-			value: true
-		})
-	);
-
-	socket.on("event:user.password.unlinked", () =>
-		updateOriginalUser({
-			property: "password",
-			value: false
-		})
-	);
-
-	socket.on("event:user.github.linked", () =>
-		updateOriginalUser({
-			property: "github",
-			value: true
-		})
-	);
-
-	socket.on("event:user.github.unlinked", () =>
-		updateOriginalUser({
-			property: "github",
-			value: false
-		})
-	);
 });
 </script>
 

+ 2 - 2
frontend/src/pages/Station/index.vue

@@ -132,8 +132,8 @@ const { activeModals } = storeToRefs(modalsStore);
 // TODO fix this if it still has some use, as this is no longer accurate
 // const video = computed(() => store.state.modals.editSong);
 
-const { loggedIn, currentUser } = storeToRefs(userAuthStore);
-const { nightmode, autoSkipDisliked } = storeToRefs(userPreferencesStore);
+const { loggedIn, currentUser, nightmode } = storeToRefs(userAuthStore);
+const { autoSkipDisliked } = storeToRefs(userPreferencesStore);
 const {
 	station,
 	currentSong,

+ 1 - 1
frontend/src/stores/model.ts

@@ -254,7 +254,7 @@ export const useModelStore = defineStore("model", () => {
 
 			model.removeUse();
 
-			if (model.getUses() > 1) return;
+			if (model.getUses() > 0) return;
 
 			// TODO only do this after a grace period
 			removeModels.push(model);

+ 44 - 5
frontend/src/stores/userAuth.ts

@@ -1,5 +1,6 @@
 import { defineStore } from "pinia";
-import { computed, ref } from "vue";
+import { computed, ref, watch } from "vue";
+import Toast from "toasters";
 import validation from "@/validation";
 import { useWebsocketStore } from "@/stores/websocket";
 import { useConfigStore } from "@/stores/config";
@@ -31,6 +32,7 @@ export const useUserAuthStore = defineStore("userAuth", () => {
 	const gotData = ref(false);
 	const gotPermissions = ref(false);
 	const permissions = ref<Record<string, boolean>>({});
+	const nightmode = ref(false);
 
 	const loggedIn = computed(() => !!currentUser.value);
 
@@ -119,9 +121,11 @@ export const useUserAuthStore = defineStore("userAuth", () => {
 			data.SID
 		}; expires=${date.toUTCString()}; ${domain}${secure}path=/`;
 
-		const bc = new BroadcastChannel(`${configStore.cookie}.user_login`);
-		bc.postMessage(true);
-		bc.close();
+		const loginBroadcastChannel = new BroadcastChannel(
+			`${configStore.cookie}.user_login`
+		);
+		loginBroadcastChannel.postMessage(true);
+		loginBroadcastChannel.close();
 	};
 
 	const logout = async () => {
@@ -232,6 +236,39 @@ export const useUserAuthStore = defineStore("userAuth", () => {
 		}; expires=${date.toUTCString()}; ${domain}${secure}path=/`;
 	};
 
+	const toggleNightmode = async () => {
+		if (loggedIn.value) {
+			try {
+				await websocketStore.runJob(`data.users.updateById`, {
+					_id: currentUser.value._id,
+					query: {
+						nightmode: !nightmode.value
+					}
+				});
+			} catch (error) {
+				new Toast(error.message);
+			}
+		} else {
+			nightmode.value = !nightmode.value;
+
+			const nightmodeBroadcastChannel = new BroadcastChannel(
+				`${configStore.cookie}.nightmode`
+			);
+			nightmodeBroadcastChannel.postMessage(nightmode.value);
+			nightmodeBroadcastChannel.close();
+		}
+	};
+
+	watch(
+		currentUser,
+		user => {
+			if (!user) return;
+
+			nightmode.value = user.nightmode;
+		},
+		{ deep: true }
+	);
+
 	return {
 		userIdMap,
 		userIdRequested,
@@ -242,6 +279,7 @@ export const useUserAuthStore = defineStore("userAuth", () => {
 		gotData,
 		gotPermissions,
 		permissions,
+		nightmode,
 		loggedIn,
 		register,
 		login,
@@ -254,6 +292,7 @@ export const useUserAuthStore = defineStore("userAuth", () => {
 		banUser,
 		hasPermission,
 		updatePermissions,
-		resetCookieExpiration
+		resetCookieExpiration,
+		toggleNightmode
 	};
 });

+ 6 - 6
frontend/src/stores/websocket.ts

@@ -1,4 +1,4 @@
-import { defineStore } from "pinia";
+import { defineStore, storeToRefs } from "pinia";
 import { ref } from "vue";
 import { generateUuid } from "@common/utils/generateUuid";
 import { forEachIn } from "@common/utils/forEachIn";
@@ -187,14 +187,14 @@ export const useWebsocketStore = defineStore("websocket", () => {
 
 		ready.value = true;
 
-		userAuthStore.currentUser = data.user
+		const { currentUser, gotData, loggedIn } = storeToRefs(userAuthStore);
+
+		currentUser.value = data.user
 			? await modelStore.registerModel(data.user)
 			: null;
-		userAuthStore.gotData = true;
+		gotData.value = true;
 
-		if (userAuthStore.loggedIn) {
-			userAuthStore.resetCookieExpiration();
-		}
+		if (loggedIn.value) userAuthStore.resetCookieExpiration();
 
 		if (configStore.experimental.media_session) ms.initialize();
 		else ms.uninitialize();