소스 검색

refactor: Continued unsaved changes and form data handling improvements

Owen Diffey 2 년 전
부모
커밋
f586edfb9e

+ 1 - 1
frontend/src/App.vue

@@ -271,7 +271,7 @@ onMounted(async () => {
 	if (localStorage.getItem("nightmode") === "true") {
 		changeNightmode(true);
 		enableNightmode();
-	}
+	} else changeNightmode(false);
 
 	lofig.get("siteSettings.christmas").then((enabled: boolean) => {
 		if (enabled) {

+ 5 - 6
frontend/src/components/modals/EditNews.vue

@@ -120,12 +120,11 @@ onMounted(() => {
 		if (newsId.value && !createNews.value) {
 			socket.dispatch(`news.getNewsFromId`, newsId.value, res => {
 				if (res.status === "success") {
-					setOriginalValue("markdown", res.data.news.markdown);
-					setOriginalValue("status", res.data.news.status);
-					setOriginalValue(
-						"showToNewUsers",
-						res.data.news.showToNewUsers
-					);
+					setOriginalValue({
+						markdown: res.data.news.markdown,
+						status: res.data.news.status,
+						showToNewUsers: res.data.news.showToNewUsers
+					});
 					createdBy.value = res.data.news.createdBy;
 					createdAt.value = res.data.news.createdAt;
 				} else {

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

@@ -119,9 +119,9 @@ const {
 
 watch(playlist, (value, oldValue) => {
 	if (value.displayName !== oldValue.displayName)
-		setDisplayName("displayName", value.displayName);
+		setDisplayName({ displayName: value.displayName });
 	if (value.privacy !== oldValue.privacy)
-		setPrivacy("privacy", value.privacy);
+		setPrivacy({ privacy: value.privacy });
 });
 
 onMounted(() => {

+ 228 - 294
frontend/src/components/modals/EditSong/index.vue

@@ -12,6 +12,7 @@ import Toast from "toasters";
 import aw from "@/aw";
 import validation from "@/validation";
 import keyboardShortcuts from "@/keyboardShortcuts";
+import { useForm } from "@/composables/useForm";
 
 import { Song } from "@/types/song.js";
 
@@ -66,7 +67,6 @@ const {
 	song,
 	youtubeId,
 	prefillData,
-	originalSong,
 	reports,
 	newSong,
 	bulk,
@@ -84,9 +84,6 @@ const youtubeVideoNote = ref("");
 const useHTTPS = ref(false);
 const muted = ref(false);
 const volumeSliderValue = ref(0);
-const artistInputValue = ref("");
-const genreInputValue = ref("");
-const tagInputValue = ref("");
 const activityWatchVideoDataInterval = ref(null);
 const activityWatchVideoLastStatus = ref("");
 const activityWatchVideoLastStartDuration = ref(0);
@@ -137,12 +134,12 @@ const thumbnailWidth = ref(null);
 const thumbnailHeight = ref(null);
 const thumbnailLoadError = ref(false);
 const tabs = ref([]);
-const inputs = ref([]);
 const playerReady = ref(true);
 const interval = ref();
 const saveButtonRefs = ref(<any>[]);
 const canvasElement = ref();
 const genreHelper = ref();
+const saveButtonRefName = ref();
 // EditSongs
 const items = ref([]);
 const currentSong = ref(<Song>{});
@@ -196,7 +193,6 @@ const {
 	pauseVideo,
 	setSong,
 	resetSong,
-	updateOriginalSong,
 	updateSongField,
 	updateReports,
 	setPlaybackRate
@@ -300,12 +296,12 @@ const onSavedError = youtubeId => {
 	if (itemIndex > -1) items.value[itemIndex].status = "error";
 };
 
-const onSaving = youtubeId => {
-	const itemIndex = items.value.findIndex(
-		item => item.song.youtubeId === youtubeId
-	);
-	if (itemIndex > -1) items.value[itemIndex].status = "saving";
-};
+// const onSaving = youtubeId => {
+// 	const itemIndex = items.value.findIndex(
+// 		item => item.song.youtubeId === youtubeId
+// 	);
+// 	if (itemIndex > -1) items.value[itemIndex].status = "saving";
+// };
 // EditSongs end
 
 const onThumbnailLoad = () => {
@@ -327,6 +323,167 @@ const onThumbnailLoadError = error => {
 	thumbnailLoadError.value = error !== 0;
 };
 
+const { inputs, unsavedChanges, save, setValue, setOriginalValue } = useForm(
+	{
+		title: {
+			value: "",
+			validate: value => {
+				if (!validation.isLength(value, 1, 100))
+					return "Title must have between 1 and 100 characters.";
+				return true;
+			}
+		},
+		duration: {
+			value: 0,
+			validate: value => {
+				if (
+					Number(inputs.value.skipDuration.value) + Number(value) >
+						Number.parseInt(youtubeVideoDuration.value) &&
+					(((!newSong.value || bulk.value) && !youtubeError.value) ||
+						inputs.value.duration.originalValue !== value)
+				)
+					return "Duration can't be higher than the length of the video";
+				return true;
+			}
+		},
+		skipDuration: 0,
+		thumbnail: {
+			value: "",
+			validate: value => {
+				if (!validation.isLength(value, 8, 256))
+					return "Thumbnail must have between 8 and 256 characters.";
+				if (useHTTPS.value && value.indexOf("https://") !== 0)
+					return 'Thumbnail must start with "https://".';
+				if (
+					!useHTTPS.value &&
+					value.indexOf("https://") !== 0 &&
+					value.indexOf("http://") !== 0
+				)
+					return 'Thumbnail must start with "http(s)://".';
+				return true;
+			}
+		},
+		youtubeId: {
+			value: "",
+			validate: value => {
+				if (
+					!newSong.value &&
+					youtubeError.value &&
+					inputs.value.youtubeId.originalValue !== value
+				)
+					return "You're not allowed to change the YouTube id while the player is not working";
+				return true;
+			}
+		},
+		verified: false,
+		addArtist: "",
+		artists: {
+			value: [],
+			validate: value => {
+				if (
+					(inputs.value.verified.value && value.length < 1) ||
+					value.length > 10
+				)
+					return "Invalid artists. You must have at least 1 artist and a maximum of 10 artists.";
+
+				let error;
+				value.forEach(artist => {
+					if (!validation.isLength(artist, 1, 64))
+						error = "Artist must have between 1 and 64 characters.";
+					if (artist === "NONE")
+						error =
+							'Invalid artist format. Artists are not allowed to be named "NONE".';
+				});
+				return error || true;
+			}
+		},
+		addGenre: "",
+		genres: {
+			value: [],
+			validate: value => {
+				if (
+					(inputs.value.verified.value && value.length < 1) ||
+					value.length > 16
+				)
+					return "Invalid genres. You must have between 1 and 16 genres.";
+
+				let error;
+				value.forEach(genre => {
+					if (!validation.isLength(genre, 1, 32))
+						error = "Genre must have between 1 and 32 characters.";
+					if (!validation.regex.ascii.test(genre))
+						error =
+							"Invalid genre format. Only ascii characters are allowed.";
+				});
+				return error || true;
+			}
+		},
+		addTag: "",
+		tags: {
+			value: [],
+			validate: value => {
+				let error;
+				value.forEach(tag => {
+					if (
+						!/^[a-zA-Z0-9_]{1,64}$|^[a-zA-Z0-9_]{1,64}\[[a-zA-Z0-9_]{1,64}\]$/.test(
+							tag
+						)
+					)
+						error = "Invalid tag format.";
+				});
+				return error || true;
+			}
+		}
+	},
+	(status, message, values) =>
+		new Promise((resolve, reject) => {
+			const saveButtonRef = saveButtonRefs.value[saveButtonRefName.value];
+			const mergedValues = {
+				...JSON.parse(JSON.stringify(song.value)),
+				...values
+			};
+			if (status === "success") {
+				const cb = res => {
+					if (res.status === "error") {
+						reject(new Error(res.message));
+						return;
+					}
+					new Toast(res.message);
+					saveButtonRef.handleSuccessfulSave();
+					onSavedSuccess(values.youtubeId);
+					if (!newSong.value) setSong(mergedValues);
+				};
+				if (newSong.value)
+					socket.dispatch("songs.create", mergedValues, cb);
+				else
+					socket.dispatch(
+						"songs.update",
+						song.value._id,
+						mergedValues,
+						cb
+					);
+			} else {
+				new Toast(message);
+				if (status === "unchanged" && !newSong.value) {
+					saveButtonRef.handleSuccessfulSave();
+					onSavedSuccess(values.youtubeId);
+				} else {
+					saveButtonRef.handleFailedSave();
+					onSavedError(values.youtubeId);
+				}
+			}
+		}),
+	{ modalUuid: props.modalUuid, preventCloseUnsaved: false }
+);
+
+const saveSong = (refName: string, closeOrNext?: boolean) => {
+	saveButtonRefName.value = refName;
+	save(() => {
+		if (closeOrNext && bulk.value) editNextSong();
+		else if (closeOrNext) closeCurrentModal();
+	});
+};
+
 const unloadSong = (_youtubeId, songId?) => {
 	songDataLoaded.value = false;
 	songDeleted.value = false;
@@ -345,7 +502,7 @@ const unloadSong = (_youtubeId, songId?) => {
 		saveButtonRefs.value.saveButton.status = "default";
 };
 
-const loadSong = _youtubeId => {
+const loadSong = (_youtubeId: string, reset?: boolean) => {
 	console.log(`LOAD SONG ${_youtubeId}`);
 	songNotFound.value = false;
 	socket.dispatch(`songs.getSongsFromYoutubeIds`, [_youtubeId], res => {
@@ -354,7 +511,7 @@ const loadSong = _youtubeId => {
 			let _song = songs[0];
 			_song = Object.assign(_song, prefillData.value);
 
-			setSong(_song);
+			setSong(_song, reset);
 
 			songDataLoaded.value = true;
 
@@ -428,232 +585,6 @@ const seekTo = position => {
 	video.value.player.seekTo(position);
 };
 
-const save = (songToCopy, closeOrNext, saveButtonRefName, _newSong = false) => {
-	const _song = JSON.parse(JSON.stringify(songToCopy));
-
-	if (!newSong.value || bulk.value) onSaving(_song.youtubeId);
-
-	const saveButtonRef = saveButtonRefs.value[saveButtonRefName];
-
-	if (!youtubeError.value && youtubeVideoDuration.value === "0.000") {
-		saveButtonRef.handleFailedSave();
-		if (!_newSong) onSavedError(_song.youtubeId);
-		return new Toast("The video appears to not be working.");
-	}
-
-	if (!_song.title) {
-		saveButtonRef.handleFailedSave();
-		if (!_newSong || bulk.value) onSavedError(_song.youtubeId);
-		return new Toast("Please fill in all fields");
-	}
-
-	if (!_song.thumbnail) {
-		saveButtonRef.handleFailedSave();
-		if (!_newSong || bulk.value) onSavedError(_song.youtubeId);
-		return new Toast("Please fill in all fields");
-	}
-
-	// const thumbnailHeight = thumbnailElement.value.naturalHeight;
-	// const thumbnailWidth = thumbnailElement.value.naturalWidth;
-
-	// if (thumbnailHeight < 80 || thumbnailWidth < 80) {
-	// 	saveButtonRef.handleFailedSave();
-	// 	return new Toast(
-	// 		"Thumbnail width and height must be at least 80px."
-	// 	);
-	// }
-
-	// if (thumbnailHeight > 4000 || thumbnailWidth > 4000) {
-	// 	saveButtonRef.handleFailedSave();
-	// 	return new Toast(
-	// 		"Thumbnail width and height must be less than 4000px."
-	// 	);
-	// }
-
-	// if (thumbnailHeight - thumbnailWidth > 5) {
-	// 	saveButtonRef.handleFailedSave();
-	// 	return new Toast("Thumbnail cannot be taller than it is wide.");
-	// }
-
-	// Youtube Id
-	if (
-		!_newSong &&
-		youtubeError.value &&
-		originalSong.value.youtubeId !== _song.youtubeId
-	) {
-		saveButtonRef.handleFailedSave();
-		if (!_newSong || bulk.value) onSavedError(_song.youtubeId);
-		return new Toast(
-			"You're not allowed to change the YouTube id while the player is not working"
-		);
-	}
-
-	// Duration
-	if (
-		Number(_song.skipDuration) + Number(_song.duration) >
-			Number.parseInt(youtubeVideoDuration.value) &&
-		(((!_newSong || bulk.value) && !youtubeError.value) ||
-			originalSong.value.duration !== _song.duration)
-	) {
-		saveButtonRef.handleFailedSave();
-		if (!_newSong || bulk.value) onSavedError(_song.youtubeId);
-		return new Toast(
-			"Duration can't be higher than the length of the video"
-		);
-	}
-
-	// Title
-	if (!validation.isLength(_song.title, 1, 100)) {
-		saveButtonRef.handleFailedSave();
-		if (!_newSong || bulk.value) onSavedError(_song.youtubeId);
-		return new Toast("Title must have between 1 and 100 characters.");
-	}
-
-	// Artists
-	if (
-		(_song.verified && _song.artists.length < 1) ||
-		_song.artists.length > 10
-	) {
-		saveButtonRef.handleFailedSave();
-		if (!_newSong || bulk.value) onSavedError(_song.youtubeId);
-		return new Toast(
-			"Invalid artists. You must have at least 1 artist and a maximum of 10 artists."
-		);
-	}
-
-	let error;
-	_song.artists.forEach(artist => {
-		if (!validation.isLength(artist, 1, 64)) {
-			error = "Artist must have between 1 and 64 characters.";
-			return error;
-		}
-		if (artist === "NONE") {
-			error =
-				'Invalid artist format. Artists are not allowed to be named "NONE".';
-			return error;
-		}
-
-		return false;
-	});
-
-	if (error) {
-		saveButtonRef.handleFailedSave();
-		if (!_newSong || bulk.value) onSavedError(_song.youtubeId);
-		return new Toast(error);
-	}
-
-	// Genres
-	error = undefined;
-	if (_song.verified && _song.genres.length < 1)
-		_song.genres.forEach(genre => {
-			if (!validation.isLength(genre, 1, 32)) {
-				error = "Genre must have between 1 and 32 characters.";
-				return error;
-			}
-			if (!validation.regex.ascii.test(genre)) {
-				error =
-					"Invalid genre format. Only ascii characters are allowed.";
-				return error;
-			}
-
-			return false;
-		});
-
-	if ((_song.verified && _song.genres.length < 1) || _song.genres.length > 16)
-		error = "You must have between 1 and 16 genres.";
-
-	if (error) {
-		saveButtonRef.handleFailedSave();
-		if (!_newSong || bulk.value) onSavedError(_song.youtubeId);
-		return new Toast(error);
-	}
-
-	error = undefined;
-	_song.tags.forEach(tag => {
-		if (
-			!/^[a-zA-Z0-9_]{1,64}$|^[a-zA-Z0-9_]{1,64}\[[a-zA-Z0-9_]{1,64}\]$/.test(
-				tag
-			)
-		) {
-			error = "Invalid tag format.";
-			return error;
-		}
-
-		return false;
-	});
-
-	if (error) {
-		saveButtonRef.handleFailedSave();
-		if (!_newSong || bulk.value) onSavedError(_song.youtubeId);
-		return new Toast(error);
-	}
-
-	// Thumbnail
-	if (!validation.isLength(_song.thumbnail, 1, 256)) {
-		saveButtonRef.handleFailedSave();
-		if (!_newSong || bulk.value) onSavedError(_song.youtubeId);
-		return new Toast("Thumbnail must have between 8 and 256 characters.");
-	}
-	if (useHTTPS.value && _song.thumbnail.indexOf("https://") !== 0) {
-		saveButtonRef.handleFailedSave();
-		if (!_newSong || bulk.value) onSavedError(_song.youtubeId);
-		return new Toast('Thumbnail must start with "https://".');
-	}
-
-	if (
-		!useHTTPS.value &&
-		_song.thumbnail.indexOf("http://") !== 0 &&
-		_song.thumbnail.indexOf("https://") !== 0
-	) {
-		saveButtonRef.handleFailedSave();
-		if (!_newSong || bulk.value) onSavedError(_song.youtubeId);
-		return new Toast('Thumbnail must start with "http://".');
-	}
-
-	saveButtonRef.status = "saving";
-
-	if (_newSong)
-		return socket.dispatch(`songs.create`, _song, res => {
-			new Toast(res.message);
-
-			if (res.status === "error") {
-				saveButtonRef.handleFailedSave();
-				onSavedError(_song.youtubeId);
-				return;
-			}
-
-			saveButtonRef.handleSuccessfulSave();
-			onSavedSuccess(_song.youtubeId);
-
-			if (!closeOrNext) {
-				loadSong(_song.youtubeId);
-				return;
-			}
-
-			if (bulk.value) editNextSong();
-			else closeCurrentModal();
-		});
-	return socket.dispatch(`songs.update`, _song._id, _song, res => {
-		new Toast(res.message);
-
-		if (res.status === "error") {
-			saveButtonRef.handleFailedSave();
-			onSavedError(_song.youtubeId);
-			return;
-		}
-
-		updateOriginalSong(_song);
-
-		saveButtonRef.handleSuccessfulSave();
-		onSavedSuccess(_song.youtubeId);
-
-		if (!closeOrNext) return;
-
-		if (bulk.value) editNextSong();
-		else closeCurrentModal();
-	});
-};
-
 const getAlbumData = type => {
 	if (!song.value.discogs) return;
 	if (type === "title")
@@ -704,7 +635,7 @@ const getYouTubeData = type => {
 		try {
 			const { author } = video.value.player.getVideoData();
 
-			if (author) artistInputValue.value = author;
+			if (author) setValue({ addArtist: author });
 			else throw new Error("No video author found");
 		} catch (e) {
 			new Toast(
@@ -776,40 +707,40 @@ const toggleMute = () => {
 
 const addTag = (type, value?) => {
 	if (type === "genres") {
-		const genre = value || genreInputValue.value.trim();
+		const genre = value || inputs.value.addGenre.value.trim();
 
 		if (
-			song.value.genres
+			inputs.value.genres.value
 				.map(genre => genre.toLowerCase())
 				.indexOf(genre.toLowerCase()) !== -1
 		)
 			return new Toast("Genre already exists");
 		if (genre) {
-			song.value.genres.push(genre);
-			genreInputValue.value = "";
+			inputs.value.genres.value.push(genre);
+			inputs.value.addGenre.value = "";
 			return false;
 		}
 
 		return new Toast("Genre cannot be empty");
 	}
 	if (type === "artists") {
-		const artist = value || artistInputValue.value;
-		if (song.value.artists.indexOf(artist) !== -1)
+		const artist = value || inputs.value.addArtist.value.trim();
+		if (inputs.value.artists.value.indexOf(artist) !== -1)
 			return new Toast("Artist already exists");
 		if (artist !== "") {
-			song.value.artists.push(artist);
-			artistInputValue.value = "";
+			inputs.value.artists.value.push(artist);
+			inputs.value.addArtist.value = "";
 			return false;
 		}
 		return new Toast("Artist cannot be empty");
 	}
 	if (type === "tags") {
-		const tag = value || tagInputValue.value;
-		if (song.value.tags.indexOf(tag) !== -1)
+		const tag = value || inputs.value.addTag.value.trim();
+		if (inputs.value.tags.value.indexOf(tag) !== -1)
 			return new Toast("Tag already exists");
 		if (tag !== "") {
-			song.value.tags.push(tag);
-			tagInputValue.value = "";
+			inputs.value.tags.value.push(tag);
+			inputs.value.addTag.value = "";
 			return false;
 		}
 		return new Toast("Tag cannot be empty");
@@ -820,11 +751,20 @@ const addTag = (type, value?) => {
 
 const removeTag = (type, value) => {
 	if (type === "genres")
-		song.value.genres.splice(song.value.genres.indexOf(value), 1);
+		inputs.value.genres.value.splice(
+			inputs.value.genres.value.indexOf(value),
+			1
+		);
 	else if (type === "artists")
-		song.value.artists.splice(song.value.artists.indexOf(value), 1);
+		inputs.value.artists.value.splice(
+			inputs.value.artists.value.indexOf(value),
+			1
+		);
 	else if (type === "tags")
-		song.value.tags.splice(song.value.tags.indexOf(value), 1);
+		inputs.value.tags.value.splice(
+			inputs.value.tags.value.indexOf(value),
+			1
+		);
 };
 
 const setTrackPosition = event => {
@@ -911,23 +851,9 @@ const confirmAction = ({ message, action, params }) => {
 
 const onCloseModal = (): Promise<void> =>
 	new Promise(resolve => {
-		const songStringified = JSON.stringify({
-			...song.value,
-			...{
-				duration: Number(song.value.duration).toFixed(3)
-			}
-		});
-		const originalSongStringified = JSON.stringify({
-			...originalSong.value,
-			...{
-				duration: Number(originalSong.value.duration).toFixed(3)
-			}
-		});
-		const unsavedChanges = songStringified !== originalSongStringified;
-
 		const confirmReasons = [];
 
-		if (unsavedChanges) {
+		if (unsavedChanges.value.length > 0) {
 			confirmReasons.push(
 				"You have unsaved changes. Are you sure you want to discard unsaved changes?"
 			);
@@ -972,7 +898,7 @@ watch(
 watch(youtubeId, (_youtubeId, _oldYoutubeId) => {
 	console.log("NEW YOUTUBE ID", _youtubeId);
 	if (_oldYoutubeId) unloadSong(_oldYoutubeId);
-	if (_youtubeId) loadSong(_youtubeId);
+	if (_youtubeId) loadSong(_youtubeId, _youtubeId !== _oldYoutubeId);
 });
 watch(
 	() => hasPermission("songs.update"),
@@ -982,6 +908,13 @@ watch(
 );
 
 onMounted(async () => {
+	editSongStore.form = {
+		inputs,
+		unsavedChanges,
+		save,
+		setValue,
+		setOriginalValue
+	};
 	preventCloseCbs[props.modalUuid] = onCloseModal;
 
 	activityWatchVideoDataInterval.value = setInterval(() => {
@@ -1905,7 +1838,7 @@ onBeforeUnmount(() => {
 								!songDeleted
 							"
 							class="thumbnail-dummy"
-							:src="song.thumbnail"
+							:src="inputs['thumbnail'].value"
 							ref="thumbnailElement"
 							@load="onThumbnailLoad"
 						/>
@@ -1922,10 +1855,8 @@ onBeforeUnmount(() => {
 									<input
 										class="input"
 										type="text"
-										:ref="
-											el => (inputs['title-input'] = el)
-										"
-										v-model="song.title"
+										:ref="el => (inputs['title'].ref = el)"
+										v-model="inputs['title'].value"
 										placeholder="Enter song title..."
 										@keyup.shift.enter="
 											getAlbumData('title')
@@ -1962,7 +1893,9 @@ onBeforeUnmount(() => {
 										class="input"
 										type="text"
 										placeholder="Enter song duration..."
-										v-model.number="song.duration"
+										v-model.number="
+											inputs['duration'].value
+										"
 										@keyup.shift.enter="fillDuration()"
 									/>
 									<button
@@ -1986,7 +1919,9 @@ onBeforeUnmount(() => {
 										class="input"
 										type="text"
 										placeholder="Enter skip duration..."
-										v-model.number="song.skipDuration"
+										v-model.number="
+											inputs['skipDuration'].value
+										"
 									/>
 								</p>
 							</div>
@@ -2024,7 +1959,7 @@ onBeforeUnmount(() => {
 									<input
 										class="input"
 										type="text"
-										v-model="song.thumbnail"
+										v-model="inputs['thumbnail'].value"
 										placeholder="Enter link to thumbnail..."
 										@keyup.shift.enter="
 											getAlbumData('albumArt')
@@ -2060,7 +1995,7 @@ onBeforeUnmount(() => {
 										class="input"
 										type="text"
 										placeholder="Enter YouTube ID..."
-										v-model="song.youtubeId"
+										v-model="inputs['youtubeId'].value"
 									/>
 								</p>
 							</div>
@@ -2071,7 +2006,7 @@ onBeforeUnmount(() => {
 										<input
 											type="checkbox"
 											id="verified"
-											v-model="song.verified"
+											v-model="inputs['verified'].value"
 										/>
 										<span class="slider round"></span>
 									</label>
@@ -2084,7 +2019,7 @@ onBeforeUnmount(() => {
 								<label class="label">Artists</label>
 								<p class="control has-addons">
 									<auto-suggest
-										v-model="artistInputValue"
+										v-model="inputs['addArtist'].value"
 										ref="new-artist"
 										placeholder="Add artist..."
 										:all-items="
@@ -2126,7 +2061,8 @@ onBeforeUnmount(() => {
 								<div class="list-container">
 									<div
 										class="list-item"
-										v-for="artist in song.artists"
+										v-for="artist in inputs['artists']
+											.value"
 										:key="artist"
 									>
 										<div
@@ -2155,7 +2091,7 @@ onBeforeUnmount(() => {
 								</label>
 								<p class="control has-addons">
 									<auto-suggest
-										v-model="genreInputValue"
+										v-model="inputs['addGenre'].value"
 										ref="new-genre"
 										placeholder="Add genre..."
 										:all-items="autosuggest.allItems.genres"
@@ -2185,7 +2121,7 @@ onBeforeUnmount(() => {
 								<div class="list-container">
 									<div
 										class="list-item"
-										v-for="genre in song.genres"
+										v-for="genre in inputs['genres'].value"
 										:key="genre"
 									>
 										<div
@@ -2202,7 +2138,7 @@ onBeforeUnmount(() => {
 								<label class="label">Tags</label>
 								<p class="control has-addons">
 									<auto-suggest
-										v-model="tagInputValue"
+										v-model="inputs['addTag'].value"
 										ref="new-tag"
 										placeholder="Add tag..."
 										:all-items="autosuggest.allItems.tags"
@@ -2218,7 +2154,7 @@ onBeforeUnmount(() => {
 								<div class="list-container">
 									<div
 										class="list-item"
-										v-for="tag in song.tags"
+										v-for="tag in inputs['tags'].value"
 										:key="tag"
 									>
 										<div
@@ -2319,14 +2255,14 @@ onBeforeUnmount(() => {
 				<div v-if="!newSong && !songDeleted">
 					<save-button
 						:ref="el => (saveButtonRefs['saveButton'] = el)"
-						@clicked="save(song, false, 'saveButton')"
+						@clicked="saveSong('saveButton')"
 					/>
 					<save-button
 						:ref="el => (saveButtonRefs['saveAndCloseButton'] = el)"
 						:default-message="
 							bulk ? `Save and next` : `Save and close`
 						"
-						@clicked="save(song, true, 'saveAndCloseButton')"
+						@clicked="saveSong('saveAndCloseButton', true)"
 					/>
 
 					<div class="right">
@@ -2352,7 +2288,7 @@ onBeforeUnmount(() => {
 					<save-button
 						:ref="el => (saveButtonRefs['createButton'] = el)"
 						default-message="Create Song"
-						@clicked="save(song, false, 'createButton', true)"
+						@clicked="saveSong('createButton')"
 					/>
 					<save-button
 						:ref="
@@ -2361,9 +2297,7 @@ onBeforeUnmount(() => {
 						:default-message="
 							bulk ? `Create and next` : `Create and close`
 						"
-						@clicked="
-							save(song, true, 'createAndCloseButton', true)
-						"
+						@clicked="saveSong('createAndCloseButton', true)"
 					/>
 				</div>
 			</template>

+ 222 - 110
frontend/src/components/modals/EditUser.vue

@@ -1,11 +1,5 @@
 <script setup lang="ts">
-import {
-	defineAsyncComponent,
-	ref,
-	watch,
-	onMounted,
-	onBeforeUnmount
-} from "vue";
+import { defineAsyncComponent, watch, onMounted, onBeforeUnmount } from "vue";
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
 import validation from "@/validation";
@@ -13,6 +7,7 @@ import { useEditUserStore } from "@/stores/editUser";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useModalsStore } from "@/stores/modals";
 import { useUserAuthStore } from "@/stores/userAuth";
+import { useForm } from "@/composables/useForm";
 
 const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
 const QuickConfirm = defineAsyncComponent(
@@ -30,106 +25,186 @@ const { socket } = useWebsocketsStore();
 const { userId, user } = storeToRefs(editUserStore);
 const { setUser } = editUserStore;
 
-const { closeCurrentModal } = useModalsStore();
+const { closeCurrentModal, preventCloseUnsaved } = useModalsStore();
 
 const { hasPermission } = useUserAuthStore();
 
-const ban = ref({ reason: "", expiresAt: "1h" });
-
-const init = () => {
-	if (userId.value)
-		socket.dispatch(`users.getUserFromId`, userId.value, res => {
-			if (res.status === "success") {
-				setUser(res.data);
-
-				socket.dispatch("apis.joinRoom", `edit-user.${userId.value}`);
-
-				socket.on(
-					"event:user.removed",
-					res => {
-						if (res.data.userId === userId.value)
-							closeCurrentModal();
-					},
-					{ modalUuid: props.modalUuid }
-				);
-			} else {
-				new Toast("User with that ID not found");
-				closeCurrentModal();
+const {
+	inputs: usernameInputs,
+	unsavedChanges: usernameUnsaved,
+	save: saveUsername,
+	setOriginalValue: setUsername
+} = useForm(
+	{
+		username: {
+			value: user.value.username,
+			validate: value => {
+				if (!validation.isLength(value, 2, 32)) {
+					const err =
+						"Username must have between 2 and 32 characters.";
+					new Toast(err);
+					return err;
+				}
+				if (!validation.regex.custom("a-zA-Z0-9_-").test(value)) {
+					const err =
+						"Invalid username format. Allowed characters: a-z, A-Z, 0-9, _ and -.";
+					new Toast(err);
+					return err;
+				}
+				return true;
 			}
-		});
-};
-
-const updateUsername = () => {
-	const { username } = user.value;
-
-	if (!validation.isLength(username, 2, 32))
-		return new Toast("Username must have between 2 and 32 characters.");
-
-	if (!validation.regex.custom("a-zA-Z0-9_-").test(username))
-		return new Toast(
-			"Invalid username format. Allowed characters: a-z, A-Z, 0-9, _ and -."
-		);
-
-	return socket.dispatch(
-		`users.updateUsername`,
-		user.value._id,
-		username,
-		res => {
-			new Toast(res.message);
 		}
-	);
-};
-
-const updateEmail = () => {
-	const email = user.value.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) ||
-		!validation.regex.ascii.test(email)
-	)
-		return new Toast("Invalid email format.");
-
-	return socket.dispatch(`users.updateEmail`, user.value._id, email, res => {
-		new Toast(res.message);
-	});
-};
+	},
+	(status, message, values) =>
+		new Promise((resolve, reject) => {
+			if (status === "success")
+				socket.dispatch(
+					"users.updateUsername",
+					user.value._id,
+					values.username,
+					res => {
+						user.value.username = values.username;
+						if (res.status === "success") {
+							resolve();
+							new Toast(res.message);
+						} else reject(new Error(res.message));
+					}
+				);
+			else new Toast(message);
+		}),
+	{
+		modalUuid: props.modalUuid,
+		preventCloseUnsaved: false
+	}
+);
 
-const updateRole = () => {
-	socket.dispatch(
-		`users.updateRole`,
-		user.value._id,
-		user.value.role,
-		res => {
-			new Toast(res.message);
+const {
+	inputs: emailInputs,
+	unsavedChanges: emailUnsaved,
+	save: saveEmail,
+	setOriginalValue: setEmail
+} = useForm(
+	{
+		email: {
+			value: "",
+			validate: value => {
+				if (!validation.isLength(value, 3, 254)) {
+					const err = "Email must have between 3 and 254 characters.";
+					new Toast(err);
+					return err;
+				}
+				if (
+					value.indexOf("@") !== value.lastIndexOf("@") ||
+					!validation.regex.emailSimple.test(value) ||
+					!validation.regex.ascii.test(value)
+				) {
+					const err = "Invalid email format.";
+					new Toast(err);
+					return err;
+				}
+				return true;
+			}
 		}
-	);
-};
-
-const banUser = () => {
-	const { reason } = ban.value;
-
-	if (!validation.isLength(reason, 1, 64))
-		return new Toast("Reason must have between 1 and 64 characters.");
+	},
+	(status, message, values) =>
+		new Promise((resolve, reject) => {
+			if (status === "success")
+				socket.dispatch(
+					"users.updateEmail",
+					user.value._id,
+					values.email,
+					res => {
+						user.value.email.address = values.email;
+						if (res.status === "success") {
+							resolve();
+							new Toast(res.message);
+						} else reject(new Error(res.message));
+					}
+				);
+			else new Toast(message);
+		}),
+	{
+		modalUuid: props.modalUuid,
+		preventCloseUnsaved: false
+	}
+);
 
-	if (!validation.regex.ascii.test(reason))
-		return new Toast(
-			"Invalid reason format. Only ascii characters are allowed."
-		);
+const {
+	inputs: roleInputs,
+	unsavedChanges: roleUnsaved,
+	save: saveRole,
+	setOriginalValue: setRole
+} = useForm(
+	{ role: user.value.role },
+	(status, message, values) =>
+		new Promise((resolve, reject) => {
+			if (status === "success")
+				socket.dispatch(
+					"users.updateRole",
+					user.value._id,
+					values.role,
+					res => {
+						user.value.role = values.role;
+						if (res.status === "success") {
+							resolve();
+							new Toast(res.message);
+						} else reject(new Error(res.message));
+					}
+				);
+			else new Toast(message);
+		}),
+	{
+		modalUuid: props.modalUuid,
+		preventCloseUnsaved: false
+	}
+);
 
-	return socket.dispatch(
-		`users.banUserById`,
-		user.value._id,
-		ban.value.reason,
-		ban.value.expiresAt,
-		res => {
-			new Toast(res.message);
-		}
-	);
-};
+const {
+	inputs: banInputs,
+	unsavedChanges: banUnsaved,
+	save: saveBan
+} = useForm(
+	{
+		reason: {
+			value: "",
+			validate: value => {
+				if (!validation.isLength(value, 1, 64)) {
+					const err = "Reason must have between 1 and 64 characters.";
+					new Toast(err);
+					return err;
+				}
+				if (!validation.regex.ascii.test(value)) {
+					const err =
+						"Invalid reason format. Only ascii characters are allowed.";
+					new Toast(err);
+					return err;
+				}
+				return true;
+			}
+		},
+		expiresAt: "1h"
+	},
+	(status, message, values) =>
+		new Promise((resolve, reject) => {
+			if (status === "success")
+				socket.dispatch(
+					"users.banUserById",
+					user.value._id,
+					values.reason,
+					values.expiresAt,
+					res => {
+						new Toast(res.message);
+						if (res.status === "success") resolve();
+						else reject(new Error(res.message));
+					}
+				);
+			else if (status === "unchanged") new Toast(message);
+		}),
+	{
+		modalUuid: props.modalUuid,
+		preventCloseUnsaved: false
+	}
+);
 
 const resendVerificationEmail = () => {
 	socket.dispatch(`users.resendVerifyEmail`, user.value._id, res => {
@@ -155,20 +230,57 @@ const removeSessions = () => {
 	});
 };
 
-// When the userId changes, run init. There can be a delay between the modal opening and the required data (userId) being available
-watch(userId, () => init());
 watch(
 	() => hasPermission("users.get") && hasPermission("users.update"),
 	value => {
 		if (!value) closeCurrentModal();
 	}
 );
+watch(user, (value, oldValue) => {
+	if (value.username !== oldValue.username)
+		setUsername({ username: value.username });
+	if (
+		value.email &&
+		(value.email.address !== (oldValue.email && oldValue.email.address) ||
+			!emailInputs.value.email.value)
+	)
+		setEmail({ email: value.email.address });
+	if (value.role !== oldValue.role) setRole({ role: value.role });
+});
 
 onMounted(() => {
-	socket.onConnect(init);
+	preventCloseUnsaved[props.modalUuid] = () =>
+		usernameUnsaved.value.length +
+			emailUnsaved.value.length +
+			roleUnsaved.value.length +
+			banUnsaved.value.length >
+		0;
+
+	socket.onConnect(() => {
+		socket.dispatch(`users.getUserFromId`, userId.value, res => {
+			if (res.status === "success") {
+				setUser(res.data);
+
+				socket.dispatch("apis.joinRoom", `edit-user.${userId.value}`);
+
+				socket.on(
+					"event:user.removed",
+					res => {
+						if (res.data.userId === userId.value)
+							closeCurrentModal();
+					},
+					{ modalUuid: props.modalUuid }
+				);
+			} else {
+				new Toast("User with that ID not found");
+				closeCurrentModal();
+			}
+		});
+	});
 });
 
 onBeforeUnmount(() => {
+	delete preventCloseUnsaved[props.modalUuid];
 	socket.dispatch("apis.leaveRoom", `edit-user.${userId.value}`, () => {});
 	// Delete the Pinia store that was created for this modal, after all other cleanup tasks are performed
 	editUserStore.$dispose();
@@ -184,7 +296,7 @@ onBeforeUnmount(() => {
 					<p class="control is-grouped">
 						<span class="control is-expanded">
 							<input
-								v-model="user.username"
+								v-model="usernameInputs['username'].value"
 								class="input"
 								type="text"
 								placeholder="Username"
@@ -195,7 +307,7 @@ onBeforeUnmount(() => {
 							v-if="hasPermission('users.update')"
 							class="control"
 						>
-							<a class="button is-info" @click="updateUsername()"
+							<a class="button is-info" @click="saveUsername()"
 								>Update Username</a
 							>
 						</span>
@@ -205,7 +317,7 @@ onBeforeUnmount(() => {
 					<p class="control is-grouped">
 						<span class="control is-expanded">
 							<input
-								v-model="user.email.address"
+								v-model="emailInputs['email'].value"
 								class="input"
 								type="text"
 								placeholder="Email Address"
@@ -219,7 +331,7 @@ onBeforeUnmount(() => {
 							v-if="hasPermission('users.update.restricted')"
 							class="control"
 						>
-							<a class="button is-info" @click="updateEmail()"
+							<a class="button is-info" @click="saveEmail()"
 								>Update Email Address</a
 							>
 						</span>
@@ -229,7 +341,7 @@ onBeforeUnmount(() => {
 					<div class="control is-grouped">
 						<div class="control is-expanded select">
 							<select
-								v-model="user.role"
+								v-model="roleInputs['role'].value"
 								:disabled="
 									!hasPermission('users.update.restricted')
 								"
@@ -243,7 +355,7 @@ onBeforeUnmount(() => {
 							v-if="hasPermission('users.update.restricted')"
 							class="control"
 						>
-							<a class="button is-info" @click="updateRole()"
+							<a class="button is-info" @click="saveRole()"
 								>Update Role</a
 							>
 						</p>
@@ -254,7 +366,7 @@ onBeforeUnmount(() => {
 					<label class="label"> Punish/Ban User </label>
 					<p class="control is-grouped">
 						<span class="control select">
-							<select v-model="ban.expiresAt">
+							<select v-model="banInputs['expiresAt'].value">
 								<option value="1h">1 Hour</option>
 								<option value="12h">12 Hours</option>
 								<option value="1d">1 Day</option>
@@ -267,7 +379,7 @@ onBeforeUnmount(() => {
 						</span>
 						<span class="control is-expanded">
 							<input
-								v-model="ban.reason"
+								v-model="banInputs['reason'].value"
 								class="input"
 								type="text"
 								placeholder="Ban reason"
@@ -275,7 +387,7 @@ onBeforeUnmount(() => {
 							/>
 						</span>
 						<span class="control">
-							<a class="button is-danger" @click="banUser()">
+							<a class="button is-danger" @click="saveBan()">
 								Ban user
 							</a>
 						</span>

+ 28 - 1
frontend/src/components/modals/ImportAlbum.vue

@@ -36,7 +36,7 @@ const {
 	updatePlaylistSong
 } = importAlbumStore;
 
-const { openModal } = useModalsStore();
+const { openModal, preventCloseCbs } = useModalsStore();
 
 const isImportingPlaylist = ref(false);
 const trackSongs = ref([]);
@@ -327,6 +327,32 @@ const updateTrackSong = updatedSong => {
 };
 
 onMounted(() => {
+	preventCloseCbs[props.modalUuid] = (): Promise<void> =>
+		new Promise(resolve => {
+			const confirmReasons = [];
+
+			let unverifiedSongs = 0;
+			trackSongs.value.forEach(songs => {
+				songs.forEach(song => {
+					if (!song.verified) unverifiedSongs += 1;
+				});
+			});
+			if (unverifiedSongs > 0)
+				confirmReasons.push(
+					`There are still ${unverifiedSongs} unverified songs. Are you sure you want to close Import Album?`
+				);
+
+			if (confirmReasons.length > 0)
+				openModal({
+					modal: "confirm",
+					data: {
+						message: confirmReasons,
+						onCompleted: resolve
+					}
+				});
+			else resolve();
+		});
+
 	socket.onConnect(() => {
 		socket.dispatch("apis.joinRoom", "import-album");
 
@@ -337,6 +363,7 @@ onMounted(() => {
 });
 
 onBeforeUnmount(() => {
+	delete preventCloseCbs[props.modalUuid];
 	selectDiscogsAlbum({});
 	setPlaylistSongs([]);
 	showDiscogsTab("search");

+ 14 - 21
frontend/src/components/modals/ManageStation/Settings.vue

@@ -126,27 +126,20 @@ const { inputs, save, setOriginalValue } = useForm(
 	}
 );
 
-watch(station, (value, oldValue) => {
-	if (value.name !== oldValue.name) setOriginalValue("name", value.name);
-	if (value.displayName !== oldValue.displayName)
-		setOriginalValue("displayName", value.displayName);
-	if (value.description !== oldValue.description)
-		setOriginalValue("description", value.description);
-	if (value.theme !== oldValue.theme) setOriginalValue("theme", value.theme);
-	if (value.privacy !== oldValue.privacy)
-		setOriginalValue("privacy", value.privacy);
-	if (value.requests.enabled !== oldValue.requests.enabled)
-		setOriginalValue("requestsEnabled", value.requests.enabled);
-	if (value.requests.access !== oldValue.requests.access)
-		setOriginalValue("requestsAccess", value.requests.access);
-	if (value.requests.limit !== oldValue.requests.limit)
-		setOriginalValue("requestsLimit", value.requests.limit);
-	if (value.autofill.enabled !== oldValue.autofill.enabled)
-		setOriginalValue("autofillEnabled", value.autofill.enabled);
-	if (value.autofill.limit !== oldValue.autofill.limit)
-		setOriginalValue("autofillLimit", value.autofill.limit);
-	if (value.autofill.mode !== oldValue.autofill.mode)
-		setOriginalValue("autofillMode", value.autofill.mode);
+watch(station, value => {
+	setOriginalValue({
+		name: value.name,
+		displayName: value.displayName,
+		description: value.description,
+		theme: value.theme,
+		privacy: value.privacy,
+		requestsEnabled: value.requests.enabled,
+		requestsAccess: value.requests.access,
+		requestsLimit: value.requests.limit,
+		autofillEnabled: value.autofill.enabled,
+		autofillLimit: value.autofill.limit,
+		autofillMode: value.autofill.mode
+	});
 });
 </script>
 

+ 25 - 10
frontend/src/composables/useForm.ts

@@ -131,16 +131,30 @@ export const useForm = (
 		}
 	};
 
-	const setOriginalValue = (input: string, value: any) => {
-		if (
-			JSON.stringify(value) !==
-			JSON.stringify(inputs.value[input].originalValue)
-		) {
-			if (unsavedChanges.value.find(change => change === input))
-				inputs.value[input].sourceChanged = true;
-			else inputs.value[input].value = value;
-			inputs.value[input].originalValue = value;
-		}
+	const setValue = (value: { [key: string]: any }) => {
+		Object.entries(value).forEach(([name, inputValue]) => {
+			if (inputs.value[name]) {
+				inputs.value[name].sourceChanged = false;
+				inputs.value[name].value = inputValue;
+				inputs.value[name].originalValue = inputValue;
+			}
+		});
+	};
+
+	const setOriginalValue = (value: { [key: string]: any }) => {
+		Object.entries(value).forEach(([name, inputValue]) => {
+			if (inputs.value[name]) {
+				if (
+					JSON.stringify(inputValue) !==
+					JSON.stringify(inputs.value[name].originalValue)
+				) {
+					if (unsavedChanges.value.find(change => change === name))
+						inputs.value[name].sourceChanged = true;
+					else inputs.value[name].value = inputValue;
+					inputs.value[name].originalValue = inputValue;
+				}
+			}
+		});
 	};
 
 	onMounted(() => {
@@ -166,6 +180,7 @@ export const useForm = (
 		inputs,
 		unsavedChanges,
 		save,
+		setValue,
 		setOriginalValue
 	};
 };

+ 37 - 12
frontend/src/stores/editSong.ts

@@ -1,4 +1,5 @@
 import { defineStore } from "pinia";
+import { ComputedRef, Ref } from "vue";
 import { Song } from "@/types/song";
 import { Report } from "@/types/report";
 
@@ -15,14 +16,33 @@ export const useEditSongStore = ({ modalUuid }: { modalUuid: string }) =>
 			},
 			youtubeId: null,
 			song: <Song>{},
-			originalSong: <Song>{},
 			reports: <Report[]>[],
 			tab: "discogs",
 			newSong: false,
 			prefillData: {},
 			bulk: false,
 			youtubeIds: [],
-			songPrefillData: {}
+			songPrefillData: {},
+			form: <
+				{
+					inputs: Ref<{
+						[key: string]:
+							| {
+									value: any;
+									originalValue: any;
+									validate?: (value: any) => boolean | string;
+									errors: string[];
+									ref: Ref;
+									sourceChanged: boolean;
+							  }
+							| any;
+					}>;
+					unsavedChanges: ComputedRef<string[]>;
+					save: (saveCb?: () => void) => void;
+					setValue: (value: { [key: string]: any }) => void;
+					setOriginalValue: (value: { [key: string]: any }) => void;
+				}
+			>{}
 		}),
 		actions: {
 			init({ song, songs }) {
@@ -45,25 +65,30 @@ export const useEditSongStore = ({ modalUuid }: { modalUuid: string }) =>
 				this.youtubeId = song.youtubeId || null;
 				this.prefillData = song.prefill ? song.prefill : {};
 			},
-			setSong(song) {
+			setSong(song, reset?: boolean) {
 				if (song.discogs === undefined) song.discogs = null;
-				this.originalSong = JSON.parse(JSON.stringify(song));
 				this.song = JSON.parse(JSON.stringify(song));
 				this.newSong = !song._id;
 				this.youtubeId = song.youtubeId;
-			},
-			updateOriginalSong(song) {
-				this.originalSong = JSON.parse(JSON.stringify(song));
+				const formSong = {
+					title: song.title,
+					duration: song.duration,
+					skipDuration: song.skipDuration,
+					thumbnail: song.thumbnail,
+					youtubeId: song.youtubeId,
+					verified: song.verified,
+					artists: song.artists,
+					genres: song.genres,
+					tags: song.tags
+				};
+				if (reset && this.form.setValue) this.form.setValue(formSong);
+				else if (!reset && this.form.setOriginalValue)
+					this.form.setOriginalValue(formSong);
 			},
 			resetSong(youtubeId) {
 				if (this.youtubeId === youtubeId) this.youtubeId = "";
 				if (this.song && this.song.youtubeId === youtubeId)
 					this.song = {};
-				if (
-					this.originalSong &&
-					this.originalSong.youtubeId === youtubeId
-				)
-					this.originalSong = {};
 			},
 			stopVideo() {
 				if (this.video.player && this.video.player.pauseVideo) {