Browse Source

Merge tag 'v3.7.0-rc2' into v3.8.0

Owen Diffey 2 years ago
parent
commit
000791b67f

+ 17 - 0
CHANGELOG.md

@@ -1,5 +1,22 @@
 # Changelog
 
+## [v3.7.0-rc2] - 2022-08-21
+
+This release includes all changes from v3.7.0-rc1, in addition to the following.
+Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).
+
+### Changed
+
+- refactor: Migrated from sortablejs-vue3 to vue-draggable-list
+- refactor: Disabled user preference activity items
+- refactor: Allowed for YouTube channel imports in to playlists
+
+### Fixed
+
+- fix: Invalid settings store getters
+- fix: EditSong song items scrollIntoView not functioning
+- fix: Cache/notifications module falsely reporting readiness
+
 ## [v3.7.0-rc1] - 2022-08-07
 
 This release contains mostly internal refactors.

+ 34 - 13
backend/logic/actions/playlists.js

@@ -1265,20 +1265,41 @@ export default {
 		async.waterfall(
 			[
 				next => {
-					this.publishProgress({ status: "update", message: `Importing YouTube playlist (stage 1)` });
-					YouTubeModule.runJob("GET_PLAYLIST", { url, musicOnly }, this)
-						.then(res => {
-							if (res.filteredSongs) {
-								videosInPlaylistTotal = res.songs.length;
-								songsInPlaylistTotal = res.filteredSongs.length;
-							} else {
-								songsInPlaylistTotal = videosInPlaylistTotal = res.songs.length;
-							}
-							next(null, res.songs);
-						})
-						.catch(err => {
-							next(err);
+					DBModule.runJob("GET_MODEL", { modelName: "user" }, this).then(userModel => {
+						userModel.findOne({ _id: session.userId }, (err, user) => {
+							if (user && user.role === "admin") return next(null, true);
+							return next(null, false);
 						});
+					});
+				},
+
+				(isAdmin, next) => {
+					this.publishProgress({ status: "update", message: `Importing YouTube playlist (stage 1)` });
+					const playlistRegex = /[\\?&]list=([^&#]*)/;
+					const channelRegex =
+						/\.[\w]+\/(?:(?:channel\/(UC[0-9A-Za-z_-]{21}[AQgw]))|(?:user\/?([\w-]+))|(?:c\/?([\w-]+))|(?:\/?([\w-]+)))/;
+
+					if (playlistRegex.exec(url) || channelRegex.exec(url))
+						YouTubeModule.runJob(
+							playlistRegex.exec(url) ? "GET_PLAYLIST" : "GET_CHANNEL",
+							{
+								url,
+								musicOnly,
+								disableSearch: !isAdmin
+							},
+							this
+						)
+							.then(res => {
+								if (res.filteredSongs) {
+									videosInPlaylistTotal = res.songs.length;
+									songsInPlaylistTotal = res.filteredSongs.length;
+								} else {
+									songsInPlaylistTotal = videosInPlaylistTotal = res.songs.length;
+								}
+								next(null, res.songs);
+							})
+							.catch(next);
+					else next("Invalid YouTube URL.");
 				},
 				(youtubeIds, next) => {
 					this.publishProgress({ status: "update", message: `Importing YouTube playlist (stage 2)` });

+ 35 - 35
backend/logic/actions/users.js

@@ -1467,7 +1467,7 @@ export default {
 					userModel.findByIdAndUpdate(session.userId, { $set }, { new: false, upsert: true }, next);
 				}
 			],
-			async (err, user) => {
+			async err => {
 				if (err) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 
@@ -1490,40 +1490,40 @@ export default {
 					}
 				});
 
-				if (preferences.nightmode !== undefined && preferences.nightmode !== user.preferences.nightmode)
-					ActivitiesModule.runJob("ADD_ACTIVITY", {
-						userId: session.userId,
-						type: "user__toggle_nightmode",
-						payload: { message: preferences.nightmode ? "Enabled nightmode" : "Disabled nightmode" }
-					});
-
-				if (
-					preferences.autoSkipDisliked !== undefined &&
-					preferences.autoSkipDisliked !== user.preferences.autoSkipDisliked
-				)
-					ActivitiesModule.runJob("ADD_ACTIVITY", {
-						userId: session.userId,
-						type: "user__toggle_autoskip_disliked_songs",
-						payload: {
-							message: preferences.autoSkipDisliked
-								? "Enabled the autoskipping of disliked songs"
-								: "Disabled the autoskipping of disliked songs"
-						}
-					});
-
-				if (
-					preferences.activityWatch !== undefined &&
-					preferences.activityWatch !== user.preferences.activityWatch
-				)
-					ActivitiesModule.runJob("ADD_ACTIVITY", {
-						userId: session.userId,
-						type: "user__toggle_activity_watch",
-						payload: {
-							message: preferences.activityWatch
-								? "Enabled ActivityWatch integration"
-								: "Disabled ActivityWatch integration"
-						}
-					});
+				// if (preferences.nightmode !== undefined && preferences.nightmode !== user.preferences.nightmode)
+				// 	ActivitiesModule.runJob("ADD_ACTIVITY", {
+				// 		userId: session.userId,
+				// 		type: "user__toggle_nightmode",
+				// 		payload: { message: preferences.nightmode ? "Enabled nightmode" : "Disabled nightmode" }
+				// 	});
+
+				// if (
+				// 	preferences.autoSkipDisliked !== undefined &&
+				// 	preferences.autoSkipDisliked !== user.preferences.autoSkipDisliked
+				// )
+				// 	ActivitiesModule.runJob("ADD_ACTIVITY", {
+				// 		userId: session.userId,
+				// 		type: "user__toggle_autoskip_disliked_songs",
+				// 		payload: {
+				// 			message: preferences.autoSkipDisliked
+				// 				? "Enabled the autoskipping of disliked songs"
+				// 				: "Disabled the autoskipping of disliked songs"
+				// 		}
+				// 	});
+
+				// if (
+				// 	preferences.activityWatch !== undefined &&
+				// 	preferences.activityWatch !== user.preferences.activityWatch
+				// )
+				// 	ActivitiesModule.runJob("ADD_ACTIVITY", {
+				// 		userId: session.userId,
+				// 		type: "user__toggle_activity_watch",
+				// 		payload: {
+				// 			message: preferences.activityWatch
+				// 				? "Enabled ActivityWatch integration"
+				// 				: "Disabled ActivityWatch integration"
+				// 		}
+				// 	});
 
 				this.log(
 					"SUCCESS",

+ 6 - 3
backend/logic/cache/index.js

@@ -76,13 +76,16 @@ class _CacheModule extends CoreClass {
 				this.log("ERROR", `Error ${err.message}.`);
 			});
 
-			this.client.connect().then(async () => {
-				this.log("INFO", "Connected succesfully.");
-
+			this.client.on("ready", () => {
+				this.log("INFO", "Redis is ready.");
 				if (this.getStatus() === "INITIALIZING") resolve();
 				else if (this.getStatus() === "FAILED" || this.getStatus() === "RECONNECTING") this.setStatus("READY");
 			});
 
+			this.client.connect().then(async () => {
+				this.log("INFO", "Connected succesfully.");
+			});
+
 			// TODO move to a better place
 			CacheModule.runJob("KEYS", { pattern: "longJobs.*" }).then(keys => {
 				async.eachLimit(keys, 1, (key, next) => {

+ 7 - 4
backend/logic/notifications.js

@@ -56,6 +56,13 @@ class _NotificationsModule extends CoreClass {
 				this.log("ERROR", `Error ${err.message}.`);
 			});
 
+			this.pub.on("ready", () => {
+				this.log("INFO", "Pub is ready.");
+				if (this.getStatus() === "INITIALIZING") resolve();
+				else if (this.getStatus() === "LOCKDOWN" || this.getStatus() === "RECONNECTING")
+					this.setStatus("INITIALIZED");
+			});
+
 			this.pub.connect().then(async () => {
 				this.log("INFO", "Pub connected succesfully.");
 
@@ -85,10 +92,6 @@ class _NotificationsModule extends CoreClass {
 						);
 						this.log("STATION_ISSUE", `Getting notify-keyspace-events gave an error. ${err}.`);
 					});
-
-				if (this.getStatus() === "INITIALIZING") resolve();
-				else if (this.getStatus() === "LOCKDOWN" || this.getStatus() === "RECONNECTING")
-					this.setStatus("INITIALIZED");
 			});
 
 			this.sub = this.pub.duplicate();

+ 10 - 3
backend/logic/youtube.js

@@ -587,7 +587,7 @@ class _YouTubeModule extends CoreClass {
 				],
 				(err, channelId) => {
 					if (err) {
-						YouTubeModule.log("ERROR", "GET_CHANNEL_ID_FROM_CUSTOM_URL", `${err.message}`);
+						YouTubeModule.log("ERROR", "GET_CHANNEL_ID_FROM_CUSTOM_URL", `${err.message || err}`);
 						if (err.message === "Request failed with status code 404") {
 							return reject(new Error("Channel not found. Is the channel public/unlisted?"));
 						}
@@ -783,6 +783,7 @@ class _YouTubeModule extends CoreClass {
 	 *
 	 * @param {object} payload - object that contains the payload
 	 * @param {boolean} payload.musicOnly - whether to return music videos or all videos in the channel
+	 * @param {boolean} payload.disableSearch - whether to allow searching for custom url/username
 	 * @param {string} payload.url - the url of the YouTube channel
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
@@ -807,6 +808,8 @@ class _YouTubeModule extends CoreClass {
 			console.log(`Channel custom URL: ${channelCustomUrl}`);
 			console.log(`Channel username or custom URL: ${channelUsernameOrCustomUrl}`);
 
+			const disableSearch = payload.disableSearch || false;
+
 			async.waterfall(
 				[
 					next => {
@@ -829,6 +832,10 @@ class _YouTubeModule extends CoreClass {
 					(getUsernameFromCustomUrl, playlistId, next) => {
 						if (!getUsernameFromCustomUrl) return next(null, playlistId);
 
+						if (disableSearch)
+							return next(
+								"Importing with this type of URL is disabled. Please provide a channel URL with the channel ID."
+							);
 						const payload = {};
 						if (channelCustomUrl) payload.customUrl = channelCustomUrl;
 						else if (channelUsernameOrCustomUrl) payload.customUrl = channelUsernameOrCustomUrl;
@@ -890,8 +897,8 @@ class _YouTubeModule extends CoreClass {
 				],
 				(err, response) => {
 					if (err && err !== true) {
-						YouTubeModule.log("ERROR", "GET_CHANNEL", "Some error has occurred.", err.message);
-						reject(new Error(err.message));
+						YouTubeModule.log("ERROR", "GET_CHANNEL", "Some error has occurred.", err.message || err);
+						reject(new Error(err.message || err));
 					} else {
 						resolve({ songs: response.filteredSongs ? response.filteredSongs.videoIds : response.songs });
 					}

+ 108 - 108
backend/package-lock.json

@@ -19,8 +19,8 @@
         "cors": "^2.8.5",
         "express": "^4.18.1",
         "moment": "^2.29.4",
-        "mongoose": "^6.5.1",
-        "nodemailer": "^6.7.7",
+        "mongoose": "^6.5.2",
+        "nodemailer": "^6.7.8",
         "oauth": "^0.10.0",
         "redis": "^4.2.0",
         "retry-axios": "^3.0.0",
@@ -30,13 +30,13 @@
         "ws": "^8.8.1"
       },
       "devDependencies": {
-        "@typescript-eslint/eslint-plugin": "^5.32.0",
-        "@typescript-eslint/parser": "^5.32.0",
-        "eslint": "^8.21.0",
+        "@typescript-eslint/eslint-plugin": "^5.33.1",
+        "@typescript-eslint/parser": "^5.33.1",
+        "eslint": "^8.22.0",
         "eslint-config-airbnb-base": "^15.0.0",
         "eslint-config-prettier": "^8.5.0",
         "eslint-plugin-import": "^2.26.0",
-        "eslint-plugin-jsdoc": "^39.3.4",
+        "eslint-plugin-jsdoc": "^39.3.6",
         "eslint-plugin-prettier": "^4.2.1",
         "nodemon": "^2.0.19",
         "prettier": "2.7.1",
@@ -355,14 +355,14 @@
       }
     },
     "node_modules/@typescript-eslint/eslint-plugin": {
-      "version": "5.32.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.32.0.tgz",
-      "integrity": "sha512-CHLuz5Uz7bHP2WgVlvoZGhf0BvFakBJKAD/43Ty0emn4wXWv5k01ND0C0fHcl/Im8Td2y/7h44E9pca9qAu2ew==",
+      "version": "5.33.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.33.1.tgz",
+      "integrity": "sha512-S1iZIxrTvKkU3+m63YUOxYPKaP+yWDQrdhxTglVDVEVBf+aCSw85+BmJnyUaQQsk5TXFG/LpBu9fa+LrAQ91fQ==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/scope-manager": "5.32.0",
-        "@typescript-eslint/type-utils": "5.32.0",
-        "@typescript-eslint/utils": "5.32.0",
+        "@typescript-eslint/scope-manager": "5.33.1",
+        "@typescript-eslint/type-utils": "5.33.1",
+        "@typescript-eslint/utils": "5.33.1",
         "debug": "^4.3.4",
         "functional-red-black-tree": "^1.0.1",
         "ignore": "^5.2.0",
@@ -411,14 +411,14 @@
       "dev": true
     },
     "node_modules/@typescript-eslint/parser": {
-      "version": "5.32.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.32.0.tgz",
-      "integrity": "sha512-IxRtsehdGV9GFQ35IGm5oKKR2OGcazUoiNBxhRV160iF9FoyuXxjY+rIqs1gfnd+4eL98OjeGnMpE7RF/NBb3A==",
+      "version": "5.33.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.33.1.tgz",
+      "integrity": "sha512-IgLLtW7FOzoDlmaMoXdxG8HOCByTBXrB1V2ZQYSEV1ggMmJfAkMWTwUjjzagS6OkfpySyhKFkBw7A9jYmcHpZA==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/scope-manager": "5.32.0",
-        "@typescript-eslint/types": "5.32.0",
-        "@typescript-eslint/typescript-estree": "5.32.0",
+        "@typescript-eslint/scope-manager": "5.33.1",
+        "@typescript-eslint/types": "5.33.1",
+        "@typescript-eslint/typescript-estree": "5.33.1",
         "debug": "^4.3.4"
       },
       "engines": {
@@ -461,13 +461,13 @@
       "dev": true
     },
     "node_modules/@typescript-eslint/scope-manager": {
-      "version": "5.32.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.32.0.tgz",
-      "integrity": "sha512-KyAE+tUON0D7tNz92p1uetRqVJiiAkeluvwvZOqBmW9z2XApmk5WSMV9FrzOroAcVxJZB3GfUwVKr98Dr/OjOg==",
+      "version": "5.33.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.33.1.tgz",
+      "integrity": "sha512-8ibcZSqy4c5m69QpzJn8XQq9NnqAToC8OdH/W6IXPXv83vRyEDPYLdjAlUx8h/rbusq6MkW4YdQzURGOqsn3CA==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "5.32.0",
-        "@typescript-eslint/visitor-keys": "5.32.0"
+        "@typescript-eslint/types": "5.33.1",
+        "@typescript-eslint/visitor-keys": "5.33.1"
       },
       "engines": {
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@@ -478,12 +478,12 @@
       }
     },
     "node_modules/@typescript-eslint/type-utils": {
-      "version": "5.32.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.32.0.tgz",
-      "integrity": "sha512-0gSsIhFDduBz3QcHJIp3qRCvVYbqzHg8D6bHFsDMrm0rURYDj+skBK2zmYebdCp+4nrd9VWd13egvhYFJj/wZg==",
+      "version": "5.33.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.33.1.tgz",
+      "integrity": "sha512-X3pGsJsD8OiqhNa5fim41YtlnyiWMF/eKsEZGsHID2HcDqeSC5yr/uLOeph8rNF2/utwuI0IQoAK3fpoxcLl2g==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/utils": "5.32.0",
+        "@typescript-eslint/utils": "5.33.1",
         "debug": "^4.3.4",
         "tsutils": "^3.21.0"
       },
@@ -527,9 +527,9 @@
       "dev": true
     },
     "node_modules/@typescript-eslint/types": {
-      "version": "5.32.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.32.0.tgz",
-      "integrity": "sha512-EBUKs68DOcT/EjGfzywp+f8wG9Zw6gj6BjWu7KV/IYllqKJFPlZlLSYw/PTvVyiRw50t6wVbgv4p9uE2h6sZrQ==",
+      "version": "5.33.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.33.1.tgz",
+      "integrity": "sha512-7K6MoQPQh6WVEkMrMW5QOA5FO+BOwzHSNd0j3+BlBwd6vtzfZceJ8xJ7Um2XDi/O3umS8/qDX6jdy2i7CijkwQ==",
       "dev": true,
       "engines": {
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@@ -540,13 +540,13 @@
       }
     },
     "node_modules/@typescript-eslint/typescript-estree": {
-      "version": "5.32.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.32.0.tgz",
-      "integrity": "sha512-ZVAUkvPk3ITGtCLU5J4atCw9RTxK+SRc6hXqLtllC2sGSeMFWN+YwbiJR9CFrSFJ3w4SJfcWtDwNb/DmUIHdhg==",
+      "version": "5.33.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.33.1.tgz",
+      "integrity": "sha512-JOAzJ4pJ+tHzA2pgsWQi4804XisPHOtbvwUyqsuuq8+y5B5GMZs7lI1xDWs6V2d7gE/Ez5bTGojSK12+IIPtXA==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "5.32.0",
-        "@typescript-eslint/visitor-keys": "5.32.0",
+        "@typescript-eslint/types": "5.33.1",
+        "@typescript-eslint/visitor-keys": "5.33.1",
         "debug": "^4.3.4",
         "globby": "^11.1.0",
         "is-glob": "^4.0.3",
@@ -590,15 +590,15 @@
       "dev": true
     },
     "node_modules/@typescript-eslint/utils": {
-      "version": "5.32.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.32.0.tgz",
-      "integrity": "sha512-W7lYIAI5Zlc5K082dGR27Fczjb3Q57ECcXefKU/f0ajM5ToM0P+N9NmJWip8GmGu/g6QISNT+K6KYB+iSHjXCQ==",
+      "version": "5.33.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.33.1.tgz",
+      "integrity": "sha512-uphZjkMaZ4fE8CR4dU7BquOV6u0doeQAr8n6cQenl/poMaIyJtBu8eys5uk6u5HiDH01Mj5lzbJ5SfeDz7oqMQ==",
       "dev": true,
       "dependencies": {
         "@types/json-schema": "^7.0.9",
-        "@typescript-eslint/scope-manager": "5.32.0",
-        "@typescript-eslint/types": "5.32.0",
-        "@typescript-eslint/typescript-estree": "5.32.0",
+        "@typescript-eslint/scope-manager": "5.33.1",
+        "@typescript-eslint/types": "5.33.1",
+        "@typescript-eslint/typescript-estree": "5.33.1",
         "eslint-scope": "^5.1.1",
         "eslint-utils": "^3.0.0"
       },
@@ -636,12 +636,12 @@
       }
     },
     "node_modules/@typescript-eslint/visitor-keys": {
-      "version": "5.32.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.32.0.tgz",
-      "integrity": "sha512-S54xOHZgfThiZ38/ZGTgB2rqx51CMJ5MCfVT2IplK4Q7hgzGfe0nLzLCcenDnc/cSjP568hdeKfeDcBgqNHD/g==",
+      "version": "5.33.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.33.1.tgz",
+      "integrity": "sha512-nwIxOK8Z2MPWltLKMLOEZwmfBZReqUdbEoHQXeCpa+sRVARe5twpJGHCB4dk9903Yaf0nMAlGbQfaAH92F60eg==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "5.32.0",
+        "@typescript-eslint/types": "5.33.1",
         "eslint-visitor-keys": "^3.3.0"
       },
       "engines": {
@@ -1478,9 +1478,9 @@
       }
     },
     "node_modules/eslint": {
-      "version": "8.21.0",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.21.0.tgz",
-      "integrity": "sha512-/XJ1+Qurf1T9G2M5IHrsjp+xrGT73RZf23xA1z5wB1ZzzEAWSZKvRwhWxTFp1rvkvCfwcvAUNAP31bhKTTGfDA==",
+      "version": "8.22.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.22.0.tgz",
+      "integrity": "sha512-ci4t0sz6vSRKdmkOGmprBo6fmI4PrphDFMy5JEq/fNS0gQkJM3rLmrqcp8ipMcdobH3KtUP40KniAE9W19S4wA==",
       "dev": true,
       "dependencies": {
         "@eslint/eslintrc": "^1.3.0",
@@ -1666,9 +1666,9 @@
       }
     },
     "node_modules/eslint-plugin-jsdoc": {
-      "version": "39.3.4",
-      "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-39.3.4.tgz",
-      "integrity": "sha512-dYWXhMMHJaq++bY2hyByhgiRzt5qQ7XdfQGiHrU9f3APSSVZ/HuOnXuvUUX7W0jO55Udsu4/7iRlpF/yLFQdSA==",
+      "version": "39.3.6",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-39.3.6.tgz",
+      "integrity": "sha512-R6dZ4t83qPdMhIOGr7g2QII2pwCjYyKP+z0tPOfO1bbAbQyKC20Y2Rd6z1te86Lq3T7uM8bNo+VD9YFpE8HU/g==",
       "dev": true,
       "dependencies": {
         "@es-joy/jsdoccomment": "~0.31.0",
@@ -3136,9 +3136,9 @@
       }
     },
     "node_modules/mongoose": {
-      "version": "6.5.1",
-      "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-6.5.1.tgz",
-      "integrity": "sha512-8C0213y279nrSp6Au+WB+l/VczcotMU65jalTJJxU6KYf/Kd8gNW9+B3giWNJOVd8VvKvUQG0suWv/Vngp/83A==",
+      "version": "6.5.2",
+      "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-6.5.2.tgz",
+      "integrity": "sha512-3CFDrSLtK2qjM1pZeZpLTUyqPRkc11Iuh74ZrwS4IwEJ3K2PqGnmyPLw7ex4Kzu37ujIMp3MAuiBlUjfrcb6hw==",
       "dependencies": {
         "bson": "^4.6.5",
         "kareem": "2.4.1",
@@ -3264,9 +3264,9 @@
       }
     },
     "node_modules/nodemailer": {
-      "version": "6.7.7",
-      "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.7.7.tgz",
-      "integrity": "sha512-pOLC/s+2I1EXuSqO5Wa34i3kXZG3gugDssH+ZNCevHad65tc8vQlCQpOLaUjopvkRQKm2Cki2aME7fEOPRy3bA==",
+      "version": "6.7.8",
+      "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.7.8.tgz",
+      "integrity": "sha512-2zaTFGqZixVmTxpJRCFC+Vk5eGRd/fYtvIR+dl5u9QXLTQWGIf48x/JXvo58g9sa0bU6To04XUv554Paykum3g==",
       "engines": {
         "node": ">=6.0.0"
       }
@@ -4925,14 +4925,14 @@
       }
     },
     "@typescript-eslint/eslint-plugin": {
-      "version": "5.32.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.32.0.tgz",
-      "integrity": "sha512-CHLuz5Uz7bHP2WgVlvoZGhf0BvFakBJKAD/43Ty0emn4wXWv5k01ND0C0fHcl/Im8Td2y/7h44E9pca9qAu2ew==",
+      "version": "5.33.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.33.1.tgz",
+      "integrity": "sha512-S1iZIxrTvKkU3+m63YUOxYPKaP+yWDQrdhxTglVDVEVBf+aCSw85+BmJnyUaQQsk5TXFG/LpBu9fa+LrAQ91fQ==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/scope-manager": "5.32.0",
-        "@typescript-eslint/type-utils": "5.32.0",
-        "@typescript-eslint/utils": "5.32.0",
+        "@typescript-eslint/scope-manager": "5.33.1",
+        "@typescript-eslint/type-utils": "5.33.1",
+        "@typescript-eslint/utils": "5.33.1",
         "debug": "^4.3.4",
         "functional-red-black-tree": "^1.0.1",
         "ignore": "^5.2.0",
@@ -4959,14 +4959,14 @@
       }
     },
     "@typescript-eslint/parser": {
-      "version": "5.32.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.32.0.tgz",
-      "integrity": "sha512-IxRtsehdGV9GFQ35IGm5oKKR2OGcazUoiNBxhRV160iF9FoyuXxjY+rIqs1gfnd+4eL98OjeGnMpE7RF/NBb3A==",
+      "version": "5.33.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.33.1.tgz",
+      "integrity": "sha512-IgLLtW7FOzoDlmaMoXdxG8HOCByTBXrB1V2ZQYSEV1ggMmJfAkMWTwUjjzagS6OkfpySyhKFkBw7A9jYmcHpZA==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/scope-manager": "5.32.0",
-        "@typescript-eslint/types": "5.32.0",
-        "@typescript-eslint/typescript-estree": "5.32.0",
+        "@typescript-eslint/scope-manager": "5.33.1",
+        "@typescript-eslint/types": "5.33.1",
+        "@typescript-eslint/typescript-estree": "5.33.1",
         "debug": "^4.3.4"
       },
       "dependencies": {
@@ -4988,22 +4988,22 @@
       }
     },
     "@typescript-eslint/scope-manager": {
-      "version": "5.32.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.32.0.tgz",
-      "integrity": "sha512-KyAE+tUON0D7tNz92p1uetRqVJiiAkeluvwvZOqBmW9z2XApmk5WSMV9FrzOroAcVxJZB3GfUwVKr98Dr/OjOg==",
+      "version": "5.33.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.33.1.tgz",
+      "integrity": "sha512-8ibcZSqy4c5m69QpzJn8XQq9NnqAToC8OdH/W6IXPXv83vRyEDPYLdjAlUx8h/rbusq6MkW4YdQzURGOqsn3CA==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/types": "5.32.0",
-        "@typescript-eslint/visitor-keys": "5.32.0"
+        "@typescript-eslint/types": "5.33.1",
+        "@typescript-eslint/visitor-keys": "5.33.1"
       }
     },
     "@typescript-eslint/type-utils": {
-      "version": "5.32.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.32.0.tgz",
-      "integrity": "sha512-0gSsIhFDduBz3QcHJIp3qRCvVYbqzHg8D6bHFsDMrm0rURYDj+skBK2zmYebdCp+4nrd9VWd13egvhYFJj/wZg==",
+      "version": "5.33.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.33.1.tgz",
+      "integrity": "sha512-X3pGsJsD8OiqhNa5fim41YtlnyiWMF/eKsEZGsHID2HcDqeSC5yr/uLOeph8rNF2/utwuI0IQoAK3fpoxcLl2g==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/utils": "5.32.0",
+        "@typescript-eslint/utils": "5.33.1",
         "debug": "^4.3.4",
         "tsutils": "^3.21.0"
       },
@@ -5026,19 +5026,19 @@
       }
     },
     "@typescript-eslint/types": {
-      "version": "5.32.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.32.0.tgz",
-      "integrity": "sha512-EBUKs68DOcT/EjGfzywp+f8wG9Zw6gj6BjWu7KV/IYllqKJFPlZlLSYw/PTvVyiRw50t6wVbgv4p9uE2h6sZrQ==",
+      "version": "5.33.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.33.1.tgz",
+      "integrity": "sha512-7K6MoQPQh6WVEkMrMW5QOA5FO+BOwzHSNd0j3+BlBwd6vtzfZceJ8xJ7Um2XDi/O3umS8/qDX6jdy2i7CijkwQ==",
       "dev": true
     },
     "@typescript-eslint/typescript-estree": {
-      "version": "5.32.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.32.0.tgz",
-      "integrity": "sha512-ZVAUkvPk3ITGtCLU5J4atCw9RTxK+SRc6hXqLtllC2sGSeMFWN+YwbiJR9CFrSFJ3w4SJfcWtDwNb/DmUIHdhg==",
+      "version": "5.33.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.33.1.tgz",
+      "integrity": "sha512-JOAzJ4pJ+tHzA2pgsWQi4804XisPHOtbvwUyqsuuq8+y5B5GMZs7lI1xDWs6V2d7gE/Ez5bTGojSK12+IIPtXA==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/types": "5.32.0",
-        "@typescript-eslint/visitor-keys": "5.32.0",
+        "@typescript-eslint/types": "5.33.1",
+        "@typescript-eslint/visitor-keys": "5.33.1",
         "debug": "^4.3.4",
         "globby": "^11.1.0",
         "is-glob": "^4.0.3",
@@ -5064,15 +5064,15 @@
       }
     },
     "@typescript-eslint/utils": {
-      "version": "5.32.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.32.0.tgz",
-      "integrity": "sha512-W7lYIAI5Zlc5K082dGR27Fczjb3Q57ECcXefKU/f0ajM5ToM0P+N9NmJWip8GmGu/g6QISNT+K6KYB+iSHjXCQ==",
+      "version": "5.33.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.33.1.tgz",
+      "integrity": "sha512-uphZjkMaZ4fE8CR4dU7BquOV6u0doeQAr8n6cQenl/poMaIyJtBu8eys5uk6u5HiDH01Mj5lzbJ5SfeDz7oqMQ==",
       "dev": true,
       "requires": {
         "@types/json-schema": "^7.0.9",
-        "@typescript-eslint/scope-manager": "5.32.0",
-        "@typescript-eslint/types": "5.32.0",
-        "@typescript-eslint/typescript-estree": "5.32.0",
+        "@typescript-eslint/scope-manager": "5.33.1",
+        "@typescript-eslint/types": "5.33.1",
+        "@typescript-eslint/typescript-estree": "5.33.1",
         "eslint-scope": "^5.1.1",
         "eslint-utils": "^3.0.0"
       },
@@ -5096,12 +5096,12 @@
       }
     },
     "@typescript-eslint/visitor-keys": {
-      "version": "5.32.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.32.0.tgz",
-      "integrity": "sha512-S54xOHZgfThiZ38/ZGTgB2rqx51CMJ5MCfVT2IplK4Q7hgzGfe0nLzLCcenDnc/cSjP568hdeKfeDcBgqNHD/g==",
+      "version": "5.33.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.33.1.tgz",
+      "integrity": "sha512-nwIxOK8Z2MPWltLKMLOEZwmfBZReqUdbEoHQXeCpa+sRVARe5twpJGHCB4dk9903Yaf0nMAlGbQfaAH92F60eg==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/types": "5.32.0",
+        "@typescript-eslint/types": "5.33.1",
         "eslint-visitor-keys": "^3.3.0"
       }
     },
@@ -5711,9 +5711,9 @@
       "dev": true
     },
     "eslint": {
-      "version": "8.21.0",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.21.0.tgz",
-      "integrity": "sha512-/XJ1+Qurf1T9G2M5IHrsjp+xrGT73RZf23xA1z5wB1ZzzEAWSZKvRwhWxTFp1rvkvCfwcvAUNAP31bhKTTGfDA==",
+      "version": "8.22.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.22.0.tgz",
+      "integrity": "sha512-ci4t0sz6vSRKdmkOGmprBo6fmI4PrphDFMy5JEq/fNS0gQkJM3rLmrqcp8ipMcdobH3KtUP40KniAE9W19S4wA==",
       "dev": true,
       "requires": {
         "@eslint/eslintrc": "^1.3.0",
@@ -5931,9 +5931,9 @@
       }
     },
     "eslint-plugin-jsdoc": {
-      "version": "39.3.4",
-      "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-39.3.4.tgz",
-      "integrity": "sha512-dYWXhMMHJaq++bY2hyByhgiRzt5qQ7XdfQGiHrU9f3APSSVZ/HuOnXuvUUX7W0jO55Udsu4/7iRlpF/yLFQdSA==",
+      "version": "39.3.6",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-39.3.6.tgz",
+      "integrity": "sha512-R6dZ4t83qPdMhIOGr7g2QII2pwCjYyKP+z0tPOfO1bbAbQyKC20Y2Rd6z1te86Lq3T7uM8bNo+VD9YFpE8HU/g==",
       "dev": true,
       "requires": {
         "@es-joy/jsdoccomment": "~0.31.0",
@@ -6916,9 +6916,9 @@
       }
     },
     "mongoose": {
-      "version": "6.5.1",
-      "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-6.5.1.tgz",
-      "integrity": "sha512-8C0213y279nrSp6Au+WB+l/VczcotMU65jalTJJxU6KYf/Kd8gNW9+B3giWNJOVd8VvKvUQG0suWv/Vngp/83A==",
+      "version": "6.5.2",
+      "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-6.5.2.tgz",
+      "integrity": "sha512-3CFDrSLtK2qjM1pZeZpLTUyqPRkc11Iuh74ZrwS4IwEJ3K2PqGnmyPLw7ex4Kzu37ujIMp3MAuiBlUjfrcb6hw==",
       "requires": {
         "bson": "^4.6.5",
         "kareem": "2.4.1",
@@ -7015,9 +7015,9 @@
       }
     },
     "nodemailer": {
-      "version": "6.7.7",
-      "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.7.7.tgz",
-      "integrity": "sha512-pOLC/s+2I1EXuSqO5Wa34i3kXZG3gugDssH+ZNCevHad65tc8vQlCQpOLaUjopvkRQKm2Cki2aME7fEOPRy3bA=="
+      "version": "6.7.8",
+      "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.7.8.tgz",
+      "integrity": "sha512-2zaTFGqZixVmTxpJRCFC+Vk5eGRd/fYtvIR+dl5u9QXLTQWGIf48x/JXvo58g9sa0bU6To04XUv554Paykum3g=="
     },
     "nodemon": {
       "version": "2.0.19",

+ 6 - 6
backend/package.json

@@ -26,8 +26,8 @@
     "cors": "^2.8.5",
     "express": "^4.18.1",
     "moment": "^2.29.4",
-    "mongoose": "^6.5.1",
-    "nodemailer": "^6.7.7",
+    "mongoose": "^6.5.2",
+    "nodemailer": "^6.7.8",
     "oauth": "^0.10.0",
     "redis": "^4.2.0",
     "retry-axios": "^3.0.0",
@@ -37,13 +37,13 @@
     "ws": "^8.8.1"
   },
   "devDependencies": {
-    "@typescript-eslint/eslint-plugin": "^5.32.0",
-    "@typescript-eslint/parser": "^5.32.0",
-    "eslint": "^8.21.0",
+    "@typescript-eslint/eslint-plugin": "^5.33.1",
+    "@typescript-eslint/parser": "^5.33.1",
+    "eslint": "^8.22.0",
     "eslint-config-airbnb-base": "^15.0.0",
     "eslint-config-prettier": "^8.5.0",
     "eslint-plugin-import": "^2.26.0",
-    "eslint-plugin-jsdoc": "^39.3.4",
+    "eslint-plugin-jsdoc": "^39.3.6",
     "eslint-plugin-prettier": "^4.2.1",
     "nodemon": "^2.0.19",
     "prettier": "2.7.1",

+ 214 - 235
frontend/package-lock.json

@@ -9,33 +9,32 @@
       "version": "3.8.0-dev",
       "license": "GPL-3.0",
       "dependencies": {
-        "@vitejs/plugin-vue": "^3.0.1",
+        "@vitejs/plugin-vue": "^3.0.3",
         "can-autoplay": "^3.0.2",
         "chart.js": "^3.9.1",
         "config": "^3.3.7",
-        "date-fns": "^2.29.1",
+        "date-fns": "^2.29.2",
         "dompurify": "^2.3.10",
         "eslint-config-airbnb-base": "^15.0.0",
         "lofig": "^1.3.4",
         "marked": "^4.0.18",
         "normalize.css": "^8.0.1",
-        "pinia": "^2.0.17",
-        "sortablejs": "^1.15.0",
-        "sortablejs-vue3": "^1.2.0",
+        "pinia": "^2.0.19",
         "toasters": "^2.3.1",
         "typescript": "^4.7.4",
-        "vite": "^3.0.4",
+        "vite": "^3.0.9",
         "vue": "^3.2.36",
         "vue-chartjs": "^4.1.1",
         "vue-content-loader": "^2.0.1",
-        "vue-json-pretty": "^2.1.1",
+        "vue-draggable-list": "^0.1.0",
+        "vue-json-pretty": "^2.2.0",
         "vue-router": "^4.1.3",
         "vue-tippy": "^6.0.0-alpha.63"
       },
       "devDependencies": {
-        "@typescript-eslint/eslint-plugin": "^5.32.0",
-        "@typescript-eslint/parser": "^5.32.0",
-        "eslint": "^8.21.0",
+        "@typescript-eslint/eslint-plugin": "^5.33.1",
+        "@typescript-eslint/parser": "^5.33.1",
+        "eslint": "^8.22.0",
         "eslint-config-prettier": "^8.5.0",
         "eslint-plugin-import": "^2.26.0",
         "eslint-plugin-prettier": "^4.2.1",
@@ -44,7 +43,7 @@
         "prettier": "^2.7.1",
         "vite-plugin-dynamic-import": "^1.1.1",
         "vue-eslint-parser": "^9.0.3",
-        "vue-tsc": "^0.39.4"
+        "vue-tsc": "^0.39.5"
       }
     },
     "node_modules/@ampproject/remapping": {
@@ -696,14 +695,14 @@
       "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="
     },
     "node_modules/@typescript-eslint/eslint-plugin": {
-      "version": "5.32.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.32.0.tgz",
-      "integrity": "sha512-CHLuz5Uz7bHP2WgVlvoZGhf0BvFakBJKAD/43Ty0emn4wXWv5k01ND0C0fHcl/Im8Td2y/7h44E9pca9qAu2ew==",
+      "version": "5.33.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.33.1.tgz",
+      "integrity": "sha512-S1iZIxrTvKkU3+m63YUOxYPKaP+yWDQrdhxTglVDVEVBf+aCSw85+BmJnyUaQQsk5TXFG/LpBu9fa+LrAQ91fQ==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/scope-manager": "5.32.0",
-        "@typescript-eslint/type-utils": "5.32.0",
-        "@typescript-eslint/utils": "5.32.0",
+        "@typescript-eslint/scope-manager": "5.33.1",
+        "@typescript-eslint/type-utils": "5.33.1",
+        "@typescript-eslint/utils": "5.33.1",
         "debug": "^4.3.4",
         "functional-red-black-tree": "^1.0.1",
         "ignore": "^5.2.0",
@@ -744,14 +743,14 @@
       }
     },
     "node_modules/@typescript-eslint/parser": {
-      "version": "5.32.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.32.0.tgz",
-      "integrity": "sha512-IxRtsehdGV9GFQ35IGm5oKKR2OGcazUoiNBxhRV160iF9FoyuXxjY+rIqs1gfnd+4eL98OjeGnMpE7RF/NBb3A==",
+      "version": "5.33.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.33.1.tgz",
+      "integrity": "sha512-IgLLtW7FOzoDlmaMoXdxG8HOCByTBXrB1V2ZQYSEV1ggMmJfAkMWTwUjjzagS6OkfpySyhKFkBw7A9jYmcHpZA==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/scope-manager": "5.32.0",
-        "@typescript-eslint/types": "5.32.0",
-        "@typescript-eslint/typescript-estree": "5.32.0",
+        "@typescript-eslint/scope-manager": "5.33.1",
+        "@typescript-eslint/types": "5.33.1",
+        "@typescript-eslint/typescript-estree": "5.33.1",
         "debug": "^4.3.4"
       },
       "engines": {
@@ -771,13 +770,13 @@
       }
     },
     "node_modules/@typescript-eslint/scope-manager": {
-      "version": "5.32.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.32.0.tgz",
-      "integrity": "sha512-KyAE+tUON0D7tNz92p1uetRqVJiiAkeluvwvZOqBmW9z2XApmk5WSMV9FrzOroAcVxJZB3GfUwVKr98Dr/OjOg==",
+      "version": "5.33.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.33.1.tgz",
+      "integrity": "sha512-8ibcZSqy4c5m69QpzJn8XQq9NnqAToC8OdH/W6IXPXv83vRyEDPYLdjAlUx8h/rbusq6MkW4YdQzURGOqsn3CA==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "5.32.0",
-        "@typescript-eslint/visitor-keys": "5.32.0"
+        "@typescript-eslint/types": "5.33.1",
+        "@typescript-eslint/visitor-keys": "5.33.1"
       },
       "engines": {
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@@ -788,12 +787,12 @@
       }
     },
     "node_modules/@typescript-eslint/type-utils": {
-      "version": "5.32.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.32.0.tgz",
-      "integrity": "sha512-0gSsIhFDduBz3QcHJIp3qRCvVYbqzHg8D6bHFsDMrm0rURYDj+skBK2zmYebdCp+4nrd9VWd13egvhYFJj/wZg==",
+      "version": "5.33.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.33.1.tgz",
+      "integrity": "sha512-X3pGsJsD8OiqhNa5fim41YtlnyiWMF/eKsEZGsHID2HcDqeSC5yr/uLOeph8rNF2/utwuI0IQoAK3fpoxcLl2g==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/utils": "5.32.0",
+        "@typescript-eslint/utils": "5.33.1",
         "debug": "^4.3.4",
         "tsutils": "^3.21.0"
       },
@@ -814,9 +813,9 @@
       }
     },
     "node_modules/@typescript-eslint/types": {
-      "version": "5.32.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.32.0.tgz",
-      "integrity": "sha512-EBUKs68DOcT/EjGfzywp+f8wG9Zw6gj6BjWu7KV/IYllqKJFPlZlLSYw/PTvVyiRw50t6wVbgv4p9uE2h6sZrQ==",
+      "version": "5.33.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.33.1.tgz",
+      "integrity": "sha512-7K6MoQPQh6WVEkMrMW5QOA5FO+BOwzHSNd0j3+BlBwd6vtzfZceJ8xJ7Um2XDi/O3umS8/qDX6jdy2i7CijkwQ==",
       "dev": true,
       "engines": {
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@@ -827,13 +826,13 @@
       }
     },
     "node_modules/@typescript-eslint/typescript-estree": {
-      "version": "5.32.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.32.0.tgz",
-      "integrity": "sha512-ZVAUkvPk3ITGtCLU5J4atCw9RTxK+SRc6hXqLtllC2sGSeMFWN+YwbiJR9CFrSFJ3w4SJfcWtDwNb/DmUIHdhg==",
+      "version": "5.33.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.33.1.tgz",
+      "integrity": "sha512-JOAzJ4pJ+tHzA2pgsWQi4804XisPHOtbvwUyqsuuq8+y5B5GMZs7lI1xDWs6V2d7gE/Ez5bTGojSK12+IIPtXA==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "5.32.0",
-        "@typescript-eslint/visitor-keys": "5.32.0",
+        "@typescript-eslint/types": "5.33.1",
+        "@typescript-eslint/visitor-keys": "5.33.1",
         "debug": "^4.3.4",
         "globby": "^11.1.0",
         "is-glob": "^4.0.3",
@@ -869,15 +868,15 @@
       }
     },
     "node_modules/@typescript-eslint/utils": {
-      "version": "5.32.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.32.0.tgz",
-      "integrity": "sha512-W7lYIAI5Zlc5K082dGR27Fczjb3Q57ECcXefKU/f0ajM5ToM0P+N9NmJWip8GmGu/g6QISNT+K6KYB+iSHjXCQ==",
+      "version": "5.33.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.33.1.tgz",
+      "integrity": "sha512-uphZjkMaZ4fE8CR4dU7BquOV6u0doeQAr8n6cQenl/poMaIyJtBu8eys5uk6u5HiDH01Mj5lzbJ5SfeDz7oqMQ==",
       "dev": true,
       "dependencies": {
         "@types/json-schema": "^7.0.9",
-        "@typescript-eslint/scope-manager": "5.32.0",
-        "@typescript-eslint/types": "5.32.0",
-        "@typescript-eslint/typescript-estree": "5.32.0",
+        "@typescript-eslint/scope-manager": "5.33.1",
+        "@typescript-eslint/types": "5.33.1",
+        "@typescript-eslint/typescript-estree": "5.33.1",
         "eslint-scope": "^5.1.1",
         "eslint-utils": "^3.0.0"
       },
@@ -893,12 +892,12 @@
       }
     },
     "node_modules/@typescript-eslint/visitor-keys": {
-      "version": "5.32.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.32.0.tgz",
-      "integrity": "sha512-S54xOHZgfThiZ38/ZGTgB2rqx51CMJ5MCfVT2IplK4Q7hgzGfe0nLzLCcenDnc/cSjP568hdeKfeDcBgqNHD/g==",
+      "version": "5.33.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.33.1.tgz",
+      "integrity": "sha512-nwIxOK8Z2MPWltLKMLOEZwmfBZReqUdbEoHQXeCpa+sRVARe5twpJGHCB4dk9903Yaf0nMAlGbQfaAH92F60eg==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "5.32.0",
+        "@typescript-eslint/types": "5.33.1",
         "eslint-visitor-keys": "^3.3.0"
       },
       "engines": {
@@ -919,9 +918,9 @@
       }
     },
     "node_modules/@vitejs/plugin-vue": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-3.0.1.tgz",
-      "integrity": "sha512-Ll9JgxG7ONIz/XZv3dssfoMUDu9qAnlJ+km+pBA0teYSXzwPCIzS/e1bmwNYl5dcQGs677D21amgfYAnzMl17A==",
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-3.0.3.tgz",
+      "integrity": "sha512-U4zNBlz9mg+TA+i+5QPc3N5lQvdUXENZLO2h0Wdzp56gI1MWhqJOv+6R+d4kOzoaSSq6TnGPBdZAXKOe4lXy6g==",
       "engines": {
         "node": "^14.18.0 || >=16.0.0"
       },
@@ -931,24 +930,24 @@
       }
     },
     "node_modules/@volar/code-gen": {
-      "version": "0.39.4",
-      "resolved": "https://registry.npmjs.org/@volar/code-gen/-/code-gen-0.39.4.tgz",
-      "integrity": "sha512-2RoDdktnN5ovhJoL1NgxKwKhfgP2TzcKVWp8+1Lb67sZ+hvWRL5GjHGkvlPkS91cElpwuURUHnbNNDT+uEqXuA==",
+      "version": "0.39.5",
+      "resolved": "https://registry.npmjs.org/@volar/code-gen/-/code-gen-0.39.5.tgz",
+      "integrity": "sha512-vQr5VoCH8T2NHmqLc/AA1/4F8l41WB+24+I+VjxBaev/Hmwjye9K0GlmMHAOl84WB3hWGOqpHaPX6JkqzRNjJg==",
       "dev": true,
       "dependencies": {
-        "@volar/source-map": "0.39.4"
+        "@volar/source-map": "0.39.5"
       }
     },
     "node_modules/@volar/source-map": {
-      "version": "0.39.4",
-      "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-0.39.4.tgz",
-      "integrity": "sha512-0zp7v0Ta1rZ2nKC4RcsU94Q/wJVVDWD0AJIqRGFU8rlEs2QO+RpBgotTL6wnKyJjyTzXxhcz/7AHUcwFs2oRnw==",
+      "version": "0.39.5",
+      "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-0.39.5.tgz",
+      "integrity": "sha512-IVOX+v++Sr5Kok4/cLbDJp2vf1ia1rChpV7adgcnMle6uORBuGFEur234UzamK0iHRCcfFFRz7z+hSPf2CO23Q==",
       "dev": true
     },
     "node_modules/@volar/typescript-faster": {
-      "version": "0.39.4",
-      "resolved": "https://registry.npmjs.org/@volar/typescript-faster/-/typescript-faster-0.39.4.tgz",
-      "integrity": "sha512-nVwTr1MSeUOjm+piJge3WW8PE+JyYbkfpEsf54P0e4P+8PUPHbGRIgr2TSpAh3802JSqg2SHCoDionECT5aXYw==",
+      "version": "0.39.5",
+      "resolved": "https://registry.npmjs.org/@volar/typescript-faster/-/typescript-faster-0.39.5.tgz",
+      "integrity": "sha512-IzLqlxefmKkjNKXC/8aFiqPcTqnj6RG31D2f9cIWxmW9pvUYJxLED+y9phnOxNxq0OmeRtQ3Pfmvu85tUBoZsQ==",
       "dev": true,
       "dependencies": {
         "semver": "^7.3.7"
@@ -970,40 +969,40 @@
       }
     },
     "node_modules/@volar/vue-code-gen": {
-      "version": "0.39.4",
-      "resolved": "https://registry.npmjs.org/@volar/vue-code-gen/-/vue-code-gen-0.39.4.tgz",
-      "integrity": "sha512-jQwweKAgtKhX7kDvsVcBRieyNtEywoxZFN+XTyPRvtY57Z2B7Ei9zQb/01n1l2lI+FPuskxaXdZKCn4txSKojQ==",
+      "version": "0.39.5",
+      "resolved": "https://registry.npmjs.org/@volar/vue-code-gen/-/vue-code-gen-0.39.5.tgz",
+      "integrity": "sha512-y+QUV9MuuasiIuRoGKQl+gMhDaAX6XNhckAyJCvD1FZ8f2eJuPY2VtoFxmu/Z2bGWBdtUW/g98jaeKJ+j3wwOw==",
       "dev": true,
       "dependencies": {
-        "@volar/code-gen": "0.39.4",
-        "@volar/source-map": "0.39.4",
+        "@volar/code-gen": "0.39.5",
+        "@volar/source-map": "0.39.5",
         "@vue/compiler-core": "^3.2.37",
         "@vue/compiler-dom": "^3.2.37",
         "@vue/shared": "^3.2.37"
       }
     },
     "node_modules/@volar/vue-language-core": {
-      "version": "0.39.4",
-      "resolved": "https://registry.npmjs.org/@volar/vue-language-core/-/vue-language-core-0.39.4.tgz",
-      "integrity": "sha512-ua4HAT8VYSf3EgY4Fl/mfpOQcUWz3gokJ8qsGIGfgKq3MxORnpp+RzKOEpMo1q+Ic550i+x0fh6Ylde76zOLww==",
+      "version": "0.39.5",
+      "resolved": "https://registry.npmjs.org/@volar/vue-language-core/-/vue-language-core-0.39.5.tgz",
+      "integrity": "sha512-m+e1tYuL/WRPhSeC7hZ0NuSwHsfnnGJVxCBHLaP7jR0f6xcC0DAegP3QF+gfu9ZJFPGznpZYFKadngMjuhQS9Q==",
       "dev": true,
       "dependencies": {
-        "@volar/code-gen": "0.39.4",
-        "@volar/source-map": "0.39.4",
-        "@volar/vue-code-gen": "0.39.4",
+        "@volar/code-gen": "0.39.5",
+        "@volar/source-map": "0.39.5",
+        "@volar/vue-code-gen": "0.39.5",
         "@vue/compiler-sfc": "^3.2.37",
         "@vue/reactivity": "^3.2.37"
       }
     },
     "node_modules/@volar/vue-typescript": {
-      "version": "0.39.4",
-      "resolved": "https://registry.npmjs.org/@volar/vue-typescript/-/vue-typescript-0.39.4.tgz",
-      "integrity": "sha512-ZIWg8EvTq53+P4DQVlrW5y+bo5v9VTOASBTrojBo0yK2frNbv/Gs7Ml4V+NmlsvIggtrPtDC/hIQChFiS5B3SA==",
+      "version": "0.39.5",
+      "resolved": "https://registry.npmjs.org/@volar/vue-typescript/-/vue-typescript-0.39.5.tgz",
+      "integrity": "sha512-ckhWD1xOi0OMr702XVkv/Npsb9FKAp5gvhxyLv0QqWekPdSo04t4KrZfwosJLGERIEcyr50SuB7HqBp8ndQmzA==",
       "dev": true,
       "dependencies": {
-        "@volar/code-gen": "0.39.4",
-        "@volar/typescript-faster": "0.39.4",
-        "@volar/vue-language-core": "0.39.4"
+        "@volar/code-gen": "0.39.5",
+        "@volar/typescript-faster": "0.39.5",
+        "@volar/vue-language-core": "0.39.5"
       }
     },
     "node_modules/@vue/compiler-core": {
@@ -1500,9 +1499,9 @@
       "integrity": "sha512-/WwNkdXfckNgw6S5R125rrW8ez139lBHWouiBvX8dfMFtcn6V81REDqnH7+CRpRipfYlyU1CmOnOxrmGcFOjeA=="
     },
     "node_modules/date-fns": {
-      "version": "2.29.1",
-      "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.1.tgz",
-      "integrity": "sha512-dlLD5rKaKxpFdnjrs+5azHDFOPEu4ANy/LTh04A1DTzMM7qoajmKCBc8pkKRFT41CNzw+4gQh79X5C+Jq27HAw==",
+      "version": "2.29.2",
+      "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.2.tgz",
+      "integrity": "sha512-0VNbwmWJDS/G3ySwFSJA3ayhbURMTJLtwM2DTxf9CWondCnh6DTNlO9JgRSq6ibf4eD0lfMJNBxUdEAHHix+bA==",
       "engines": {
         "node": ">=0.11"
       },
@@ -2028,9 +2027,9 @@
       }
     },
     "node_modules/eslint": {
-      "version": "8.21.0",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.21.0.tgz",
-      "integrity": "sha512-/XJ1+Qurf1T9G2M5IHrsjp+xrGT73RZf23xA1z5wB1ZzzEAWSZKvRwhWxTFp1rvkvCfwcvAUNAP31bhKTTGfDA==",
+      "version": "8.22.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.22.0.tgz",
+      "integrity": "sha512-ci4t0sz6vSRKdmkOGmprBo6fmI4PrphDFMy5JEq/fNS0gQkJM3rLmrqcp8ipMcdobH3KtUP40KniAE9W19S4wA==",
       "dependencies": {
         "@eslint/eslintrc": "^1.3.0",
         "@humanwhocodes/config-array": "^0.10.4",
@@ -3681,9 +3680,9 @@
       }
     },
     "node_modules/pinia": {
-      "version": "2.0.17",
-      "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.0.17.tgz",
-      "integrity": "sha512-AtwLwEWQgIjofjgeFT+nxbnK5lT2QwQjaHNEDqpsi2AiCwf/NY78uWTeHUyEhiiJy8+sBmw0ujgQMoQbWiZDfA==",
+      "version": "2.0.19",
+      "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.0.19.tgz",
+      "integrity": "sha512-Q/UQrmFLDMdlCkLfM5rGw1Ug0A7dy0G7NtBusMQSK+TNjf3CV/pO0RqblNIfuurWl42byTjM6HIemCWOfo8KXA==",
       "dependencies": {
         "@vue/devtools-api": "^6.2.1",
         "vue-demi": "*"
@@ -3731,9 +3730,9 @@
       }
     },
     "node_modules/postcss": {
-      "version": "8.4.14",
-      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.14.tgz",
-      "integrity": "sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig==",
+      "version": "8.4.16",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.16.tgz",
+      "integrity": "sha512-ipHE1XBvKzm5xI7hiHCZJCSugxvsdq2mPnsq5+UF+VHCjiBvtDrlxJfMBToWaP9D5XlgNmcFGqoHmUn0EYEaRQ==",
       "funding": [
         {
           "type": "opencollective",
@@ -4118,28 +4117,6 @@
         "node": ">=8"
       }
     },
-    "node_modules/sortablejs": {
-      "version": "1.15.0",
-      "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.0.tgz",
-      "integrity": "sha512-bv9qgVMjUMf89wAvM6AxVvS/4MX3sPeN0+agqShejLU5z5GX4C75ow1O2e5k4L6XItUyAK3gH6AxSbXrOM5e8w=="
-    },
-    "node_modules/sortablejs-vue3": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/sortablejs-vue3/-/sortablejs-vue3-1.2.0.tgz",
-      "integrity": "sha512-/dkDoWI2cxtre9ZwAxwNOu/74nI7DqEkzOAlnS340Y66KlBcv8zkoIC+YuUN8H4phlvHKctJK3pgEX8PgSgCFA==",
-      "dependencies": {
-        "sortablejs": "^1.15.0",
-        "vue": "^3.2.37"
-      },
-      "funding": {
-        "type": "individual",
-        "url": "https://github.com/sponsors/MaxLeiter/"
-      },
-      "peerDependencies": {
-        "sortablejs": "^1.15.0",
-        "vue": "^3.2.25"
-      }
-    },
     "node_modules/source-map": {
       "version": "0.6.1",
       "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -4435,14 +4412,14 @@
       "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA=="
     },
     "node_modules/vite": {
-      "version": "3.0.4",
-      "resolved": "https://registry.npmjs.org/vite/-/vite-3.0.4.tgz",
-      "integrity": "sha512-NU304nqnBeOx2MkQnskBQxVsa0pRAH5FphokTGmyy8M3oxbvw7qAXts2GORxs+h/2vKsD+osMhZ7An6yK6F1dA==",
+      "version": "3.0.9",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-3.0.9.tgz",
+      "integrity": "sha512-waYABTM+G6DBTCpYAxvevpG50UOlZuynR0ckTK5PawNVt7ebX6X7wNXHaGIO6wYYFXSM7/WcuFuO2QzhBB6aMw==",
       "dependencies": {
         "esbuild": "^0.14.47",
-        "postcss": "^8.4.14",
+        "postcss": "^8.4.16",
         "resolve": "^1.22.1",
-        "rollup": "^2.75.6"
+        "rollup": ">=2.75.6 <2.77.0 || ~2.77.0"
       },
       "bin": {
         "vite": "bin/vite.js"
@@ -4512,6 +4489,14 @@
         "vue": "^3"
       }
     },
+    "node_modules/vue-draggable-list": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/vue-draggable-list/-/vue-draggable-list-0.1.0.tgz",
+      "integrity": "sha512-bSVxTaTqr16srBD7vpLLEbN+vAckDVTklWW3/iEfIr5GnBNZgSN+Z0Jnq75WnteZPdaT6BgfCdgISf6MzC5PSw==",
+      "dependencies": {
+        "vue": "^3.2.37"
+      }
+    },
     "node_modules/vue-eslint-parser": {
       "version": "9.0.3",
       "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.0.3.tgz",
@@ -4583,9 +4568,9 @@
       }
     },
     "node_modules/vue-json-pretty": {
-      "version": "2.1.1",
-      "resolved": "https://registry.npmjs.org/vue-json-pretty/-/vue-json-pretty-2.1.1.tgz",
-      "integrity": "sha512-zl/gWr/zeQU4mUozlBxGu/9ebR/tYywty5VGu8FcHTJwOu9se4OwlPZELruwnzvHODfUOh8rUirSgNTHmLZXVw==",
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/vue-json-pretty/-/vue-json-pretty-2.2.0.tgz",
+      "integrity": "sha512-Jah7kWV+ZEFPIvf1NCAntUzcmtNYAbkkj2l5WHJotz97BE7YRJ3hx5ecBcaCPkW6bYBugm1ditZjKAdX8AQZMA==",
       "engines": {
         "node": ">= 10.0.0",
         "npm": ">= 5.0.0"
@@ -4620,13 +4605,13 @@
       }
     },
     "node_modules/vue-tsc": {
-      "version": "0.39.4",
-      "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-0.39.4.tgz",
-      "integrity": "sha512-oGFuAdSt8Q1NatnyyJheW0P/8Sk9RDMWPNzeMHXl1OOnoXrbjz2miMcccujySCpA48+AhzdtyFY1PL0XTPsOSg==",
+      "version": "0.39.5",
+      "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-0.39.5.tgz",
+      "integrity": "sha512-jhTsrKhZkafpIeN4Cbhr1K53hNfa/oesSrlh7hUaeHyCk55VhZT6oJkwJbtqN4MYkWZIwPrm3/xTwsELuf2ocg==",
       "dev": true,
       "dependencies": {
-        "@volar/vue-language-core": "0.39.4",
-        "@volar/vue-typescript": "0.39.4"
+        "@volar/vue-language-core": "0.39.5",
+        "@volar/vue-typescript": "0.39.5"
       },
       "bin": {
         "vue-tsc": "bin/vue-tsc.js"
@@ -5188,14 +5173,14 @@
       "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="
     },
     "@typescript-eslint/eslint-plugin": {
-      "version": "5.32.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.32.0.tgz",
-      "integrity": "sha512-CHLuz5Uz7bHP2WgVlvoZGhf0BvFakBJKAD/43Ty0emn4wXWv5k01ND0C0fHcl/Im8Td2y/7h44E9pca9qAu2ew==",
+      "version": "5.33.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.33.1.tgz",
+      "integrity": "sha512-S1iZIxrTvKkU3+m63YUOxYPKaP+yWDQrdhxTglVDVEVBf+aCSw85+BmJnyUaQQsk5TXFG/LpBu9fa+LrAQ91fQ==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/scope-manager": "5.32.0",
-        "@typescript-eslint/type-utils": "5.32.0",
-        "@typescript-eslint/utils": "5.32.0",
+        "@typescript-eslint/scope-manager": "5.33.1",
+        "@typescript-eslint/type-utils": "5.33.1",
+        "@typescript-eslint/utils": "5.33.1",
         "debug": "^4.3.4",
         "functional-red-black-tree": "^1.0.1",
         "ignore": "^5.2.0",
@@ -5216,52 +5201,52 @@
       }
     },
     "@typescript-eslint/parser": {
-      "version": "5.32.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.32.0.tgz",
-      "integrity": "sha512-IxRtsehdGV9GFQ35IGm5oKKR2OGcazUoiNBxhRV160iF9FoyuXxjY+rIqs1gfnd+4eL98OjeGnMpE7RF/NBb3A==",
+      "version": "5.33.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.33.1.tgz",
+      "integrity": "sha512-IgLLtW7FOzoDlmaMoXdxG8HOCByTBXrB1V2ZQYSEV1ggMmJfAkMWTwUjjzagS6OkfpySyhKFkBw7A9jYmcHpZA==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/scope-manager": "5.32.0",
-        "@typescript-eslint/types": "5.32.0",
-        "@typescript-eslint/typescript-estree": "5.32.0",
+        "@typescript-eslint/scope-manager": "5.33.1",
+        "@typescript-eslint/types": "5.33.1",
+        "@typescript-eslint/typescript-estree": "5.33.1",
         "debug": "^4.3.4"
       }
     },
     "@typescript-eslint/scope-manager": {
-      "version": "5.32.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.32.0.tgz",
-      "integrity": "sha512-KyAE+tUON0D7tNz92p1uetRqVJiiAkeluvwvZOqBmW9z2XApmk5WSMV9FrzOroAcVxJZB3GfUwVKr98Dr/OjOg==",
+      "version": "5.33.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.33.1.tgz",
+      "integrity": "sha512-8ibcZSqy4c5m69QpzJn8XQq9NnqAToC8OdH/W6IXPXv83vRyEDPYLdjAlUx8h/rbusq6MkW4YdQzURGOqsn3CA==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/types": "5.32.0",
-        "@typescript-eslint/visitor-keys": "5.32.0"
+        "@typescript-eslint/types": "5.33.1",
+        "@typescript-eslint/visitor-keys": "5.33.1"
       }
     },
     "@typescript-eslint/type-utils": {
-      "version": "5.32.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.32.0.tgz",
-      "integrity": "sha512-0gSsIhFDduBz3QcHJIp3qRCvVYbqzHg8D6bHFsDMrm0rURYDj+skBK2zmYebdCp+4nrd9VWd13egvhYFJj/wZg==",
+      "version": "5.33.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.33.1.tgz",
+      "integrity": "sha512-X3pGsJsD8OiqhNa5fim41YtlnyiWMF/eKsEZGsHID2HcDqeSC5yr/uLOeph8rNF2/utwuI0IQoAK3fpoxcLl2g==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/utils": "5.32.0",
+        "@typescript-eslint/utils": "5.33.1",
         "debug": "^4.3.4",
         "tsutils": "^3.21.0"
       }
     },
     "@typescript-eslint/types": {
-      "version": "5.32.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.32.0.tgz",
-      "integrity": "sha512-EBUKs68DOcT/EjGfzywp+f8wG9Zw6gj6BjWu7KV/IYllqKJFPlZlLSYw/PTvVyiRw50t6wVbgv4p9uE2h6sZrQ==",
+      "version": "5.33.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.33.1.tgz",
+      "integrity": "sha512-7K6MoQPQh6WVEkMrMW5QOA5FO+BOwzHSNd0j3+BlBwd6vtzfZceJ8xJ7Um2XDi/O3umS8/qDX6jdy2i7CijkwQ==",
       "dev": true
     },
     "@typescript-eslint/typescript-estree": {
-      "version": "5.32.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.32.0.tgz",
-      "integrity": "sha512-ZVAUkvPk3ITGtCLU5J4atCw9RTxK+SRc6hXqLtllC2sGSeMFWN+YwbiJR9CFrSFJ3w4SJfcWtDwNb/DmUIHdhg==",
+      "version": "5.33.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.33.1.tgz",
+      "integrity": "sha512-JOAzJ4pJ+tHzA2pgsWQi4804XisPHOtbvwUyqsuuq8+y5B5GMZs7lI1xDWs6V2d7gE/Ez5bTGojSK12+IIPtXA==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/types": "5.32.0",
-        "@typescript-eslint/visitor-keys": "5.32.0",
+        "@typescript-eslint/types": "5.33.1",
+        "@typescript-eslint/visitor-keys": "5.33.1",
         "debug": "^4.3.4",
         "globby": "^11.1.0",
         "is-glob": "^4.0.3",
@@ -5281,26 +5266,26 @@
       }
     },
     "@typescript-eslint/utils": {
-      "version": "5.32.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.32.0.tgz",
-      "integrity": "sha512-W7lYIAI5Zlc5K082dGR27Fczjb3Q57ECcXefKU/f0ajM5ToM0P+N9NmJWip8GmGu/g6QISNT+K6KYB+iSHjXCQ==",
+      "version": "5.33.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.33.1.tgz",
+      "integrity": "sha512-uphZjkMaZ4fE8CR4dU7BquOV6u0doeQAr8n6cQenl/poMaIyJtBu8eys5uk6u5HiDH01Mj5lzbJ5SfeDz7oqMQ==",
       "dev": true,
       "requires": {
         "@types/json-schema": "^7.0.9",
-        "@typescript-eslint/scope-manager": "5.32.0",
-        "@typescript-eslint/types": "5.32.0",
-        "@typescript-eslint/typescript-estree": "5.32.0",
+        "@typescript-eslint/scope-manager": "5.33.1",
+        "@typescript-eslint/types": "5.33.1",
+        "@typescript-eslint/typescript-estree": "5.33.1",
         "eslint-scope": "^5.1.1",
         "eslint-utils": "^3.0.0"
       }
     },
     "@typescript-eslint/visitor-keys": {
-      "version": "5.32.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.32.0.tgz",
-      "integrity": "sha512-S54xOHZgfThiZ38/ZGTgB2rqx51CMJ5MCfVT2IplK4Q7hgzGfe0nLzLCcenDnc/cSjP568hdeKfeDcBgqNHD/g==",
+      "version": "5.33.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.33.1.tgz",
+      "integrity": "sha512-nwIxOK8Z2MPWltLKMLOEZwmfBZReqUdbEoHQXeCpa+sRVARe5twpJGHCB4dk9903Yaf0nMAlGbQfaAH92F60eg==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/types": "5.32.0",
+        "@typescript-eslint/types": "5.33.1",
         "eslint-visitor-keys": "^3.3.0"
       },
       "dependencies": {
@@ -5313,30 +5298,30 @@
       }
     },
     "@vitejs/plugin-vue": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-3.0.1.tgz",
-      "integrity": "sha512-Ll9JgxG7ONIz/XZv3dssfoMUDu9qAnlJ+km+pBA0teYSXzwPCIzS/e1bmwNYl5dcQGs677D21amgfYAnzMl17A==",
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-3.0.3.tgz",
+      "integrity": "sha512-U4zNBlz9mg+TA+i+5QPc3N5lQvdUXENZLO2h0Wdzp56gI1MWhqJOv+6R+d4kOzoaSSq6TnGPBdZAXKOe4lXy6g==",
       "requires": {}
     },
     "@volar/code-gen": {
-      "version": "0.39.4",
-      "resolved": "https://registry.npmjs.org/@volar/code-gen/-/code-gen-0.39.4.tgz",
-      "integrity": "sha512-2RoDdktnN5ovhJoL1NgxKwKhfgP2TzcKVWp8+1Lb67sZ+hvWRL5GjHGkvlPkS91cElpwuURUHnbNNDT+uEqXuA==",
+      "version": "0.39.5",
+      "resolved": "https://registry.npmjs.org/@volar/code-gen/-/code-gen-0.39.5.tgz",
+      "integrity": "sha512-vQr5VoCH8T2NHmqLc/AA1/4F8l41WB+24+I+VjxBaev/Hmwjye9K0GlmMHAOl84WB3hWGOqpHaPX6JkqzRNjJg==",
       "dev": true,
       "requires": {
-        "@volar/source-map": "0.39.4"
+        "@volar/source-map": "0.39.5"
       }
     },
     "@volar/source-map": {
-      "version": "0.39.4",
-      "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-0.39.4.tgz",
-      "integrity": "sha512-0zp7v0Ta1rZ2nKC4RcsU94Q/wJVVDWD0AJIqRGFU8rlEs2QO+RpBgotTL6wnKyJjyTzXxhcz/7AHUcwFs2oRnw==",
+      "version": "0.39.5",
+      "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-0.39.5.tgz",
+      "integrity": "sha512-IVOX+v++Sr5Kok4/cLbDJp2vf1ia1rChpV7adgcnMle6uORBuGFEur234UzamK0iHRCcfFFRz7z+hSPf2CO23Q==",
       "dev": true
     },
     "@volar/typescript-faster": {
-      "version": "0.39.4",
-      "resolved": "https://registry.npmjs.org/@volar/typescript-faster/-/typescript-faster-0.39.4.tgz",
-      "integrity": "sha512-nVwTr1MSeUOjm+piJge3WW8PE+JyYbkfpEsf54P0e4P+8PUPHbGRIgr2TSpAh3802JSqg2SHCoDionECT5aXYw==",
+      "version": "0.39.5",
+      "resolved": "https://registry.npmjs.org/@volar/typescript-faster/-/typescript-faster-0.39.5.tgz",
+      "integrity": "sha512-IzLqlxefmKkjNKXC/8aFiqPcTqnj6RG31D2f9cIWxmW9pvUYJxLED+y9phnOxNxq0OmeRtQ3Pfmvu85tUBoZsQ==",
       "dev": true,
       "requires": {
         "semver": "^7.3.7"
@@ -5354,40 +5339,40 @@
       }
     },
     "@volar/vue-code-gen": {
-      "version": "0.39.4",
-      "resolved": "https://registry.npmjs.org/@volar/vue-code-gen/-/vue-code-gen-0.39.4.tgz",
-      "integrity": "sha512-jQwweKAgtKhX7kDvsVcBRieyNtEywoxZFN+XTyPRvtY57Z2B7Ei9zQb/01n1l2lI+FPuskxaXdZKCn4txSKojQ==",
+      "version": "0.39.5",
+      "resolved": "https://registry.npmjs.org/@volar/vue-code-gen/-/vue-code-gen-0.39.5.tgz",
+      "integrity": "sha512-y+QUV9MuuasiIuRoGKQl+gMhDaAX6XNhckAyJCvD1FZ8f2eJuPY2VtoFxmu/Z2bGWBdtUW/g98jaeKJ+j3wwOw==",
       "dev": true,
       "requires": {
-        "@volar/code-gen": "0.39.4",
-        "@volar/source-map": "0.39.4",
+        "@volar/code-gen": "0.39.5",
+        "@volar/source-map": "0.39.5",
         "@vue/compiler-core": "^3.2.37",
         "@vue/compiler-dom": "^3.2.37",
         "@vue/shared": "^3.2.37"
       }
     },
     "@volar/vue-language-core": {
-      "version": "0.39.4",
-      "resolved": "https://registry.npmjs.org/@volar/vue-language-core/-/vue-language-core-0.39.4.tgz",
-      "integrity": "sha512-ua4HAT8VYSf3EgY4Fl/mfpOQcUWz3gokJ8qsGIGfgKq3MxORnpp+RzKOEpMo1q+Ic550i+x0fh6Ylde76zOLww==",
+      "version": "0.39.5",
+      "resolved": "https://registry.npmjs.org/@volar/vue-language-core/-/vue-language-core-0.39.5.tgz",
+      "integrity": "sha512-m+e1tYuL/WRPhSeC7hZ0NuSwHsfnnGJVxCBHLaP7jR0f6xcC0DAegP3QF+gfu9ZJFPGznpZYFKadngMjuhQS9Q==",
       "dev": true,
       "requires": {
-        "@volar/code-gen": "0.39.4",
-        "@volar/source-map": "0.39.4",
-        "@volar/vue-code-gen": "0.39.4",
+        "@volar/code-gen": "0.39.5",
+        "@volar/source-map": "0.39.5",
+        "@volar/vue-code-gen": "0.39.5",
         "@vue/compiler-sfc": "^3.2.37",
         "@vue/reactivity": "^3.2.37"
       }
     },
     "@volar/vue-typescript": {
-      "version": "0.39.4",
-      "resolved": "https://registry.npmjs.org/@volar/vue-typescript/-/vue-typescript-0.39.4.tgz",
-      "integrity": "sha512-ZIWg8EvTq53+P4DQVlrW5y+bo5v9VTOASBTrojBo0yK2frNbv/Gs7Ml4V+NmlsvIggtrPtDC/hIQChFiS5B3SA==",
+      "version": "0.39.5",
+      "resolved": "https://registry.npmjs.org/@volar/vue-typescript/-/vue-typescript-0.39.5.tgz",
+      "integrity": "sha512-ckhWD1xOi0OMr702XVkv/Npsb9FKAp5gvhxyLv0QqWekPdSo04t4KrZfwosJLGERIEcyr50SuB7HqBp8ndQmzA==",
       "dev": true,
       "requires": {
-        "@volar/code-gen": "0.39.4",
-        "@volar/typescript-faster": "0.39.4",
-        "@volar/vue-language-core": "0.39.4"
+        "@volar/code-gen": "0.39.5",
+        "@volar/typescript-faster": "0.39.5",
+        "@volar/vue-language-core": "0.39.5"
       }
     },
     "@vue/compiler-core": {
@@ -5772,9 +5757,9 @@
       "integrity": "sha512-/WwNkdXfckNgw6S5R125rrW8ez139lBHWouiBvX8dfMFtcn6V81REDqnH7+CRpRipfYlyU1CmOnOxrmGcFOjeA=="
     },
     "date-fns": {
-      "version": "2.29.1",
-      "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.1.tgz",
-      "integrity": "sha512-dlLD5rKaKxpFdnjrs+5azHDFOPEu4ANy/LTh04A1DTzMM7qoajmKCBc8pkKRFT41CNzw+4gQh79X5C+Jq27HAw=="
+      "version": "2.29.2",
+      "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.2.tgz",
+      "integrity": "sha512-0VNbwmWJDS/G3ySwFSJA3ayhbURMTJLtwM2DTxf9CWondCnh6DTNlO9JgRSq6ibf4eD0lfMJNBxUdEAHHix+bA=="
     },
     "debug": {
       "version": "4.3.4",
@@ -6061,9 +6046,9 @@
       "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="
     },
     "eslint": {
-      "version": "8.21.0",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.21.0.tgz",
-      "integrity": "sha512-/XJ1+Qurf1T9G2M5IHrsjp+xrGT73RZf23xA1z5wB1ZzzEAWSZKvRwhWxTFp1rvkvCfwcvAUNAP31bhKTTGfDA==",
+      "version": "8.22.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.22.0.tgz",
+      "integrity": "sha512-ci4t0sz6vSRKdmkOGmprBo6fmI4PrphDFMy5JEq/fNS0gQkJM3rLmrqcp8ipMcdobH3KtUP40KniAE9W19S4wA==",
       "requires": {
         "@eslint/eslintrc": "^1.3.0",
         "@humanwhocodes/config-array": "^0.10.4",
@@ -7243,9 +7228,9 @@
       "optional": true
     },
     "pinia": {
-      "version": "2.0.17",
-      "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.0.17.tgz",
-      "integrity": "sha512-AtwLwEWQgIjofjgeFT+nxbnK5lT2QwQjaHNEDqpsi2AiCwf/NY78uWTeHUyEhiiJy8+sBmw0ujgQMoQbWiZDfA==",
+      "version": "2.0.19",
+      "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.0.19.tgz",
+      "integrity": "sha512-Q/UQrmFLDMdlCkLfM5rGw1Ug0A7dy0G7NtBusMQSK+TNjf3CV/pO0RqblNIfuurWl42byTjM6HIemCWOfo8KXA==",
       "requires": {
         "@vue/devtools-api": "^6.2.1",
         "vue-demi": "*"
@@ -7260,9 +7245,9 @@
       }
     },
     "postcss": {
-      "version": "8.4.14",
-      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.14.tgz",
-      "integrity": "sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig==",
+      "version": "8.4.16",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.16.tgz",
+      "integrity": "sha512-ipHE1XBvKzm5xI7hiHCZJCSugxvsdq2mPnsq5+UF+VHCjiBvtDrlxJfMBToWaP9D5XlgNmcFGqoHmUn0EYEaRQ==",
       "requires": {
         "nanoid": "^3.3.4",
         "picocolors": "^1.0.0",
@@ -7517,20 +7502,6 @@
       "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
       "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="
     },
-    "sortablejs": {
-      "version": "1.15.0",
-      "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.0.tgz",
-      "integrity": "sha512-bv9qgVMjUMf89wAvM6AxVvS/4MX3sPeN0+agqShejLU5z5GX4C75ow1O2e5k4L6XItUyAK3gH6AxSbXrOM5e8w=="
-    },
-    "sortablejs-vue3": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/sortablejs-vue3/-/sortablejs-vue3-1.2.0.tgz",
-      "integrity": "sha512-/dkDoWI2cxtre9ZwAxwNOu/74nI7DqEkzOAlnS340Y66KlBcv8zkoIC+YuUN8H4phlvHKctJK3pgEX8PgSgCFA==",
-      "requires": {
-        "sortablejs": "^1.15.0",
-        "vue": "^3.2.37"
-      }
-    },
     "source-map": {
       "version": "0.6.1",
       "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -7759,15 +7730,15 @@
       "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA=="
     },
     "vite": {
-      "version": "3.0.4",
-      "resolved": "https://registry.npmjs.org/vite/-/vite-3.0.4.tgz",
-      "integrity": "sha512-NU304nqnBeOx2MkQnskBQxVsa0pRAH5FphokTGmyy8M3oxbvw7qAXts2GORxs+h/2vKsD+osMhZ7An6yK6F1dA==",
+      "version": "3.0.9",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-3.0.9.tgz",
+      "integrity": "sha512-waYABTM+G6DBTCpYAxvevpG50UOlZuynR0ckTK5PawNVt7ebX6X7wNXHaGIO6wYYFXSM7/WcuFuO2QzhBB6aMw==",
       "requires": {
         "esbuild": "^0.14.47",
         "fsevents": "~2.3.2",
-        "postcss": "^8.4.14",
+        "postcss": "^8.4.16",
         "resolve": "^1.22.1",
-        "rollup": "^2.75.6"
+        "rollup": ">=2.75.6 <2.77.0 || ~2.77.0"
       }
     },
     "vite-plugin-dynamic-import": {
@@ -7803,6 +7774,14 @@
       "integrity": "sha512-pkof4+q2xmzNEdhqelxtJejeP/vQUJtLle4/v2ueG+HURqM9Q/GIGC8GJ2bVVWeLfTDET51jqimwQdmxJTlu0g==",
       "requires": {}
     },
+    "vue-draggable-list": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/vue-draggable-list/-/vue-draggable-list-0.1.0.tgz",
+      "integrity": "sha512-bSVxTaTqr16srBD7vpLLEbN+vAckDVTklWW3/iEfIr5GnBNZgSN+Z0Jnq75WnteZPdaT6BgfCdgISf6MzC5PSw==",
+      "requires": {
+        "vue": "^3.2.37"
+      }
+    },
     "vue-eslint-parser": {
       "version": "9.0.3",
       "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.0.3.tgz",
@@ -7852,9 +7831,9 @@
       }
     },
     "vue-json-pretty": {
-      "version": "2.1.1",
-      "resolved": "https://registry.npmjs.org/vue-json-pretty/-/vue-json-pretty-2.1.1.tgz",
-      "integrity": "sha512-zl/gWr/zeQU4mUozlBxGu/9ebR/tYywty5VGu8FcHTJwOu9se4OwlPZELruwnzvHODfUOh8rUirSgNTHmLZXVw==",
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/vue-json-pretty/-/vue-json-pretty-2.2.0.tgz",
+      "integrity": "sha512-Jah7kWV+ZEFPIvf1NCAntUzcmtNYAbkkj2l5WHJotz97BE7YRJ3hx5ecBcaCPkW6bYBugm1ditZjKAdX8AQZMA==",
       "requires": {}
     },
     "vue-router": {
@@ -7874,13 +7853,13 @@
       }
     },
     "vue-tsc": {
-      "version": "0.39.4",
-      "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-0.39.4.tgz",
-      "integrity": "sha512-oGFuAdSt8Q1NatnyyJheW0P/8Sk9RDMWPNzeMHXl1OOnoXrbjz2miMcccujySCpA48+AhzdtyFY1PL0XTPsOSg==",
+      "version": "0.39.5",
+      "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-0.39.5.tgz",
+      "integrity": "sha512-jhTsrKhZkafpIeN4Cbhr1K53hNfa/oesSrlh7hUaeHyCk55VhZT6oJkwJbtqN4MYkWZIwPrm3/xTwsELuf2ocg==",
       "dev": true,
       "requires": {
-        "@volar/vue-language-core": "0.39.4",
-        "@volar/vue-typescript": "0.39.4"
+        "@volar/vue-language-core": "0.39.5",
+        "@volar/vue-typescript": "0.39.5"
       }
     },
     "whatwg-fetch": {

+ 10 - 11
frontend/package.json

@@ -18,9 +18,9 @@
     "typescript": "npx vue-tsc --noEmit --skipLibCheck"
   },
   "devDependencies": {
-    "@typescript-eslint/eslint-plugin": "^5.32.0",
-    "@typescript-eslint/parser": "^5.32.0",
-    "eslint": "^8.21.0",
+    "@typescript-eslint/eslint-plugin": "^5.33.1",
+    "@typescript-eslint/parser": "^5.33.1",
+    "eslint": "^8.22.0",
     "eslint-config-prettier": "^8.5.0",
     "eslint-plugin-import": "^2.26.0",
     "eslint-plugin-prettier": "^4.2.1",
@@ -29,29 +29,28 @@
     "prettier": "^2.7.1",
     "vite-plugin-dynamic-import": "^1.1.1",
     "vue-eslint-parser": "^9.0.3",
-    "vue-tsc": "^0.39.4"
+    "vue-tsc": "^0.39.5"
   },
   "dependencies": {
-    "@vitejs/plugin-vue": "^3.0.1",
+    "@vitejs/plugin-vue": "^3.0.3",
     "can-autoplay": "^3.0.2",
     "chart.js": "^3.9.1",
     "config": "^3.3.7",
-    "date-fns": "^2.29.1",
+    "date-fns": "^2.29.2",
     "dompurify": "^2.3.10",
     "eslint-config-airbnb-base": "^15.0.0",
     "lofig": "^1.3.4",
     "marked": "^4.0.18",
     "normalize.css": "^8.0.1",
-    "pinia": "^2.0.17",
-    "sortablejs": "^1.15.0",
-    "sortablejs-vue3": "^1.2.0",
+    "pinia": "^2.0.19",
     "toasters": "^2.3.1",
     "typescript": "^4.7.4",
-    "vite": "^3.0.4",
+    "vite": "^3.0.9",
     "vue": "^3.2.36",
     "vue-chartjs": "^4.1.1",
     "vue-content-loader": "^2.0.1",
-    "vue-json-pretty": "^2.1.1",
+    "vue-draggable-list": "^0.1.0",
+    "vue-json-pretty": "^2.2.0",
     "vue-router": "^4.1.3",
     "vue-tippy": "^6.0.0-alpha.63"
   }

+ 3 - 4
frontend/src/App.vue

@@ -307,6 +307,7 @@ onMounted(async () => {
 @import "normalize.css/normalize.css";
 @import "tippy.js/dist/tippy.css";
 @import "tippy.js/animations/scale.css";
+@import "vue-draggable-list/dist/style.css";
 
 :root {
 	--primary-color: var(--blue);
@@ -577,6 +578,8 @@ body {
 	line-height: 1.4285714;
 	font-size: 1rem;
 	font-family: "Inter", Helvetica, Arial, sans-serif;
+	max-width: 100%;
+	overflow-x: hidden;
 }
 
 .app {
@@ -1636,10 +1639,6 @@ h4.section-title {
 }
 
 /** Universial items e.g. playlist items, queue items, activity items */
-.item-draggable {
-	cursor: move;
-}
-
 .universal-item {
 	display: flex;
 	flex-direction: row;

+ 187 - 217
frontend/src/components/AdvancedTable.vue

@@ -11,9 +11,9 @@ import {
 	nextTick
 } from "vue";
 import { useRoute, useRouter } from "vue-router";
-import { Sortable } from "sortablejs-vue3";
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
+import { DraggableList } from "vue-draggable-list";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useModalsStore } from "@/stores/modals";
 import keyboardShortcuts from "@/keyboardShortcuts";
@@ -86,15 +86,6 @@ const count = ref(0);
 const sort = ref({});
 const orderedColumns = ref([]);
 const shownColumns = ref([]);
-const columnDragOptions = ref({
-	animation: 200,
-	group: "columns",
-	disabled: false,
-	ghostClass: "draggable-list-ghost",
-	filter: ".ignore-elements",
-	fallbackTolerance: 50,
-	draggable: ".item-draggable"
-});
 const editingFilters = ref([]);
 const appliedFilters = ref([]);
 const filterOperator = ref("or");
@@ -674,52 +665,8 @@ const applyFilterAndGetData = () => {
 	storeTableSettings();
 };
 
-const columnOrderChanged = ({ oldIndex, newIndex }) => {
-	if (columnOrderChangedDebounceTimeout.value)
-		clearTimeout(columnOrderChangedDebounceTimeout.value);
-
-	columnOrderChangedDebounceTimeout.value = setTimeout(() => {
-		if (oldIndex === newIndex) return;
-		orderedColumns.value.splice(
-			newIndex,
-			0,
-			orderedColumns.value.splice(oldIndex, 1)[0]
-		);
-		storeTableSettings();
-	}, 100);
-};
-
-const columnOrderChangedDropdown = event => {
-	const filteredOrderedColumns = orderedColumns.value.filter(
-		column =>
-			column.name !== "select" &&
-			column.name !== "placeholder" &&
-			column.name !== "updatedPlaceholder" &&
-			column.draggable
-	);
-	const oldColumn = filteredOrderedColumns[event.oldDraggableIndex];
-	const newColumn = filteredOrderedColumns[event.newDraggableIndex];
-
-	const oldIndex = orderedColumns.value.indexOf(oldColumn);
-	const newIndex = orderedColumns.value.indexOf(newColumn);
-
-	columnOrderChanged({ oldIndex, newIndex });
-};
-
-const columnOrderChangedHead = event => {
-	const filteredOrderedColumns = orderedColumns.value.filter(
-		column =>
-			shownColumns.value.indexOf(column.name) !== -1 &&
-			(column.name !== "updatedPlaceholder" || rows.value.length > 0) &&
-			column.draggable
-	);
-	const oldColumn = filteredOrderedColumns[event.oldDraggableIndex];
-	const newColumn = filteredOrderedColumns[event.newDraggableIndex];
-
-	const oldIndex = orderedColumns.value.indexOf(oldColumn);
-	const newIndex = orderedColumns.value.indexOf(newColumn);
-
-	columnOrderChanged({ oldIndex, newIndex });
+const columnOrderChanged = () => {
+	storeTableSettings();
 };
 
 const getTableSettings = () => {
@@ -1572,66 +1519,79 @@ watch(selectedRows, (newSelectedRows, oldSelectedRows) => {
 						</div>
 
 						<template #content>
-							<Sortable
-								:list="orderedColumns"
-								item-key="name"
-								:options="columnDragOptions"
-								class="nav-dropdown-items"
-								@update="columnOrderChangedDropdown"
-							>
-								<template #item="{ element: column }">
-									<button
-										v-if="
-											column.name !== 'select' &&
-											column.name !== 'placeholder' &&
-											column.name !== 'updatedPlaceholder'
-										"
-										:class="{
+							<div class="nav-dropdown-items">
+								<draggable-list
+									v-model:list="orderedColumns"
+									item-key="name"
+									@update="columnOrderChanged"
+									:attributes="{
+										class: column => ({
 											sortable: column.sortable,
-											'item-draggable': column.draggable,
 											'nav-item': true
-										}"
-										@click.prevent="
-											toggleColumnVisibility(column)
-										"
-									>
-										<p
-											class="control is-expanded checkbox-control"
+										})
+									}"
+									:disabled="column => !column.draggable"
+									tag="button"
+								>
+									<template #item="{ element: column }">
+										<template
+											v-if="
+												column.name !== 'select' &&
+												column.name !== 'placeholder' &&
+												column.name !==
+													'updatedPlaceholder'
+											"
 										>
-											<label class="switch">
-												<input
-													type="checkbox"
-													:id="`column-dropdown-checkbox-${column.name}`"
-													:checked="
-														shownColumns.indexOf(
-															column.name
-														) !== -1
-													"
-													@click="
-														toggleColumnVisibility(
-															column
-														)
-													"
-												/>
-												<span
-													:class="{
-														slider: true,
-														round: true,
-														disabled:
-															!column.hidable
-													}"
-												></span>
-											</label>
-											<label
-												:for="`column-dropdown-checkbox-${column.name}`"
+											<div
+												@click.prevent="
+													toggleColumnVisibility(
+														column
+													)
+												"
 											>
-												<span></span>
-												<p>{{ column.displayName }}</p>
-											</label>
-										</p>
-									</button>
-								</template>
-							</Sortable>
+												<p
+													class="control is-expanded checkbox-control"
+												>
+													<label class="switch">
+														<input
+															type="checkbox"
+															:id="`column-dropdown-checkbox-${column.name}`"
+															:checked="
+																shownColumns.indexOf(
+																	column.name
+																) !== -1
+															"
+															@click="
+																toggleColumnVisibility(
+																	column
+																)
+															"
+														/>
+														<span
+															:class="{
+																slider: true,
+																round: true,
+																disabled:
+																	!column.hidable
+															}"
+														></span>
+													</label>
+													<label
+														:for="`column-dropdown-checkbox-${column.name}`"
+													>
+														<span></span>
+														<p>
+															{{
+																column.displayName
+															}}
+														</p>
+													</label>
+												</p>
+											</div>
+										</template>
+									</template>
+								</draggable-list>
+							</div>
 						</template>
 					</tippy>
 				</div>
@@ -1644,29 +1604,14 @@ watch(selectedRows, (newSelectedRows, oldSelectedRows) => {
 					}"
 				>
 					<thead>
-						<Sortable
-							:list="orderedColumns"
-							item-key="name"
-							tag="tr"
-							:options="{
-								...columnDragOptions,
-								handle: '.handle'
-							}"
-							@update="columnOrderChangedHead"
-						>
-							<template #item="{ element: column }">
-								<th
-									v-if="
-										shownColumns.indexOf(column.name) !==
-											-1 &&
-										(column.name !== 'updatedPlaceholder' ||
-											rows.length > 0)
-									"
-									:class="{
-										sortable: column.sortable,
-										'item-draggable': column.draggable
-									}"
-									:style="{
+						<tr>
+							<draggable-list
+								v-model:list="orderedColumns"
+								item-key="name"
+								@update="columnOrderChanged"
+								tag="th"
+								:attributes="{
+									style: column => ({
 										minWidth: Number.isNaN(column.minWidth)
 											? column.minWidth
 											: `${column.minWidth}px`,
@@ -1676,86 +1621,111 @@ watch(selectedRows, (newSelectedRows, oldSelectedRows) => {
 										maxWidth: Number.isNaN(column.maxWidth)
 											? column.maxWidth
 											: `${column.maxWidth}px`
-									}"
-								>
-									<div v-if="column.name === 'select'">
-										<p class="checkbox">
-											<input
-												v-if="rows.length === 0"
-												type="checkbox"
-												disabled
-											/>
-											<input
-												v-else
-												type="checkbox"
-												:checked="
-													rows.filter(
-														row => !row.removed
-													).length ===
-													selectedRows.length
-												"
-												@click="toggleAllRows()"
-											/>
-										</p>
-									</div>
-									<div v-else class="handle">
-										<span>
-											{{ column.displayName }}
-										</span>
-										<span
-											v-if="column.sortable"
-											:content="`Sort by ${column.displayName}`"
-											v-tippy
-										>
-											<span
-												v-if="
-													!sort[column.sortProperty]
-												"
-												class="material-icons"
-												@click="changeSort(column)"
-											>
-												unfold_more
-											</span>
-											<span
-												v-if="
-													sort[
-														column.sortProperty
-													] === 'ascending'
-												"
-												class="material-icons active"
-												@click="changeSort(column)"
-											>
-												expand_more
+									}),
+									class: column => ({
+										sortable: column.sortable
+									})
+								}"
+								:disabled="column => !column.draggable"
+							>
+								<template #item="{ element: column }">
+									<template
+										v-if="
+											shownColumns.indexOf(
+												column.name
+											) !== -1 &&
+											(column.name !==
+												'updatedPlaceholder' ||
+												rows.length > 0)
+										"
+									>
+										<div v-if="column.name === 'select'">
+											<p class="checkbox">
+												<input
+													v-if="rows.length === 0"
+													type="checkbox"
+													disabled
+												/>
+												<input
+													v-else
+													type="checkbox"
+													:checked="
+														rows.filter(
+															row => !row.removed
+														).length ===
+														selectedRows.length
+													"
+													@click="toggleAllRows()"
+												/>
+											</p>
+										</div>
+										<div v-else class="handle">
+											<span>
+												{{ column.displayName }}
 											</span>
 											<span
-												v-if="
-													sort[
-														column.sortProperty
-													] === 'descending'
-												"
-												class="material-icons active"
-												@click="changeSort(column)"
+												v-if="column.sortable"
+												:content="`Sort by ${column.displayName}`"
+												v-tippy
 											>
-												expand_less
+												<span
+													v-if="
+														!sort[
+															column.sortProperty
+														]
+													"
+													class="material-icons"
+													@click="changeSort(column)"
+												>
+													unfold_more
+												</span>
+												<span
+													v-if="
+														sort[
+															column.sortProperty
+														] === 'ascending'
+													"
+													class="material-icons active"
+													@click="changeSort(column)"
+												>
+													expand_more
+												</span>
+												<span
+													v-if="
+														sort[
+															column.sortProperty
+														] === 'descending'
+													"
+													class="material-icons active"
+													@click="changeSort(column)"
+												>
+													expand_less
+												</span>
 											</span>
-										</span>
-									</div>
-									<div
-										class="resizer"
-										v-if="column.resizable"
-										@mousedown.prevent.stop="
-											columnResizingStart(column, $event)
-										"
-										@touchstart.prevent.stop="
-											columnResizingStart(column, $event)
-										"
-										@mouseup="columnResizingStop()"
-										@touchend="columnResizingStop()"
-										@dblclick="columnResetWidth(column)"
-									></div>
-								</th>
-							</template>
-						</Sortable>
+										</div>
+										<div
+											class="resizer"
+											v-if="column.resizable"
+											@mousedown.prevent.stop="
+												columnResizingStart(
+													column,
+													$event
+												)
+											"
+											@touchstart.prevent.stop="
+												columnResizingStart(
+													column,
+													$event
+												)
+											"
+											@mouseup="columnResizingStop()"
+											@touchend="columnResizingStop()"
+											@dblclick="columnResetWidth(column)"
+										></div>
+									</template>
+								</template>
+							</draggable-list>
+						</tr>
 					</thead>
 					<tbody>
 						<tr
@@ -1980,13 +1950,13 @@ watch(selectedRows, (newSelectedRows, oldSelectedRows) => {
 	.table-outer-container {
 		.table-container .table {
 			&,
-			thead th {
+			:deep(thead th) {
 				background-color: var(--dark-grey-3) !important;
 				color: var(--light-grey-2);
 			}
 
 			tr {
-				th,
+				:deep(th),
 				td {
 					border-color: var(--dark-grey) !important;
 					background-color: var(--dark-grey-3) !important;
@@ -1999,7 +1969,7 @@ watch(selectedRows, (newSelectedRows, oldSelectedRows) => {
 				&:hover,
 				&:focus,
 				&.highlighted {
-					th,
+					:deep(th),
 					td {
 						background-color: var(--dark-grey-4) !important;
 					}
@@ -2075,7 +2045,7 @@ watch(selectedRows, (newSelectedRows, oldSelectedRows) => {
 			border-collapse: separate;
 			table-layout: fixed;
 
-			thead {
+			:deep(thead) {
 				tr {
 					th {
 						height: 40px;
@@ -2176,7 +2146,7 @@ watch(selectedRows, (newSelectedRows, oldSelectedRows) => {
 		}
 
 		table {
-			thead tr,
+			:deep(thead tr),
 			tbody tr {
 				th,
 				td {
@@ -2223,7 +2193,7 @@ watch(selectedRows, (newSelectedRows, oldSelectedRows) => {
 			}
 
 			&.has-checkboxes {
-				thead,
+				:deep(thead),
 				tbody {
 					tr {
 						th,

+ 6 - 15
frontend/src/components/PlaylistTabBase.vue

@@ -50,10 +50,9 @@ const featuredPlaylists = ref([]);
 const tabs = ref({});
 
 const {
-	Sortable,
+	DraggableList,
 	drag,
 	playlists,
-	dragOptions,
 	savePlaylistOrder,
 	orderOfPlaylists,
 	myUserId,
@@ -810,22 +809,15 @@ onMounted(() => {
 					class="menu-list scrollable-list"
 					v-if="playlists.length > 0"
 				>
-					<sortable
-						:component-data="{
-							name: !drag ? 'draggable-list-transition' : null
-						}"
+					<draggable-list
+						v-model:list="playlists"
 						item-key="_id"
-						:list="playlists"
-						:options="dragOptions"
 						@start="drag = true"
 						@end="drag = false"
 						@update="savePlaylistOrder"
 					>
 						<template #item="{ element }">
-							<playlist-item
-								class="item-draggable"
-								:playlist="element"
-							>
+							<playlist-item :playlist="element">
 								<template #item-icon>
 									<i
 										class="material-icons blacklisted-icon"
@@ -973,7 +965,7 @@ onMounted(() => {
 								</template>
 							</playlist-item>
 						</template>
-					</sortable>
+					</draggable-list>
 				</div>
 
 				<p v-else class="has-text-centered scrollable-list">
@@ -1030,8 +1022,7 @@ onMounted(() => {
 		.tab {
 			padding: 15px 0;
 			border-radius: 0;
-			.playlist-item:not(:last-of-type),
-			.item.item-draggable:not(:last-of-type) {
+			.playlist-item:not(:last-of-type) {
 				margin-bottom: 10px;
 			}
 			.load-more-button {

+ 26 - 42
frontend/src/components/Queue.vue

@@ -1,7 +1,7 @@
 <script setup lang="ts">
 import { defineAsyncComponent, ref, computed, onUpdated } from "vue";
-import { Sortable } from "sortablejs-vue3";
 import Toast from "toasters";
+import { DraggableList } from "vue-draggable-list";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useStationStore } from "@/stores/station";
 import { useManageStationStore } from "@/stores/manageStation";
@@ -22,12 +22,6 @@ const { socket } = useWebsocketsStore();
 const stationStore = useStationStore();
 const manageStationStore = useManageStationStore(props);
 
-const repositionSongInList = payload => {
-	if (props.sector === "manageStation")
-		return manageStationStore.repositionSongInList(payload);
-	return stationStore.repositionSongInList(payload);
-};
-
 const actionableButtonVisible = ref(false);
 const drag = ref(false);
 const songItems = ref([]);
@@ -62,13 +56,6 @@ const hasPermission = permission =>
 		? manageStationStore.hasPermission(permission)
 		: stationStore.hasPermission(permission);
 
-const dragOptions = computed(() => ({
-	animation: 200,
-	group: "queue",
-	disabled: !hasPermission("stations.queue.reposition"),
-	ghostClass: "draggable-list-ghost"
-}));
-
 const removeFromQueue = youtubeId => {
 	socket.dispatch(
 		"stations.removeFromQueue",
@@ -82,9 +69,10 @@ const removeFromQueue = youtubeId => {
 	);
 };
 
-const repositionSongInQueue = ({ oldIndex, newIndex }) => {
+const repositionSongInQueue = ({ moved }) => {
+	const { oldIndex, newIndex } = moved;
 	if (oldIndex === newIndex) return; // we only need to update when song is moved
-	const song = queue.value[oldIndex];
+	const song = queue.value[newIndex];
 	socket.dispatch(
 		"stations.repositionSongInQueue",
 		station.value._id,
@@ -96,30 +84,38 @@ const repositionSongInQueue = ({ oldIndex, newIndex }) => {
 		res => {
 			new Toast({ content: res.message, timeout: 4000 });
 			if (res.status !== "success")
-				repositionSongInList({
-					...song,
+				queue.value.splice(
 					oldIndex,
-					newIndex
-				});
+					0,
+					queue.value.splice(newIndex, 1)[0]
+				);
 		}
 	);
 };
 
 const moveSongToTop = index => {
 	songItems.value[`song-item-${index}`].$refs.songActions.tippy.hide();
-
+	queue.value.splice(0, 0, queue.value.splice(index, 1)[0]);
 	repositionSongInQueue({
-		oldIndex: index,
-		newIndex: 0
+		moved: {
+			oldIndex: index,
+			newIndex: 0
+		}
 	});
 };
 
 const moveSongToBottom = index => {
 	songItems.value[`song-item-${index}`].$refs.songActions.tippy.hide();
-
+	queue.value.splice(
+		queue.value.length - 1,
+		0,
+		queue.value.splice(index, 1)[0]
+	);
 	repositionSongInQueue({
-		oldIndex: index,
-		newIndex: queue.value.length
+		moved: {
+			oldIndex: index,
+			newIndex: queue.value.length - 1
+		}
 	});
 };
 
@@ -141,26 +137,18 @@ onUpdated(() => {
 				'scrollable-list': true
 			}"
 		>
-			<Sortable
-				:component-data="{
-					name: !drag ? 'draggable-list-transition' : null
-				}"
-				:list="queue"
+			<draggable-list
+				v-model:list="queue"
 				item-key="_id"
-				:options="dragOptions"
 				@start="drag = true"
 				@end="drag = false"
 				@update="repositionSongInQueue"
+				:disabled="!hasPermission('stations.queue.reposition')"
 			>
 				<template #item="{ element, index }">
 					<song-item
 						:song="element"
 						:requested-by="true"
-						:class="{
-							'item-draggable': hasPermission(
-								'stations.queue.reposition'
-							)
-						}"
 						:disabled-actions="[]"
 						:ref="el => (songItems[`song-item-${index}`] = el)"
 					>
@@ -199,7 +187,7 @@ onUpdated(() => {
 						</template>
 					</song-item>
 				</template>
-			</Sortable>
+			</draggable-list>
 		</div>
 		<p class="nothing-here-text has-text-centered" v-else>
 			There are no songs currently queued
@@ -224,10 +212,6 @@ onUpdated(() => {
 		max-height: 100%;
 	}
 
-	.song-item:not(:last-of-type) {
-		margin-bottom: 10px;
-	}
-
 	#queue-locked {
 		display: flex;
 		justify-content: center;

+ 7 - 3
frontend/src/components/modals/EditPlaylist/Tabs/ImportPlaylists.vue

@@ -27,10 +27,14 @@ const importPlaylist = () => {
 	if (!youtubeSearch.value.playlist.query)
 		return new Toast("Please enter a YouTube playlist URL.");
 
-	const regex = /[\\?&]list=([^&#]*)/;
-	const splitQuery = regex.exec(youtubeSearch.value.playlist.query);
+	const playlistRegex = /[\\?&]list=([^&#]*)/;
+	const channelRegex =
+		/\.[\w]+\/(?:(?:channel\/(UC[0-9A-Za-z_-]{21}[AQgw]))|(?:user\/?([\w-]+))|(?:c\/?([\w-]+))|(?:\/?([\w-]+)))/;
 
-	if (!splitQuery) {
+	if (
+		!playlistRegex.exec(youtubeSearch.value.playlist.query) &&
+		!channelRegex.exec(youtubeSearch.value.playlist.query)
+	) {
 		return new Toast({
 			content: "Please enter a valid YouTube playlist URL.",
 			timeout: 4000

+ 103 - 145
frontend/src/components/modals/EditPlaylist/index.vue

@@ -6,9 +6,9 @@ import {
 	onMounted,
 	onBeforeUnmount
 } from "vue";
-import { Sortable } from "sortablejs-vue3";
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
+import { DraggableList } from "vue-draggable-list";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useEditPlaylistStore } from "@/stores/editPlaylist";
 import { useStationStore } from "@/stores/station";
@@ -80,13 +80,6 @@ const isEditable = permission =>
 		permission === "playlists.update.privacy" &&
 		hasPermission(permission));
 
-const dragOptions = computed(() => ({
-	animation: 200,
-	group: "songs",
-	disabled: !isEditable("playlists.songs.reposition"),
-	ghostClass: "draggable-list-ghost"
-}));
-
 const init = () => {
 	gettingSongs.value = true;
 	socket.dispatch("playlists.getPlaylist", playlistId.value, res => {
@@ -97,10 +90,10 @@ const init = () => {
 	});
 };
 
-const repositionSong = ({ oldIndex, newIndex }) => {
+const repositionSong = ({ moved }) => {
+	const { oldIndex, newIndex } = moved;
 	if (oldIndex === newIndex) return; // we only need to update when song is moved
-	const song = playlistSongs.value[oldIndex];
-
+	const song = playlistSongs.value[newIndex];
 	socket.dispatch(
 		"playlists.repositionSong",
 		playlist.value._id,
@@ -120,21 +113,29 @@ const repositionSong = ({ oldIndex, newIndex }) => {
 	);
 };
 
-const moveSongToTop = (song, index) => {
+const moveSongToTop = index => {
 	songItems.value[`song-item-${index}`].$refs.songActions.tippy.hide();
-
+	playlistSongs.value.splice(0, 0, playlistSongs.value.splice(index, 1)[0]);
 	repositionSong({
-		oldIndex: index,
-		newIndex: 0
+		moved: {
+			oldIndex: index,
+			newIndex: 0
+		}
 	});
 };
 
-const moveSongToBottom = (song, index) => {
+const moveSongToBottom = index => {
 	songItems.value[`song-item-${index}`].$refs.songActions.tippy.hide();
-
+	playlistSongs.value.splice(
+		playlistSongs.value.length - 1,
+		0,
+		playlistSongs.value.splice(index, 1)[0]
+	);
 	repositionSong({
-		oldIndex: index,
-		newIndex: playlistSongs.value.length
+		moved: {
+			oldIndex: index,
+			newIndex: playlistSongs.value.length - 1
+		}
 	});
 };
 
@@ -401,123 +402,101 @@ onBeforeUnmount(() => {
 					</div>
 
 					<aside class="menu">
-						<sortable
-							:component-data="{
-								name: !drag ? 'draggable-list-transition' : null
-							}"
+						<draggable-list
 							v-if="playlistSongs.length > 0"
-							:list="playlistSongs"
+							v-model:list="playlistSongs"
 							item-key="_id"
-							:options="dragOptions"
 							@start="drag = true"
 							@end="drag = false"
 							@update="repositionSong"
+							:disabled="
+								!isEditable('playlists.songs.reposition')
+							"
 						>
 							<template #item="{ element, index }">
-								<div class="menu-list scrollable-list">
-									<song-item
-										:song="element"
-										:class="{
-											'item-draggable': isEditable(
-												'playlists.songs.reposition'
-											)
-										}"
-										:ref="
-											el =>
-												(songItems[
-													`song-item-${index}`
-												] = el)
-										"
-									>
-										<template #tippyActions>
-											<i
-												class="material-icons add-to-queue-icon"
-												v-if="
-													station &&
-													station.requests &&
-													station.requests.enabled &&
+								<song-item
+									:song="element"
+									:ref="
+										el =>
+											(songItems[`song-item-${index}`] =
+												el)
+									"
+								>
+									<template #tippyActions>
+										<i
+											class="material-icons add-to-queue-icon"
+											v-if="
+												station &&
+												station.requests &&
+												station.requests.enabled &&
+												(station.requests.access ===
+													'user' ||
 													(station.requests.access ===
-														'user' ||
-														(station.requests
-															.access ===
-															'owner' &&
-															(userRole ===
-																'admin' ||
-																station.owner ===
-																	userId)))
-												"
-												@click="
-													addSongToQueue(
-														element.youtubeId
-													)
-												"
-												content="Add Song to Queue"
-												v-tippy
-												>queue</i
-											>
-											<quick-confirm
-												v-if="
-													userId ===
-														playlist.createdBy ||
-													isEditable(
-														'playlists.songs.remove'
-													)
-												"
-												placement="left"
-												@confirm="
-													removeSongFromPlaylist(
-														element.youtubeId
-													)
-												"
-											>
-												<i
-													class="material-icons delete-icon"
-													content="Remove Song from Playlist"
-													v-tippy
-													>delete_forever</i
-												>
-											</quick-confirm>
-											<i
-												class="material-icons"
-												v-if="
-													isEditable(
-														'playlists.songs.reposition'
-													) && index > 0
-												"
-												@click="
-													moveSongToTop(
-														element,
-														index
-													)
-												"
-												content="Move to top of Playlist"
-												v-tippy
-												>vertical_align_top</i
-											>
+														'owner' &&
+														(userRole === 'admin' ||
+															station.owner ===
+																userId)))
+											"
+											@click="
+												addSongToQueue(
+													element.youtubeId
+												)
+											"
+											content="Add Song to Queue"
+											v-tippy
+											>queue</i
+										>
+										<quick-confirm
+											v-if="
+												userId === playlist.createdBy ||
+												isEditable(
+													'playlists.songs.reposition'
+												)
+											"
+											placement="left"
+											@confirm="
+												removeSongFromPlaylist(
+													element.youtubeId
+												)
+											"
+										>
 											<i
-												v-if="
-													isEditable(
-														'playlists.songs.reposition'
-													) &&
-													playlistSongs.length - 1 !==
-														index
-												"
-												@click="
-													moveSongToBottom(
-														element,
-														index
-													)
-												"
-												class="material-icons"
-												content="Move to bottom of Playlist"
+												class="material-icons delete-icon"
+												content="Remove Song from Playlist"
 												v-tippy
-												>vertical_align_bottom</i
+												>delete_forever</i
 											>
-										</template>
-									</song-item>
-								</div>
+										</quick-confirm>
+										<i
+											class="material-icons"
+											v-if="
+												isEditable(
+													'playlists.songs.reposition'
+												) && index > 0
+											"
+											@click="moveSongToTop(index)"
+											content="Move to top of Playlist"
+											v-tippy
+											>vertical_align_top</i
+										>
+										<i
+											v-if="
+												isEditable(
+													'playlists.songs.reposition'
+												) &&
+												playlistSongs.length - 1 !==
+													index
+											"
+											@click="moveSongToBottom(index)"
+											class="material-icons"
+											content="Move to bottom of Playlist"
+											v-tippy
+											>vertical_align_bottom</i
+										>
+									</template>
+								</song-item>
 							</template>
-						</sortable>
+						</draggable-list>
 						<p v-else-if="gettingSongs" class="nothing-here-text">
 							Loading songs...
 						</p>
@@ -612,19 +591,6 @@ onBeforeUnmount(() => {
 	}
 }
 
-.menu-list li {
-	display: flex;
-	justify-content: space-between;
-
-	&:not(:last-of-type) {
-		margin-bottom: 10px;
-	}
-
-	a {
-		display: flex;
-	}
-}
-
 .controls {
 	display: flex;
 
@@ -720,13 +686,5 @@ onBeforeUnmount(() => {
 			}
 		}
 	}
-
-	.right-section {
-		#rearrange-songs-section {
-			.scrollable-list:not(:last-of-type) {
-				margin-bottom: 10px;
-			}
-		}
-	}
 }
 </style>

+ 2 - 7
frontend/src/components/modals/EditSong/index.vue

@@ -236,13 +236,8 @@ const pickSong = song => {
 		prefill: songPrefillData.value[song.youtubeId]
 	});
 	currentSong.value = song;
-	if (
-		songItems.value[`edit-songs-item-${song.youtubeId}`] &&
-		songItems.value[`edit-songs-item-${song.youtubeId}`][0]
-	)
-		songItems.value[
-			`edit-songs-item-${song.youtubeId}`
-		][0].scrollIntoView();
+	if (songItems.value[`edit-songs-item-${song.youtubeId}`])
+		songItems.value[`edit-songs-item-${song.youtubeId}`].scrollIntoView();
 };
 
 const editNextSong = () => {

+ 24 - 86
frontend/src/components/modals/ImportAlbum.vue

@@ -1,5 +1,4 @@
 <script setup lang="ts">
-// TODO - Fix sortable
 import {
 	defineAsyncComponent,
 	ref,
@@ -8,8 +7,8 @@ import {
 	onBeforeUnmount
 } from "vue";
 import Toast from "toasters";
-import { Sortable } from "sortablejs-vue3";
 import { storeToRefs } from "pinia";
+import { DraggableList } from "vue-draggable-list";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useModalsStore } from "@/stores/modals";
 import { useImportAlbumStore } from "@/stores/importAlbum";
@@ -56,7 +55,6 @@ const discogs = ref({
 	disableLoadMore: false
 });
 const discogsTabs = ref([]);
-const sortableUpdateNumber = ref(0);
 
 // TODO might not not be needed anymore, might be able to directly edit prefillDiscogs
 const localPrefillDiscogs = computed({
@@ -333,61 +331,6 @@ const updateTrackSong = updatedSong => {
 	});
 };
 
-const updatePlaylistSongPosition = ({ oldIndex, newIndex }) => {
-	if (oldIndex === newIndex) return;
-	const oldSongs = playlistSongs.value;
-	oldSongs.splice(newIndex, 0, oldSongs.splice(oldIndex, 1)[0]);
-	playlistSongs.value = oldSongs;
-};
-
-const updateTrackSongPosition = (trackIndex, { oldIndex, newIndex }) => {
-	if (oldIndex === newIndex) return;
-	const oldSongs = trackSongs.value[trackIndex];
-	oldSongs.splice(newIndex, 0, oldSongs.splice(oldIndex, 1)[0]);
-	trackSongs.value[trackIndex] = oldSongs;
-};
-
-const playlistSongAdded = event => {
-	const fromTrack = event.from;
-	const fromTrackIndex = Number(fromTrack.dataset.trackIndex);
-	const song = trackSongs.value[fromTrackIndex][event.oldIndex];
-	const newPlaylistSongs = JSON.parse(JSON.stringify(playlistSongs.value));
-	newPlaylistSongs.splice(event.newIndex, 0, song);
-	playlistSongs.value = newPlaylistSongs;
-
-	sortableUpdateNumber.value += 1;
-};
-
-const playlistSongRemoved = event => {
-	playlistSongs.value.splice(event.oldIndex, 1);
-
-	sortableUpdateNumber.value += 1;
-};
-
-const trackSongAdded = (trackIndex, event) => {
-	const fromElement = event.from;
-	let song = null;
-	if (fromElement.dataset.trackIndex) {
-		const fromTrackIndex = Number(fromElement.dataset.trackIndex);
-		song = trackSongs.value[fromTrackIndex][event.oldIndex];
-	} else {
-		song = playlistSongs.value[event.oldIndex];
-	}
-	const newTrackSongs = JSON.parse(
-		JSON.stringify(trackSongs.value[trackIndex])
-	);
-	newTrackSongs.splice(event.newIndex, 0, song);
-	trackSongs.value[trackIndex] = newTrackSongs;
-
-	sortableUpdateNumber.value += 1;
-};
-
-const trackSongRemoved = (trackIndex, event) => {
-	trackSongs.value[trackIndex].splice(event.oldIndex, 1);
-
-	sortableUpdateNumber.value += 1;
-};
-
 onMounted(() => {
 	ws.onConnect(init);
 
@@ -408,7 +351,7 @@ onBeforeUnmount(() => {
 
 <template>
 	<div>
-		<modal title="Import Album" class="import-album-modal">
+		<modal title="Import Album" class="import-album-modal" size="wide">
 			<template #body>
 				<div class="tabs-container discogs-container">
 					<div class="tab-selection">
@@ -657,15 +600,11 @@ onBeforeUnmount(() => {
 					>
 						Reset
 					</button>
-					<sortable
-						:key="`${sortableUpdateNumber}-playlistSongs`"
+					<draggable-list
 						v-if="playlistSongs.length > 0"
-						:list="playlistSongs"
+						v-model:list="playlistSongs"
 						item-key="_id"
-						:options="{ group: 'songs' }"
-						@update="updatePlaylistSongPosition"
-						@add="playlistSongAdded"
-						@remove="playlistSongRemoved"
+						:group="`import-album-${modalUuid}-songs`"
 					>
 						<template #item="{ element }">
 							<song-item
@@ -674,7 +613,7 @@ onBeforeUnmount(() => {
 							>
 							</song-item>
 						</template>
-					</sortable>
+					</draggable-list>
 				</div>
 				<div
 					class="track-boxes"
@@ -689,25 +628,22 @@ onBeforeUnmount(() => {
 							<span>{{ track.position }}.</span>
 							<p>{{ track.title }}</p>
 						</div>
-						<sortable
-							:key="`${sortableUpdateNumber}-${index}-playlistSongs`"
-							class="track-box-songs-drag-area"
-							:list="trackSongs[index]"
-							:data-track-index="index"
-							item-key="_id"
-							:options="{ group: 'songs' }"
-							@update="updateTrackSongPosition(index, $event)"
-							@add="trackSongAdded(index, $event)"
-							@remove="trackSongRemoved(index, $event)"
-						>
-							<template #item="{ element }">
-								<song-item
-									:key="`track-song-${element._id}`"
-									:song="element"
-								>
-								</song-item>
-							</template>
-						</sortable>
+						<!-- :data-track-index="index" -->
+						<div class="track-box-songs-drag-area">
+							<draggable-list
+								v-model:list="trackSongs[index]"
+								item-key="_id"
+								:group="`import-album-${modalUuid}-songs`"
+							>
+								<template #item="{ element }">
+									<song-item
+										:key="`track-song-${element._id}`"
+										:song="element"
+									>
+									</song-item>
+								</template>
+							</draggable-list>
+						</div>
 					</div>
 				</div>
 			</template>
@@ -1168,6 +1104,8 @@ onBeforeUnmount(() => {
 		.track-box-songs-drag-area {
 			flex: 1;
 			min-height: 100px;
+			display: flex;
+			flex-direction: column;
 		}
 	}
 }

+ 1 - 1
frontend/src/components/modals/ManageStation/index.vue

@@ -524,7 +524,7 @@ onBeforeUnmount(() => {
 					</div>
 					<hr class="section-horizontal-rule" />
 					<song-item
-						v-if="currentSong._id"
+						v-if="currentSong.youtubeId"
 						:song="currentSong"
 						:requested-by="true"
 						header="Currently Playing.."

+ 8 - 17
frontend/src/composables/useSortablePlaylists.ts

@@ -1,7 +1,7 @@
 import { ref, computed, onMounted, onBeforeUnmount, nextTick } from "vue";
-import { Sortable } from "sortablejs-vue3";
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
+import { DraggableList } from "vue-draggable-list";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useUserAuthStore } from "@/stores/userAuth";
 import { useUserPlaylistsStore } from "@/stores/userPlaylists";
@@ -24,12 +24,6 @@ export const useSortablePlaylists = () => {
 		}
 	});
 	const isCurrentUser = computed(() => userId.value === myUserId.value);
-	const dragOptions = computed(() => ({
-		animation: 200,
-		group: "playlists",
-		disabled: !isCurrentUser.value,
-		ghostClass: "draggable-list-ghost"
-	}));
 
 	const { socket } = useWebsocketsStore();
 
@@ -42,15 +36,13 @@ export const useSortablePlaylists = () => {
 		return calculatedOrder;
 	};
 
-	const savePlaylistOrder = ({ oldIndex, newIndex }) => {
-		if (oldIndex === newIndex) return;
-		const oldPlaylists = playlists.value;
-
-		oldPlaylists.splice(newIndex, 0, oldPlaylists.splice(oldIndex, 1)[0]);
-
-		setPlaylists(oldPlaylists);
-
+	const savePlaylistOrder = () => {
 		const recalculatedOrder = calculatePlaylistOrder();
+		if (
+			JSON.stringify(orderOfPlaylists.value) ===
+			JSON.stringify(recalculatedOrder)
+		)
+			return; // nothing has changed
 
 		socket.dispatch(
 			"users.updateOrderOfPlaylists",
@@ -190,12 +182,11 @@ export const useSortablePlaylists = () => {
 	});
 
 	return {
-		Sortable,
+		DraggableList,
 		drag,
 		userId,
 		isCurrentUser,
 		playlists,
-		dragOptions,
 		orderOfPlaylists,
 		myUserId,
 		savePlaylistOrder,

+ 11 - 38
frontend/src/pages/Home.vue

@@ -7,9 +7,9 @@ import {
 	onMounted,
 	onBeforeUnmount
 } from "vue";
-import { Sortable } from "sortablejs-vue3";
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
+import { DraggableList } from "vue-draggable-list";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useUserAuthStore } from "@/stores/userAuth";
 import { useModalsStore } from "@/stores/modals";
@@ -47,7 +47,6 @@ const siteSettings = ref({
 });
 const orderOfFavoriteStations = ref([]);
 const handledLoginRegisterRedirect = ref(false);
-const changeFavoriteOrderDebounceTimeout = ref();
 
 const isOwner = station => loggedIn.value && station.owner === userId.value;
 
@@ -73,15 +72,6 @@ const filteredStations = computed(() => {
 		);
 });
 
-const dragOptions = computed(() => ({
-	animation: 200,
-	group: "favoriteStations",
-	disabled: false,
-	ghostClass: "draggable-list-ghost",
-	filter: ".ignore-elements",
-	fallbackTolerance: 50
-}));
-
 const favoriteStations = computed(() =>
 	filteredStations.value
 		.filter(station => station.isFavorited === true)
@@ -152,29 +142,13 @@ const unfavoriteStation = stationId => {
 	});
 };
 
-const changeFavoriteOrder = ({ oldIndex, newIndex }) => {
-	if (changeFavoriteOrderDebounceTimeout.value)
-		clearTimeout(changeFavoriteOrderDebounceTimeout.value);
-
-	changeFavoriteOrderDebounceTimeout.value = setTimeout(() => {
-		if (oldIndex === newIndex) return;
-		favoriteStations.value.splice(
-			newIndex,
-			0,
-			favoriteStations.value.splice(oldIndex, 1)[0]
-		);
-
-		const recalculatedOrder = [];
-		favoriteStations.value.forEach(station =>
-			recalculatedOrder.push(station._id)
-		);
-
-		socket.dispatch(
-			"users.updateOrderOfFavoriteStations",
-			recalculatedOrder,
-			res => new Toast(res.message)
-		);
-	}, 100);
+const changeFavoriteOrder = ({ moved }) => {
+	const { updatedList } = moved;
+	socket.dispatch(
+		"users.updateOrderOfFavoriteStations",
+		updatedList.map(station => station._id),
+		res => new Toast(res.message)
+	);
 };
 
 onMounted(async () => {
@@ -408,10 +382,10 @@ onBeforeUnmount(() => {
 					</div>
 				</div>
 
-				<sortable
+				<draggable-list
 					item-key="_id"
+					tag="span"
 					:list="favoriteStations"
-					:options="dragOptions"
 					@update="changeFavoriteOrder"
 				>
 					<template #item="{ element }">
@@ -422,7 +396,6 @@ onBeforeUnmount(() => {
 							}"
 							:class="{
 								'station-card': true,
-								'item-draggable': true,
 								isPrivate: element.privacy === 'private',
 								isMine: isOwner(element)
 							}"
@@ -632,7 +605,7 @@ onBeforeUnmount(() => {
 							</div>
 						</router-link>
 					</template>
-				</sortable>
+				</draggable-list>
 			</div>
 			<div class="group bottom">
 				<div class="group-title">

+ 4 - 13
frontend/src/pages/Profile/Tabs/Playlists.vue

@@ -13,12 +13,11 @@ const props = defineProps({
 });
 
 const {
-	Sortable,
+	DraggableList,
 	drag,
 	userId,
 	isCurrentUser,
 	playlists,
-	dragOptions,
 	savePlaylistOrder
 } = useSortablePlaylists();
 
@@ -47,14 +46,10 @@ onMounted(() => {
 
 			<hr class="section-horizontal-rule" />
 
-			<sortable
-				:component-data="{
-					name: !drag ? 'draggable-list-transition' : null
-				}"
+			<draggable-list
 				v-if="playlists.length > 0"
-				:list="playlists"
+				v-model:list="playlists"
 				item-key="_id"
-				:options="dragOptions"
 				@start="drag = true"
 				@end="drag = false"
 				@update="savePlaylistOrder"
@@ -67,10 +62,6 @@ onMounted(() => {
 								element.createdBy === userId)
 						"
 						:playlist="element"
-						:class="{
-							item: true,
-							'item-draggable': isCurrentUser
-						}"
 					>
 						<template #actions>
 							<i
@@ -102,7 +93,7 @@ onMounted(() => {
 						</template>
 					</playlist-item>
 				</template>
-			</sortable>
+			</draggable-list>
 
 			<button
 				v-if="isCurrentUser"

+ 2 - 1
frontend/src/pages/Profile/index.vue

@@ -397,7 +397,8 @@ onMounted(() => {
 			font-weight: 400;
 		}
 
-		.item {
+		.item,
+		.draggable-item {
 			overflow: hidden;
 
 			&:not(:last-of-type) {

+ 2 - 2
frontend/src/stores/settings.ts

@@ -27,7 +27,7 @@ export const useSettingsStore = defineStore("settings", {
 		}
 	},
 	getters: {
-		isGithubLinked: state => state.originalUser.services.github,
-		isPasswordLinked: state => state.originalUser.services.password
+		isGithubLinked: state => state.originalUser.github,
+		isPasswordLinked: state => state.originalUser.password
 	}
 });

+ 10 - 0
frontend/src/types/global.d.ts

@@ -10,6 +10,16 @@ declare global {
 	var addToPlaylistDropdown: any;
 	var scrollDebounceId: any;
 	var focusedElementBefore: any;
+	var draggingItem:
+		| undefined
+		| {
+				itemIndex: number;
+				itemListUuid: string;
+				itemGroup: string;
+				itemOnMove?: (index: number) => any;
+				initialItemIndex: number;
+				initialItemListUuid: string;
+		  };
 }
 
 export {};

+ 2 - 0
frontend/src/types/user.ts

@@ -29,6 +29,8 @@ export interface User {
 			access_token: string;
 		};
 	};
+	password?: boolean;
+	github?: boolean;
 	statistics: {
 		songsRequested: number;
 	};