Browse Source

Merge branch 'polishing' into manage-station

Kristian Vos 3 years ago
parent
commit
5cd63951a1
32 changed files with 661 additions and 613 deletions
  1. 22 4
      backend/index.js
  2. 44 272
      backend/logic/actions/playlists.js
  3. 14 9
      backend/logic/actions/songs.js
  4. 0 1
      backend/logic/actions/stations.js
  5. 2 3
      backend/logic/actions/users.js
  6. 0 1
      backend/logic/activities.js
  7. 2 2
      backend/logic/db/index.js
  8. 2 3
      backend/logic/db/schemas/playlist.js
  9. 3 3
      backend/logic/db/schemas/song.js
  10. 3 9
      backend/logic/migration/index.js
  11. 62 0
      backend/logic/migration/migrations/migration10.js
  12. 59 0
      backend/logic/migration/migrations/migration11.js
  13. 12 12
      backend/package-lock.json
  14. 1 1
      frontend/dist/index.tpl.html
  15. 0 0
      frontend/dist/vendor/lofig.1.3.4.min.js
  16. 122 52
      frontend/package-lock.json
  17. 2 2
      frontend/package.json
  18. 48 46
      frontend/src/App.vue
  19. 22 28
      frontend/src/components/AddToPlaylistDropdown.vue
  20. 1 0
      frontend/src/components/Confirm.vue
  21. 2 2
      frontend/src/components/Queue.vue
  22. 1 0
      frontend/src/components/SongItem.vue
  23. 67 80
      frontend/src/components/modals/EditPlaylist.vue
  24. 4 9
      frontend/src/components/modals/EditSong.vue
  25. 2 0
      frontend/src/components/modals/ManageStationKris/Tabs/Playlists.vue
  26. 5 0
      frontend/src/components/modals/ManageStationKris/Tabs/Settings.vue
  27. 1 0
      frontend/src/main.js
  28. 25 11
      frontend/src/pages/Admin/index.vue
  29. 21 1
      frontend/src/pages/Profile/index.vue
  30. 98 52
      frontend/src/pages/Settings/Tabs/Preferences.vue
  31. 10 2
      frontend/src/pages/Settings/index.vue
  32. 4 8
      frontend/src/pages/Station/index.vue

+ 22 - 4
backend/index.js

@@ -225,20 +225,37 @@ if (config.debug && config.debug.traceUnhandledPromises === true) {
 // }
 
 class JobManager {
+	// eslint-disable-next-line require-jsdoc
 	constructor() {
 		this.runningJobs = {};
 	}
 
+	/**
+	 * Adds a job to the list of running jobs
+	 *
+	 * @param {object} job - the job object
+	 */
 	addJob(job) {
 		if (!this.runningJobs[job.module.name]) this.runningJobs[job.module.name] = {};
 		this.runningJobs[job.module.name][job.toString()] = job;
 	}
 
+	/**
+	 * Removes a job from the list of running jobs (after it's completed)
+	 *
+	 * @param {object} job - the job object
+	 */
 	removeJob(job) {
 		if (!this.runningJobs[job.module.name]) this.runningJobs[job.module.name] = {};
 		delete this.runningJobs[job.module.name][job.toString()];
 	}
 
+	/**
+	 * Returns detail about a job via a identifier
+	 *
+	 * @param {string} uuid - the job identifier
+	 * @returns {object} - the job object
+	 */
 	getJob(uuid) {
 		let job = null;
 		Object.keys(this.runningJobs).forEach(moduleName => {
@@ -343,7 +360,8 @@ class ModuleManager {
 
 			this.log(
 				"INFO",
-				`Initialized: ${Object.keys(this.modules).length - this.modulesNotInitialized.length}/${Object.keys(this.modules).length
+				`Initialized: ${Object.keys(this.modules).length - this.modulesNotInitialized.length}/${
+					Object.keys(this.modules).length
 				}.`
 			);
 
@@ -465,7 +483,8 @@ process.stdin.on("data", data => {
 			console.log(
 				`${moduleName.toUpperCase()}${Array(tabsNeeded).join(
 					"\t"
-				)}${module.getStatus()}. Jobs in queue: ${module.jobQueue.lengthQueue()}. Jobs in progress: ${module.jobQueue.lengthRunning()}. Jobs paused: ${module.jobQueue.lengthPaused()} Concurrency: ${module.jobQueue.concurrency
+				)}${module.getStatus()}. Jobs in queue: ${module.jobQueue.lengthQueue()}. Jobs in progress: ${module.jobQueue.lengthRunning()}. Jobs paused: ${module.jobQueue.lengthPaused()} Concurrency: ${
+					module.jobQueue.concurrency
 				}. Stage: ${module.getStage()}`
 			);
 		});
@@ -501,8 +520,7 @@ process.stdin.on("data", data => {
 		const parts = command.split(" ");
 
 		const uuid = parts[1];
-		let jobFound = moduleManager.jobManager.getJob(uuid);
-
+		const jobFound = moduleManager.jobManager.getJob(uuid);
 
 		if (jobFound) {
 			let topParent = jobFound;

+ 44 - 272
backend/logic/actions/playlists.js

@@ -46,15 +46,14 @@ CacheModule.runJob("SUB", {
 });
 
 CacheModule.runJob("SUB", {
-	channel: "playlist.repositionSongs",
+	channel: "playlist.repositionSong",
 	cb: res => {
-		WSModule.runJob("SOCKETS_FROM_USER", { userId: res.userId }, this).then(sockets =>
+		const { userId, playlistId, song } = res;
+
+		WSModule.runJob("SOCKETS_FROM_USER", { userId }, this).then(sockets =>
 			sockets.forEach(socket =>
-				socket.dispatch("event:playlist.songs.repositioned", {
-					data: {
-						playlistId: res.playlistId,
-						songsBeingChanged: res.songsBeingChanged
-					}
+				socket.dispatch("event:playlist.song.repositioned", {
+					data: { playlistId, song }
 				})
 			)
 		);
@@ -640,49 +639,6 @@ export default {
 		);
 	},
 
-	/**
-	 * Obtains basic metadata of a playlist in order to format an activity
-	 *
-	 * @param {object} session - the session object automatically added by the websocket
-	 * @param {string} playlistId - the playlist id
-	 * @param {Function} cb - callback
-	 */
-	getPlaylistForActivity(session, playlistId, cb) {
-		async.waterfall(
-			[
-				next => {
-					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
-						.then(playlist => {
-							next(null, playlist);
-						})
-						.catch(next);
-				}
-			],
-			async (err, playlist) => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log(
-						"ERROR",
-						"PLAYLISTS_GET_PLAYLIST_FOR_ACTIVITY",
-						`Failed to obtain metadata of playlist ${playlistId} for activity formatting. "${err}"`
-					);
-					return cb({ status: "error", message: err });
-				}
-				this.log(
-					"SUCCESS",
-					"PLAYLISTS_GET_PLAYLIST_FOR_ACTIVITY",
-					`Obtained metadata of playlist ${playlistId} for activity formatting successfully.`
-				);
-				return cb({
-					status: "success",
-					data: {
-						title: playlist.displayName
-					}
-				});
-			}
-		);
-	},
-
 	/**
 	 * Gets a playlist from station id
 	 *
@@ -742,67 +698,6 @@ export default {
 		);
 	},
 
-	// TODO Remove this
-	/**
-	 * Updates a private playlist
-	 *
-	 * @param {object} session - the session object automatically added by the websocket
-	 * @param {string} playlistId - the id of the playlist we are updating
-	 * @param {object} playlist - the new private playlist object
-	 * @param {Function} cb - gets called with the result
-	 */
-	update: isLoginRequired(async function update(session, playlistId, playlist, cb) {
-		const playlistModel = await DBModule.runJob(
-			"GET_MODEL",
-			{
-				modelName: "playlist"
-			},
-			this
-		);
-		async.waterfall(
-			[
-				next => {
-					playlistModel.updateOne(
-						{ _id: playlistId, createdBy: session.userId },
-						playlist,
-						{ runValidators: true },
-						next
-					);
-				},
-
-				(res, next) => {
-					PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
-						.then(playlist => {
-							next(null, playlist);
-						})
-						.catch(next);
-				}
-			],
-			async (err, playlist) => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log(
-						"ERROR",
-						"PLAYLIST_UPDATE",
-						`Updating private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
-					);
-					return cb({ status: "error", message: err });
-				}
-
-				this.log(
-					"SUCCESS",
-					"PLAYLIST_UPDATE",
-					`Successfully updated private playlist "${playlistId}" for user "${session.userId}".`
-				);
-
-				return cb({
-					status: "success",
-					data: { playlist }
-				});
-			}
-		);
-	}),
-
 	/**
 	 * Shuffles songs in a private playlist
 	 *
@@ -865,37 +760,44 @@ export default {
 	}),
 
 	/**
-	 * Changes the order of song(s) in a private playlist
+	 * Changes the order (position) of a song in a playlist
 	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {string} playlistId - the id of the playlist we are targeting
-	 * @param {Array} songsBeingChanged - the songs to be repositioned, each element contains "youtubeId" and "position" properties
+	 * @param {object} song - the song to be repositioned
+	 * @param {string} song.youtubeId - the youtube id of the song being repositioned
+	 * @param {string} song.newIndex - the new position of the song in the playlist
+	 * @param {...any} song.args - any other elements that would be included with a song item in a playlist
 	 * @param {Function} cb - gets called with the result
 	 */
-	repositionSongs: isLoginRequired(async function repositionSongs(session, playlistId, songsBeingChanged, cb) {
+	repositionSong: isLoginRequired(async function repositionSong(session, playlistId, song, cb) {
 		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
 
 		async.waterfall(
 			[
-				// update playlist object with each song's new position
-				next =>
-					async.each(
-						songsBeingChanged,
-						(song, nextSong) =>
-							playlistModel.updateOne(
-								{ _id: playlistId, "songs.youtubeId": song.youtubeId },
-								{
-									$set: {
-										"songs.$.position": song.position
-									}
-								},
-								err => {
-									if (err) return next(err);
-									return nextSong();
-								}
-							),
+				next => {
+					if (!playlistId) return next("Please provide a playlist.");
+					if (!song || !song.youtubeId) return next("You must provide a song to reposition.");
+					return next();
+				},
+
+				// remove song from playlist
+				next => {
+					playlistModel.updateOne(
+						{ _id: playlistId },
+						{ $pull: { songs: { youtubeId: song.youtubeId } } },
 						next
-					),
+					);
+				},
+
+				// add song back to playlist (in new position)
+				(res, next) => {
+					playlistModel.updateOne(
+						{ _id: playlistId },
+						{ $push: { songs: { $each: [song], $position: song.newIndex } } },
+						err => next(err)
+					);
+				},
 
 				// update the cache with the new songs positioning
 				next => {
@@ -910,8 +812,8 @@ export default {
 
 					this.log(
 						"ERROR",
-						"PLAYLIST_REPOSITION_SONGS",
-						`Repositioning songs for private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+						"PLAYLIST_REPOSITION_SONG",
+						`Repositioning song ${song.youtubeId}  for private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
 					);
 
 					return cb({ status: "error", message: err });
@@ -919,107 +821,22 @@ export default {
 
 				this.log(
 					"SUCCESS",
-					"PLAYLIST_REPOSITION_SONGS",
-					`Successfully repositioned songs for private playlist "${playlistId}" for user "${session.userId}".`
+					"PLAYLIST_REPOSITION_SONG",
+					`Successfully repositioned song ${song.youtubeId} for private playlist "${playlistId}" for user "${session.userId}".`
 				);
 
 				CacheModule.runJob("PUB", {
-					channel: "playlist.repositionSongs",
+					channel: "playlist.repositionSong",
 					value: {
 						userId: session.userId,
 						playlistId,
-						songsBeingChanged
+						song
 					}
 				});
 
 				return cb({
 					status: "success",
-					message: "Order of songs successfully updated"
-				});
-			}
-		);
-	}),
-
-	/**
-	 * Moves a song to the bottom of the list in a private playlist
-	 *
-	 * @param {object} session - the session object automatically added by the websocket
-	 * @param {string} playlistId - the id of the playlist we are moving the song to the bottom from
-	 * @param {string} youtubeId - the youtube id of the song we are moving to the bottom of the list
-	 * @param {Function} cb - gets called with the result
-	 */
-	moveSongToBottom: isLoginRequired(async function moveSongToBottom(session, playlistId, youtubeId, cb) {
-		async.waterfall(
-			[
-				next => {
-					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
-						.then(playlist => next(null, playlist))
-						.catch(next);
-				},
-
-				(playlist, next) => {
-					if (!playlist || playlist.createdBy !== session.userId) return next("Playlist not found");
-					if (!playlist.isUserModifiable) return next("Playlist cannot be modified.");
-
-					// sort array by position
-					playlist.songs.sort((a, b) => a.position - b.position);
-
-					// find index of youtubeId
-					playlist.songs.forEach((song, index) => {
-						// reorder array (simulates what would be done with a drag and drop interface)
-						if (song.youtubeId === youtubeId)
-							playlist.songs.splice(playlist.songs.length, 0, playlist.songs.splice(index, 1)[0]);
-					});
-
-					const songsBeingChanged = [];
-
-					playlist.songs.forEach((song, index) => {
-						// check if position needs updated based on index
-						if (song.position !== index + 1)
-							songsBeingChanged.push({
-								youtubeId: song.youtubeId,
-								position: index + 1
-							});
-					});
-
-					// update position property on songs that need to be changed
-					return WSModule.runJob(
-						"RUN_ACTION2",
-						{
-							session,
-							namespace: "playlists",
-							action: "repositionSongs",
-							args: [playlistId, songsBeingChanged]
-						},
-						this
-					)
-						.then(res => {
-							if (res.status === "success") return next();
-							return next("Unable to reposition song in playlist.");
-						})
-						.catch(next);
-				}
-			],
-			async err => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log(
-						"ERROR",
-						"PLAYLIST_MOVE_SONG_TO_BOTTOM",
-						`Moving song "${youtubeId}" to the bottom for private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
-					);
-					return cb({ status: "error", message: err });
-				}
-
-				this.log(
-					"SUCCESS",
-					"PLAYLIST_MOVE_SONG_TO_BOTTOM",
-					`Successfully moved song "${youtubeId}" to the bottom for private playlist "${playlistId}" for user "${session.userId}".`
-				);
-
-				return cb({
-					status: "success",
-					message: "Order of songs successfully updated"
+					message: "Successfully repositioned song"
 				});
 			}
 		);
@@ -1311,59 +1128,14 @@ export default {
 			[
 				next => {
 					if (!youtubeId || typeof youtubeId !== "string") return next("Invalid song id.");
-					if (!playlistId) return next("Invalid playlist id.");
+					if (!playlistId || typeof youtubeId !== "string") return next("Invalid playlist id.");
 					return next();
 				},
 
-				next => {
-					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
-						.then(playlist => next(null, playlist))
-						.catch(next);
-				},
-
-				(playlist, next) => {
-					if (!playlist || playlist.createdBy !== session.userId) return next("Playlist not found");
-
-					// sort array by position
-					playlist.songs.sort((a, b) => a.position - b.position);
-
-					// find index of youtubeId
-					playlist.songs.forEach((song, ind) => {
-						// remove song from array
-						if (song.youtubeId === youtubeId) playlist.songs.splice(ind, 1);
-					});
-
-					const songsBeingChanged = [];
-
-					playlist.songs.forEach((song, index) => {
-						// check if position needs updated based on index
-						if (song.position !== index + 1)
-							songsBeingChanged.push({
-								youtubeId: song.youtubeId,
-								position: index + 1
-							});
-					});
-
-					// update position property on songs that need to be changed
-					return WSModule.runJob(
-						"RUN_ACTION2",
-						{
-							session,
-							namespace: "playlists",
-							action: "repositionSongs",
-							args: [playlistId, songsBeingChanged]
-						},
-						this
-					)
-						.then(res => {
-							if (res.status === "success") return next();
-							return next("Unable to reposition song in playlist.");
-						})
-						.catch(next);
-				},
-
+				// remove song from playlist
 				next => playlistModel.updateOne({ _id: playlistId }, { $pull: { songs: { youtubeId } } }, next),
 
+				// update cache representation of the playlist
 				(res, next) => {
 					PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
 						.then(playlist => next(null, playlist))

+ 14 - 9
backend/logic/actions/songs.js

@@ -571,7 +571,6 @@ export default {
 	// 			},
 
 	// 			(res, next) => {
-	// 				// TODO Check if res gets returned from above
 	// 				CacheModule.runJob("HDEL", { table: "songs", key: songId }, this)
 	// 					.then(() => {
 	// 						next();
@@ -769,8 +768,8 @@ export default {
 				},
 
 				(song, next) => {
-					song.acceptedBy = session.userId;
-					song.acceptedAt = Date.now();
+					song.verifiedBy = session.userId;
+					song.verifiedAt = Date.now();
 					song.status = "verified";
 					song.save(err => {
 						next(err, song);
@@ -1001,8 +1000,8 @@ export default {
 
 	// 			next => {
 	// 				const newSong = new SongModel(song);
-	// 				newSong.acceptedBy = session.userId;
-	// 				newSong.acceptedAt = Date.now();
+	// 				newSong.verifiedBy = session.userId;
+	// 				newSong.verifiedAt = Date.now();
 	// 				newSong.save(next);
 	// 			},
 
@@ -1091,8 +1090,12 @@ export default {
 							this
 						)
 						.then(res => {
-							if (res.status === "error")
+							if (res.status === "error") {
+								if (res.message === "That song is already in the playlist")
+									return next("You have already liked this song.");
 								return next("Unable to add song to the 'Liked Songs' playlist.");
+							}
+
 							return next(null, song, user.dislikedSongsPlaylist);
 						})
 						.catch(err => next(err));
@@ -1165,8 +1168,6 @@ export default {
 		);
 	}),
 
-	// TODO: ALready liked/disliked etc.
-
 	/**
 	 * Dislikes a song
 	 *
@@ -1206,8 +1207,12 @@ export default {
 							this
 						)
 						.then(res => {
-							if (res.status === "error")
+							if (res.status === "error") {
+								if (res.message === "That song is already in the playlist")
+									return next("You have already disliked this song.");
 								return next("Unable to add song to the 'Disliked Songs' playlist.");
+							}
+
 							return next(null, song, user.likedSongsPlaylist);
 						})
 						.catch(err => next(err));

+ 0 - 1
backend/logic/actions/stations.js

@@ -541,7 +541,6 @@ CacheModule.runJob("SUB", {
 				args: ["event:admin.station.created", { data: { station } }]
 			}).then(() => {});
 
-			// TODO If community, check if on whitelist
 			if (station.privacy === "public")
 				WSModule.runJob("EMIT_TO_ROOM", {
 					room: "home",

+ 2 - 3
backend/logic/actions/users.js

@@ -1339,14 +1339,13 @@ export default {
 			});
 	}),
 
-	// TODO Fix security issues
 	/**
 	 * Gets user info from session
 	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {Function} cb - gets called with the result
 	 */
-	async findBySession(session, cb) {
+	findBySession: isLoginRequired(async function findBySession(session, cb) {
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 
 		async.waterfall(
@@ -1406,7 +1405,7 @@ export default {
 				});
 			}
 		);
-	},
+	}),
 
 	/**
 	 * Updates a user's username

+ 0 - 1
backend/logic/activities.js

@@ -34,7 +34,6 @@ class _ActivitiesModule extends CoreClass {
 		});
 	}
 
-	// TODO: Migrate
 	/**
 	 * Adds a new activity to the database
 	 *

+ 2 - 2
backend/logic/db/index.js

@@ -8,11 +8,11 @@ import CoreClass from "../../core";
 const REQUIRED_DOCUMENT_VERSIONS = {
 	activity: 2,
 	news: 2,
-	playlist: 3,
+	playlist: 4,
 	punishment: 1,
 	queueSong: 1,
 	report: 2,
-	song: 4,
+	song: 5,
 	station: 5,
 	user: 3
 };

+ 2 - 3
backend/logic/db/schemas/playlist.js

@@ -11,8 +11,7 @@ export default {
 			duration: { type: Number },
 			thumbnail: { type: String, required: false },
 			artists: { type: Array, required: false },
-			status: { type: String },
-			position: { type: Number }
+			status: { type: String }
 		}
 	],
 	createdBy: { type: String, required: true },
@@ -20,5 +19,5 @@ export default {
 	createdFor: { type: String },
 	privacy: { type: String, enum: ["public", "private"], default: "private" },
 	type: { type: String, enum: ["user", "genre", "station"], required: true },
-	documentVersion: { type: Number, default: 3, required: true }
+	documentVersion: { type: Number, default: 4, required: true }
 };

+ 3 - 3
backend/logic/db/schemas/song.js

@@ -11,9 +11,9 @@ export default {
 	explicit: { type: Boolean },
 	requestedBy: { type: String },
 	requestedAt: { type: Date },
-	acceptedBy: { type: String }, // TODO Should be verifiedBy
-	acceptedAt: { type: Date }, // TODO Should be verifiedAt
+	verifiedBy: { type: String },
+	verifiedAt: { type: Date },
 	discogs: { type: Object },
 	status: { type: String, required: true, default: "hidden", enum: ["hidden", "unverified", "verified"] },
-	documentVersion: { type: Number, default: 4, required: true }
+	documentVersion: { type: Number, default: 5, required: true }
 };

+ 3 - 9
backend/logic/migration/index.js

@@ -42,9 +42,7 @@ class _MigrationModule extends CoreClass {
 					useCreateIndex: true
 				})
 				.then(async () => {
-					mongoose.connection.on("error", err => {
-						this.log("ERROR", err);
-					});
+					mongoose.connection.on("error", err => this.log("ERROR", err));
 
 					mongoose.connection.on("disconnected", () => {
 						this.log("ERROR", "Disconnected, going to try to reconnect...");
@@ -83,12 +81,8 @@ class _MigrationModule extends CoreClass {
 						1,
 						(index, next) => {
 							MigrationModule.runJob("RUN_MIGRATION", { index: index + 1 }, null, -1)
-								.then(() => {
-									next();
-								})
-								.catch(err => {
-									next(err);
-								});
+								.then(() => next())
+								.catch(err => next(err));
 						},
 						err => {
 							if (err) console.log("Migration error", err);

+ 62 - 0
backend/logic/migration/migrations/migration10.js

@@ -0,0 +1,62 @@
+import async from "async";
+
+/**
+ * Migration 10
+ *
+ * Migration for changes in how the order of songs in a playlist is handled
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const playlistModel = await MigrationModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 10. Finding playlists with document version 3.`);
+					playlistModel.find({ documentVersion: 3 }, (err, playlists) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								playlists.map(playlisti => playlisti._doc),
+								1,
+								(playlisti, next) => {
+									// sort playlists by the position property
+									playlisti.songs.sort((song1, song2) => song1.position - song2.position);
+
+									// delete the position property for each song
+									playlisti.songs.forEach(song => delete song.position);
+
+									// update the database
+									playlistModel.updateOne(
+										{ _id: playlisti._id },
+										{
+											$set: {
+												songs: playlisti.songs,
+												documentVersion: 4
+											}
+										},
+										next
+									);
+								},
+								err => {
+									if (err) next(err);
+									else {
+										this.log("INFO", `Migration 10. Playlists found: ${playlists.length}.`);
+										next();
+									}
+								}
+							);
+						}
+					});
+				}
+			],
+			err => {
+				if (err) reject(new Error(err));
+				else resolve();
+			}
+		);
+	});
+}

+ 59 - 0
backend/logic/migration/migrations/migration11.js

@@ -0,0 +1,59 @@
+import async from "async";
+
+/**
+ * Migration 11
+ *
+ * Migration for changing language of verifying a song from 'accepted' to 'verified' for songs
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const songModel = await MigrationModule.runJob("GET_MODEL", { modelName: "song" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 11. Finding songs with document version 4.`);
+					songModel.find({ documentVersion: 4 }, (err, songs) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								songs.map(songi => songi._doc),
+								1,
+								(songi, next) =>
+									songModel.updateOne(
+										{ _id: songi._id },
+										{
+											$set: {
+												verifiedBy: songi.acceptedBy,
+												verifiedAt: songi.acceptedAt,
+												documentVersion: 5
+											},
+											$unset: {
+												acceptedBy: "",
+												acceptedAt: ""
+											}
+										},
+										next
+									),
+								err => {
+									if (err) next(err);
+									else {
+										this.log("INFO", `Migration 11. Songs found: ${songs.length}.`);
+										next();
+									}
+								}
+							);
+						}
+					});
+				}
+			],
+			err => {
+				if (err) reject(new Error(err));
+				else resolve();
+			}
+		);
+	});
+}

+ 12 - 12
backend/package-lock.json

@@ -1688,9 +1688,9 @@
       }
     },
     "node_modules/hosted-git-info": {
-      "version": "2.8.8",
-      "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz",
-      "integrity": "sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==",
+      "version": "2.8.9",
+      "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
+      "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
       "dev": true
     },
     "node_modules/http-errors": {
@@ -2030,9 +2030,9 @@
       }
     },
     "node_modules/lodash": {
-      "version": "4.17.20",
-      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz",
-      "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==",
+      "version": "4.17.21",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
       "dev": true
     },
     "node_modules/make-dir": {
@@ -4875,9 +4875,9 @@
       }
     },
     "hosted-git-info": {
-      "version": "2.8.8",
-      "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz",
-      "integrity": "sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==",
+      "version": "2.8.9",
+      "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
+      "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
       "dev": true
     },
     "http-errors": {
@@ -5144,9 +5144,9 @@
       }
     },
     "lodash": {
-      "version": "4.17.20",
-      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz",
-      "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==",
+      "version": "4.17.21",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
       "dev": true
     },
     "make-dir": {

+ 1 - 1
frontend/dist/index.tpl.html

@@ -36,7 +36,7 @@
 	<link rel='stylesheet' href='/index.css'>
 	<script src='https://www.youtube.com/iframe_api'></script>
 	<script type='text/javascript' src='/vendor/can-autoplay.min.js'></script>
-	<script type='text/javascript' src='/vendor/lofig.1.3.3.min.js'></script>
+	<script type='text/javascript' src='/vendor/lofig.1.3.4.min.js'></script>
 </head>
 <body>
 	<div id="root"></div>

+ 0 - 0
frontend/dist/vendor/lofig.1.3.3.min.js → frontend/dist/vendor/lofig.1.3.4.min.js


+ 122 - 52
frontend/package-lock.json

@@ -4904,30 +4904,103 @@
       }
     },
     "css-loader": {
-      "version": "3.6.0",
-      "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-3.6.0.tgz",
-      "integrity": "sha512-M5lSukoWi1If8dhQAUCvj4H8vUt3vOnwbQBH9DdTm/s4Ym2B/3dPMtYZeJmq7Q3S3Pa+I94DcZ7pc9bP14cWIQ==",
-      "dev": true,
-      "requires": {
-        "camelcase": "^5.3.1",
-        "cssesc": "^3.0.0",
-        "icss-utils": "^4.1.1",
-        "loader-utils": "^1.2.3",
-        "normalize-path": "^3.0.0",
-        "postcss": "^7.0.32",
-        "postcss-modules-extract-imports": "^2.0.0",
-        "postcss-modules-local-by-default": "^3.0.2",
-        "postcss-modules-scope": "^2.2.0",
-        "postcss-modules-values": "^3.0.0",
+      "version": "5.2.4",
+      "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-5.2.4.tgz",
+      "integrity": "sha512-OFYGyINCKkdQsTrSYxzGSFnGS4gNjcXkKkQgWxK138jgnPt+lepxdjSZNc8sHAl5vP3DhsJUxufWIjOwI8PMMw==",
+      "dev": true,
+      "requires": {
+        "camelcase": "^6.2.0",
+        "icss-utils": "^5.1.0",
+        "loader-utils": "^2.0.0",
+        "postcss": "^8.2.10",
+        "postcss-modules-extract-imports": "^3.0.0",
+        "postcss-modules-local-by-default": "^4.0.0",
+        "postcss-modules-scope": "^3.0.0",
+        "postcss-modules-values": "^4.0.0",
         "postcss-value-parser": "^4.1.0",
-        "schema-utils": "^2.7.0",
-        "semver": "^6.3.0"
+        "schema-utils": "^3.0.0",
+        "semver": "^7.3.5"
       },
       "dependencies": {
+        "ajv": {
+          "version": "6.12.6",
+          "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+          "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+          "dev": true,
+          "requires": {
+            "fast-deep-equal": "^3.1.1",
+            "fast-json-stable-stringify": "^2.0.0",
+            "json-schema-traverse": "^0.4.1",
+            "uri-js": "^4.2.2"
+          }
+        },
+        "ajv-keywords": {
+          "version": "3.5.2",
+          "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
+          "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
+          "dev": true
+        },
+        "camelcase": {
+          "version": "6.2.0",
+          "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.2.0.tgz",
+          "integrity": "sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==",
+          "dev": true
+        },
+        "loader-utils": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz",
+          "integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==",
+          "dev": true,
+          "requires": {
+            "big.js": "^5.2.2",
+            "emojis-list": "^3.0.0",
+            "json5": "^2.1.2"
+          }
+        },
+        "lru-cache": {
+          "version": "6.0.0",
+          "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+          "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+          "dev": true,
+          "requires": {
+            "yallist": "^4.0.0"
+          }
+        },
+        "postcss": {
+          "version": "8.2.15",
+          "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.2.15.tgz",
+          "integrity": "sha512-2zO3b26eJD/8rb106Qu2o7Qgg52ND5HPjcyQiK2B98O388h43A448LCslC0dI2P97wCAQRJsFvwTRcXxTKds+Q==",
+          "dev": true,
+          "requires": {
+            "colorette": "^1.2.2",
+            "nanoid": "^3.1.23",
+            "source-map": "^0.6.1"
+          }
+        },
+        "schema-utils": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.0.0.tgz",
+          "integrity": "sha512-6D82/xSzO094ajanoOSbe4YvXWMfn2A//8Y1+MUqFAJul5Bs+yn36xbK9OtNDcRVSBJ9jjeoXftM6CfztsjOAA==",
+          "dev": true,
+          "requires": {
+            "@types/json-schema": "^7.0.6",
+            "ajv": "^6.12.5",
+            "ajv-keywords": "^3.5.2"
+          }
+        },
         "semver": {
-          "version": "6.3.0",
-          "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
-          "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+          "version": "7.3.5",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
+          "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==",
+          "dev": true,
+          "requires": {
+            "lru-cache": "^6.0.0"
+          }
+        },
+        "yallist": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+          "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
           "dev": true
         }
       }
@@ -6833,13 +6906,10 @@
       }
     },
     "icss-utils": {
-      "version": "4.1.1",
-      "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-4.1.1.tgz",
-      "integrity": "sha512-4aFq7wvWyMHKgxsH8QQtGpvbASCf+eM3wPRLI6R+MgAnTCZ6STYsRvttLvRWK0Nfif5piF394St3HeJDaljGPA==",
-      "dev": true,
-      "requires": {
-        "postcss": "^7.0.14"
-      }
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz",
+      "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==",
+      "dev": true
     },
     "ignore": {
       "version": "4.0.6",
@@ -7957,6 +8027,12 @@
       "integrity": "sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw==",
       "dev": true
     },
+    "nanoid": {
+      "version": "3.1.23",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.23.tgz",
+      "integrity": "sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw==",
+      "dev": true
+    },
     "nanomatch": {
       "version": "1.2.13",
       "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz",
@@ -8781,44 +8857,38 @@
       }
     },
     "postcss-modules-extract-imports": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-2.0.0.tgz",
-      "integrity": "sha512-LaYLDNS4SG8Q5WAWqIJgdHPJrDDr/Lv775rMBFUbgjTz6j34lUznACHcdRWroPvXANP2Vj7yNK57vp9eFqzLWQ==",
-      "dev": true,
-      "requires": {
-        "postcss": "^7.0.5"
-      }
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz",
+      "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==",
+      "dev": true
     },
     "postcss-modules-local-by-default": {
-      "version": "3.0.3",
-      "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-3.0.3.tgz",
-      "integrity": "sha512-e3xDq+LotiGesympRlKNgaJ0PCzoUIdpH0dj47iWAui/kyTgh3CiAr1qP54uodmJhl6p9rN6BoNcdEDVJx9RDw==",
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz",
+      "integrity": "sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==",
       "dev": true,
       "requires": {
-        "icss-utils": "^4.1.1",
-        "postcss": "^7.0.32",
+        "icss-utils": "^5.0.0",
         "postcss-selector-parser": "^6.0.2",
         "postcss-value-parser": "^4.1.0"
       }
     },
     "postcss-modules-scope": {
-      "version": "2.2.0",
-      "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-2.2.0.tgz",
-      "integrity": "sha512-YyEgsTMRpNd+HmyC7H/mh3y+MeFWevy7V1evVhJWewmMbjDHIbZbOXICC2y+m1xI1UVfIT1HMW/O04Hxyu9oXQ==",
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz",
+      "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==",
       "dev": true,
       "requires": {
-        "postcss": "^7.0.6",
-        "postcss-selector-parser": "^6.0.0"
+        "postcss-selector-parser": "^6.0.4"
       }
     },
     "postcss-modules-values": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-3.0.0.tgz",
-      "integrity": "sha512-1//E5jCBrZ9DmRX+zCtmQtRSV6PV42Ix7Bzj9GbwJceduuf7IqP8MgeTXuRDHOWj2m0VzZD5+roFWDuU8RQjcg==",
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz",
+      "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==",
       "dev": true,
       "requires": {
-        "icss-utils": "^4.0.0",
-        "postcss": "^7.0.6"
+        "icss-utils": "^5.0.0"
       }
     },
     "postcss-selector-parser": {
@@ -10477,9 +10547,9 @@
       }
     },
     "toasters": {
-      "version": "2.3.0",
-      "resolved": "https://registry.npmjs.org/toasters/-/toasters-2.3.0.tgz",
-      "integrity": "sha512-3BaJ9SYFOL9VNh9YXqUIpR5beX+qLit35xi7iSMLi6CH8GR/8RCEz5EaWeCcH+Z1VD6whv0amPLtBWWGgkoiSw=="
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/toasters/-/toasters-2.3.1.tgz",
+      "integrity": "sha512-2u7xuSf0MDCt4SwDRWOe9gzOYLyB0s3hvS6FPLaxUXVmRrjLZOhOXGimHq6BxWpplBGo+xJA+Ifhcx+asSDKUA=="
     },
     "toidentifier": {
       "version": "1.0.0",

+ 2 - 2
frontend/package.json

@@ -24,7 +24,7 @@
     "@babel/preset-env": "^7.13.12",
     "babel-eslint": "^10.0.2",
     "babel-loader": "^8.2.2",
-    "css-loader": "^3.6.0",
+    "css-loader": "^5.2.4",
     "eslint": "^6.1.0",
     "eslint-config-prettier": "^6.15.0",
     "eslint-loader": "^2.2.1",
@@ -49,7 +49,7 @@
     "eslint-config-airbnb-base": "^13.2.0",
     "html-webpack-plugin": "^5.3.1",
     "marked": "^2.0.3",
-    "toasters": "^2.3.0",
+    "toasters": "^2.3.1",
     "vue": "^2.6.12",
     "vue-content-loader": "^0.2.3",
     "vue-loader": "^15.9.6",

+ 48 - 46
frontend/src/App.vue

@@ -396,10 +396,6 @@ a {
 					p {
 						color: var(--white);
 					}
-
-					.checkbox-control label span {
-						background-color: var(--dark-grey-2);
-					}
 				}
 			}
 		}
@@ -537,7 +533,7 @@ a {
 	&.songActions-theme,
 	&.addToPlaylist-theme {
 		.tippy-arrow {
-			border-top-color: var(--light-grey-3);
+			border-top-color: var(--white);
 		}
 	}
 	&.confirm-theme .tippy-arrow {
@@ -548,7 +544,7 @@ a {
 	&.songActions-theme,
 	&.addToPlaylist-theme {
 		.tippy-arrow {
-			border-bottom-color: var(--light-grey-3);
+			border-bottom-color: var(--white);
 		}
 	}
 	&.confirm-theme .tippy-arrow {
@@ -559,7 +555,7 @@ a {
 	&.songActions-theme,
 	&.addToPlaylist-theme {
 		.tippy-arrow {
-			border-left-color: var(--light-grey-3);
+			border-left-color: var(--white);
 		}
 	}
 	&.confirm-theme .tippy-arrow {
@@ -570,7 +566,7 @@ a {
 	&.songActions-theme,
 	&.addToPlaylist-theme {
 		.tippy-arrow {
-			border-right-color: var(--light-grey-3);
+			border-right-color: var(--white);
 		}
 	}
 	&.confirm-theme .tippy-arrow {
@@ -600,55 +596,61 @@ a {
 
 			.checkbox-control {
 				display: flex;
+				flex-direction: row;
 				align-items: center;
-				margin-bottom: 0 !important;
-				width: inherit;
 
-				input {
-					margin-right: 5px;
+				p {
+					margin-left: 10px;
 				}
 
-				input[type="checkbox"] {
-					opacity: 0;
-					position: absolute;
+				.switch {
+					position: relative;
+					display: inline-block;
+					flex-shrink: 0;
+					width: 40px;
+					height: 24px;
 				}
 
-				label {
-					display: flex;
-					flex-direction: row;
-					align-items: center;
-					width: inherit;
-
-					span {
-						cursor: pointer;
-						min-width: 24px;
-						height: 24px;
-						background-color: var(--white);
-						display: inline-block;
-						border: 1px solid var(--dark-grey-2);
-						position: relative;
-						border-radius: 3px;
-					}
+				.switch input {
+					opacity: 0;
+					width: 0;
+					height: 0;
+				}
 
-					p {
-						margin-left: 10px;
-						cursor: pointer;
-						color: var(--black);
-						overflow: hidden;
-						text-overflow: ellipsis;
-						white-space: nowrap;
-					}
+				.slider {
+					position: absolute;
+					cursor: pointer;
+					top: 0;
+					left: 0;
+					right: 0;
+					bottom: 0;
+					background-color: #ccc;
+					transition: 0.2s;
+					border-radius: 34px;
 				}
 
-				input[type="checkbox"]:checked + label span::after {
+				.slider:before {
+					position: absolute;
 					content: "";
-					width: 18px;
-					height: 18px;
-					left: 2px;
-					top: 2px;
-					border-radius: 3px;
+					height: 16px;
+					width: 16px;
+					left: 4px;
+					bottom: 4px;
+					background-color: white;
+					transition: 0.2s;
+					border-radius: 50%;
+				}
+
+				input:checked + .slider {
 					background-color: var(--primary-color);
-					position: absolute;
+				}
+
+				input:focus + .slider {
+					box-shadow: 0 0 1px var(--primary-color);
+				}
+
+				input:checked + .slider:before {
+					transform: translateX(16px);
 				}
 			}
 

+ 22 - 28
frontend/src/components/AddToPlaylistDropdown.vue

@@ -1,6 +1,7 @@
 <template>
 	<tippy
 		class="addToPlaylistDropdown"
+		touch="true"
 		interactive="true"
 		:placement="placement"
 		theme="addToPlaylist"
@@ -20,6 +21,7 @@
 		<template #trigger>
 			<slot name="button" />
 		</template>
+
 		<div class="nav-dropdown-items" v-if="playlists.length > 0">
 			<button
 				class="nav-item"
@@ -30,12 +32,15 @@
 				:title="playlist.displayName"
 			>
 				<p class="control is-expanded checkbox-control">
-					<input
-						type="checkbox"
-						:id="index"
-						:checked="hasSong(playlist)"
-						@click="toggleSongInPlaylist(index)"
-					/>
+					<label class="switch">
+						<input
+							type="checkbox"
+							:id="index"
+							:checked="hasSong(playlist)"
+							@click="toggleSongInPlaylist(index)"
+						/>
+						<span class="slider round"></span>
+					</label>
 					<label :for="index">
 						<span></span>
 						<p>{{ playlist.displayName }}</p>
@@ -71,7 +76,9 @@ export default {
 		}),
 		playlists: {
 			get() {
-				return this.$store.state.user.playlists.playlists;
+				return this.$store.state.user.playlists.playlists.filter(
+					playlist => playlist.isUserModifiable
+				);
 			},
 			set(playlists) {
 				this.$store.commit("user/playlists/setPlaylists", playlists);
@@ -125,33 +132,14 @@ export default {
 					false,
 					this.song.youtubeId,
 					playlist._id,
-					res => {
-						new Toast(res.message);
-
-						if (res.status === "success") {
-							this.playlists[playlistIndex].songs.push(this.song);
-						}
-					}
+					res => new Toast(res.message)
 				);
 			} else {
 				this.socket.dispatch(
 					"playlists.removeSongFromPlaylist",
 					this.song.youtubeId,
 					playlist._id,
-					res => {
-						new Toast(res.message);
-
-						if (res.status === "success") {
-							this.playlists[playlistIndex].songs.forEach(
-								(song, songIndex) => {
-									if (song.youtubeId === this.song.youtubeId)
-										this.playlists[
-											playlistIndex
-										].songs.splice(songIndex, 1);
-								}
-							);
-						}
-					}
+					res => new Toast(res.message)
 				);
 			}
 		},
@@ -165,3 +153,9 @@ export default {
 	}
 };
 </script>
+
+<style lang="scss" scoped>
+.nav-dropdown-items button .control {
+	margin-bottom: 0 !important;
+}
+</style>

+ 1 - 0
frontend/src/components/Confirm.vue

@@ -1,6 +1,7 @@
 <template>
 	<tippy
 		interactive="true"
+		touch="true"
 		:placement="placement"
 		theme="confirm"
 		ref="confirm"

+ 2 - 2
frontend/src/components/Queue.vue

@@ -222,9 +222,9 @@ export default {
 				this.station._id,
 				youtubeId,
 				res => {
-					if (res.status === "success") {
+					if (res.status === "success")
 						new Toast("Successfully removed song from the queue.");
-					} else new Toast(res.message);
+					else new Toast(res.message);
 				}
 			);
 		},

+ 1 - 0
frontend/src/components/SongItem.vue

@@ -72,6 +72,7 @@
 			>
 				<tippy
 					v-if="loggedIn"
+					touch="true"
 					interactive="true"
 					placement="left"
 					theme="songActions"

+ 67 - 80
frontend/src/components/modals/EditPlaylist.vue

@@ -240,7 +240,7 @@
 							v-bind="dragOptions"
 							@start="drag = true"
 							@end="drag = false"
-							@change="updateSongPositioning"
+							@change="repositionSong"
 						>
 							<transition-group
 								type="transition"
@@ -457,9 +457,7 @@ export default {
 				if (this.playlist._id === res.data.playlistId)
 					this.playlist.songs.push(res.data.song);
 			},
-			{
-				modal: "editPlaylist"
-			}
+			{ modal: "editPlaylist" }
 		);
 
 		this.socket.on(
@@ -482,9 +480,7 @@ export default {
 					});
 				}
 			},
-			{
-				modal: "editPlaylist"
-			}
+			{ modal: "editPlaylist" }
 		);
 
 		this.socket.on(
@@ -493,39 +489,32 @@ export default {
 				if (this.playlist._id === res.data.playlistId)
 					this.playlist.displayName = res.data.displayName;
 			},
-			{
-				modal: "editPlaylist"
-			}
+			{ modal: "editPlaylist" }
 		);
 
 		this.socket.on(
-			"event:playlist.songs.repositioned",
+			"event:playlist.song.repositioned",
 			res => {
 				if (this.playlist._id === res.data.playlistId) {
-					// for each song that has a new position
-					res.data.songsBeingChanged.forEach(changedSong => {
-						this.playlist.songs.forEach((song, index) => {
-							// find song locally
-							if (song.youtubeId === changedSong.youtubeId) {
-								// change song position attribute
-								this.playlist.songs[index].position =
-									changedSong.position;
-
-								// reposition in array if needed
-								if (index !== changedSong.position - 1)
-									this.playlist.songs.splice(
-										changedSong.position - 1,
-										0,
-										this.playlist.songs.splice(index, 1)[0]
-									);
-							}
-						});
-					});
+					const { song, playlistId } = res.data;
+
+					if (this.playlist._id === playlistId) {
+						if (
+							this.playlist.songs[song.newIndex] &&
+							this.playlist.songs[song.newIndex].youtubeId ===
+								song.youtubeId
+						)
+							return;
+
+						this.playlist.songs.splice(
+							song.newIndex,
+							0,
+							this.playlist.songs.splice(song.oldIndex, 1)[0]
+						);
+					}
 				}
 			},
-			{
-				modal: "editPlaylist"
-			}
+			{ modal: "editPlaylist" }
 		);
 	},
 	methods: {
@@ -584,28 +573,45 @@ export default {
 		isAdmin() {
 			return this.userRole === "admin";
 		},
-		updateSongPositioning({ moved }) {
+		repositionSong({ moved }) {
 			if (!moved) return; // we only need to update when song is moved
 
-			const songsBeingChanged = [];
-
-			this.playlist.songs.forEach((song, index) => {
-				if (song.position !== index + 1)
-					songsBeingChanged.push({
-						youtubeId: song.youtubeId,
-						position: index + 1
-					});
-			});
-
 			this.socket.dispatch(
-				"playlists.repositionSongs",
+				"playlists.repositionSong",
 				this.playlist._id,
-				songsBeingChanged,
+				{
+					...moved.element,
+					oldIndex: moved.oldIndex,
+					newIndex: moved.newIndex
+				},
 				res => {
-					new Toast(res.message);
+					if (res.status !== "success")
+						this.repositionSongInList({
+							...moved.element,
+							newIndex: moved.oldIndex,
+							oldIndex: moved.newIndex
+						});
 				}
 			);
 		},
+		moveSongToTop(song, index) {
+			this.repositionSongInPlaylist({
+				moved: {
+					element: song,
+					oldIndex: index,
+					newIndex: 0
+				}
+			});
+		},
+		moveSongToBottom(song, index) {
+			this.repositionSongInPlaylist({
+				moved: {
+					element: song,
+					oldIndex: index,
+					newIndex: this.songsList.length
+				}
+			});
+		},
 		totalLength() {
 			let length = 0;
 			this.playlist.songs.forEach(song => {
@@ -641,25 +647,24 @@ export default {
 			);
 		},
 		removeSongFromPlaylist(id) {
-			if (this.playlist.displayName === "Liked Songs") {
-				this.socket.dispatch("songs.unlike", id, res => {
+			if (this.playlist.displayName === "Liked Songs")
+				return this.socket.dispatch("songs.unlike", id, res => {
 					new Toast(res.message);
 				});
-			}
-			if (this.playlist.displayName === "Disliked Songs") {
-				this.socket.dispatch("songs.undislike", id, res => {
+
+			if (this.playlist.displayName === "Disliked Songs")
+				return this.socket.dispatch("songs.undislike", id, res => {
 					new Toast(res.message);
 				});
-			} else {
-				this.socket.dispatch(
-					"playlists.removeSongFromPlaylist",
-					id,
-					this.playlist._id,
-					res => {
-						new Toast(res.message);
-					}
-				);
-			}
+
+			return this.socket.dispatch(
+				"playlists.removeSongFromPlaylist",
+				id,
+				this.playlist._id,
+				res => {
+					new Toast(res.message);
+				}
+			);
 		},
 		renamePlaylist() {
 			const { displayName } = this.playlist;
@@ -717,24 +722,6 @@ export default {
 					() => new Toast("Failed to export and download playlist.")
 				);
 		},
-		moveSongToTop(index) {
-			this.playlist.songs.splice(
-				0,
-				0,
-				this.playlist.songs.splice(index, 1)[0]
-			);
-
-			this.updateSongPositioning({ moved: {} });
-		},
-		moveSongToBottom(index) {
-			this.playlist.songs.splice(
-				this.playlist.songs.length,
-				0,
-				this.playlist.songs.splice(index, 1)[0]
-			);
-
-			this.updateSongPositioning({ moved: {} });
-		},
 		updatePrivacy() {
 			const { privacy } = this.playlist;
 			if (privacy === "public" || privacy === "private") {

+ 4 - 9
frontend/src/components/modals/EditSong.vue

@@ -736,14 +736,12 @@ export default {
 								localStorage.getItem("volume")
 							);
 							volume = typeof volume === "number" ? volume : 20;
-							console.log(`Seekto: ${this.song.skipDuration}`);
 							this.video.player.seekTo(this.song.skipDuration);
 							this.video.player.setVolume(volume);
 							if (volume > 0) this.video.player.unMute();
 
 							const duration = this.video.player.getDuration();
 
-							console.log(1111, duration.toFixed(3));
 							this.youtubeVideoDuration = duration.toFixed(3);
 							this.youtubeVideoNote = "(~)";
 							this.playerReady = true;
@@ -754,7 +752,10 @@ export default {
 							this.drawCanvas();
 
 							let skipToLast10SecsPressed = false;
-							if (this.skipToLast10SecsPressed) {
+							if (
+								event.data === 1 &&
+								this.skipToLast10SecsPressed
+							) {
 								this.skipToLast10SecsPressed = false;
 								skipToLast10SecsPressed = true;
 							}
@@ -800,12 +801,6 @@ export default {
 								)
 									this.song.duration = newYoutubeVideoDuration;
 
-								console.log(
-									2222,
-									newYoutubeVideoDuration,
-									this.video.player.getDuration()
-								);
-
 								this.youtubeVideoDuration = newYoutubeVideoDuration;
 								this.youtubeVideoNote = "";
 

+ 2 - 0
frontend/src/components/modals/ManageStationKris/Tabs/Playlists.vue

@@ -12,6 +12,7 @@
 				<button
 					v-if="station.type === 'community'"
 					class="button is-default"
+					ref="my-playlists-tab"
 					:class="{ selected: tab === 'my-playlists' }"
 					@click="showTab('my-playlists')"
 				>
@@ -706,6 +707,7 @@ export default {
 	},
 	methods: {
 		showTab(tab) {
+			this.$refs[`${tab}-tab`].scrollIntoView();
 			this.tab = tab;
 		},
 		isOwner() {

+ 5 - 0
frontend/src/components/modals/ManageStationKris/Tabs/Settings.vue

@@ -46,6 +46,7 @@
 					class="button-wrapper"
 					theme="addToPlaylist"
 					interactive="true"
+					touch="true"
 					placement="bottom"
 					trigger="click"
 					append-to="parent"
@@ -96,6 +97,7 @@
 					class="button-wrapper"
 					theme="addToPlaylist"
 					interactive="true"
+					touch="true"
 					placement="bottom"
 					trigger="click"
 					append-to="parent"
@@ -146,6 +148,7 @@
 					v-if="station.type === 'community'"
 					class="button-wrapper"
 					theme="addToPlaylist"
+					touch="true"
 					interactive="true"
 					placement="bottom"
 					trigger="click"
@@ -200,6 +203,7 @@
 					v-if="station.type === 'community'"
 					class="button-wrapper"
 					theme="addToPlaylist"
+					touch="true"
 					interactive="true"
 					placement="bottom"
 					trigger="click"
@@ -258,6 +262,7 @@
 					class="button-wrapper"
 					theme="addToPlaylist"
 					interactive="true"
+					touch="true"
 					placement="bottom"
 					trigger="click"
 					append-to="parent"

+ 1 - 0
frontend/src/main.js

@@ -17,6 +17,7 @@ const handleMetadata = attrs => {
 Vue.use(VueTippy, {
 	directive: "tippy", // => v-tippy
 	flipDuration: 0,
+	touch: false,
 	popperOptions: {
 		modifiers: {
 			preventOverflow: {

+ 25 - 11
frontend/src/pages/Admin/index.vue

@@ -5,6 +5,7 @@
 			<ul>
 				<li
 					:class="{ 'is-active': currentTab == 'hiddensongs' }"
+					ref="hiddensongs-tab"
 					@click="showTab('hiddensongs')"
 				>
 					<router-link
@@ -17,6 +18,7 @@
 				</li>
 				<li
 					:class="{ 'is-active': currentTab == 'unverifiedsongs' }"
+					ref="unverifiedsongs-tab"
 					@click="showTab('unverifiedsongs')"
 				>
 					<router-link
@@ -29,6 +31,7 @@
 				</li>
 				<li
 					:class="{ 'is-active': currentTab == 'verifiedsongs' }"
+					ref="verifiedsongs-tab"
 					@click="showTab('verifiedsongs')"
 				>
 					<router-link
@@ -41,6 +44,7 @@
 				</li>
 				<li
 					:class="{ 'is-active': currentTab == 'stations' }"
+					ref="stations-tab"
 					@click="showTab('stations')"
 				>
 					<router-link class="tab stations" to="/admin/stations">
@@ -50,6 +54,7 @@
 				</li>
 				<li
 					:class="{ 'is-active': currentTab == 'playlists' }"
+					ref="playlists-tab"
 					@click="showTab('playlists')"
 				>
 					<router-link class="tab playlists" to="/admin/playlists">
@@ -59,6 +64,7 @@
 				</li>
 				<li
 					:class="{ 'is-active': currentTab == 'reports' }"
+					ref="reports-tab"
 					@click="showTab('reports')"
 				>
 					<router-link class="tab reports" to="/admin/reports">
@@ -68,6 +74,7 @@
 				</li>
 				<li
 					:class="{ 'is-active': currentTab == 'news' }"
+					ref="news-tab"
 					@click="showTab('news')"
 				>
 					<router-link class="tab news" to="/admin/news">
@@ -77,6 +84,7 @@
 				</li>
 				<li
 					:class="{ 'is-active': currentTab == 'users' }"
+					ref="users-tab"
 					@click="showTab('users')"
 				>
 					<router-link class="tab users" to="/admin/users">
@@ -86,6 +94,7 @@
 				</li>
 				<li
 					:class="{ 'is-active': currentTab == 'statistics' }"
+					ref="statistics-tab"
 					@click="showTab('statistics')"
 				>
 					<router-link class="tab statistics" to="/admin/statistics">
@@ -95,6 +104,7 @@
 				</li>
 				<li
 					:class="{ 'is-active': currentTab == 'punishments' }"
+					ref="punishments-tab"
 					@click="showTab('punishments')"
 				>
 					<router-link
@@ -163,40 +173,44 @@ export default {
 		changeTab(path) {
 			switch (path) {
 				case "/admin/unverifiedsongs":
-					this.currentTab = "unverifiedsongs";
+					this.showTab("unverifiedsongs");
 					break;
 				case "/admin/verifiedsongs":
-					this.currentTab = "verifiedsongs";
+					this.showTab("verifiedsongs");
 					break;
 				case "/admin/hiddensongs":
-					this.currentTab = "hiddensongs";
+					this.showTab("hiddensongs");
 					break;
 				case "/admin/stations":
-					this.currentTab = "stations";
+					this.showTab("stations");
 					break;
 				case "/admin/playlists":
-					this.currentTab = "playlists";
+					this.showTab("playlists");
 					break;
 				case "/admin/reports":
-					this.currentTab = "reports";
+					this.showTab("reports");
 					break;
 				case "/admin/news":
-					this.currentTab = "news";
+					this.showTab("news");
 					break;
 				case "/admin/users":
-					this.currentTab = "users";
+					this.showTab("users");
 					break;
 				case "/admin/statistics":
-					this.currentTab = "statistics";
+					this.showTab("statistics");
 					break;
 				case "/admin/punishments":
-					this.currentTab = "punishments";
+					this.showTab("punishments");
 					break;
 				default:
-					this.currentTab = "verifiedsongs";
+					this.showTab("verifiedsongs");
 			}
 		},
 		showTab(tab) {
+			if (this.$refs[`${tab}-tab`])
+				this.$refs[`${tab}-tab`].scrollIntoView({
+					inline: "center"
+				});
 			this.currentTab = tab;
 		}
 	}

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

@@ -191,12 +191,20 @@ export default {
 
 		.picture-name-row {
 			flex-direction: column !important;
+
+			.profile-picture {
+				margin-right: 0 !important;
+			}
 		}
 
-		.name-role-row {
+		.name-row {
 			margin-top: 24px;
 		}
 
+		.username-row {
+			justify-content: center;
+		}
+
 		.buttons .button:not(:last-of-type) {
 			margin-bottom: 10px;
 			margin-right: 5px;
@@ -205,6 +213,12 @@ export default {
 		.date-location-row {
 			flex-direction: column;
 			width: auto !important;
+			row-gap: 24px;
+
+			.date,
+			.location {
+				justify-content: center;
+			}
 		}
 
 		.date-location-row > div:nth-child(2),
@@ -215,6 +229,12 @@ export default {
 
 	.bottom-section {
 		flex-direction: column;
+
+		.buttons,
+		.content {
+			margin-left: auto;
+			margin-right: auto !important;
+		}
 	}
 
 	.content {

+ 98 - 52
frontend/src/pages/Settings/Tabs/Preferences.vue

@@ -7,51 +7,76 @@
 		<hr class="section-horizontal-rule" />
 
 		<p class="control is-expanded checkbox-control">
-			<input type="checkbox" id="nightmode" v-model="localNightmode" />
+			<label class="switch">
+				<input
+					type="checkbox"
+					id="nightmode"
+					v-model="localNightmode"
+				/>
+				<span class="slider round"></span>
+			</label>
+
 			<label for="nightmode">
-				<span></span>
 				<p>Use nightmode</p>
 			</label>
 		</p>
+
 		<p class="control is-expanded checkbox-control">
-			<input
-				type="checkbox"
-				id="autoSkipDisliked"
-				v-model="localAutoSkipDisliked"
-			/>
+			<label class="switch">
+				<input
+					type="checkbox"
+					id="autoSkipDisliked"
+					v-model="localAutoSkipDisliked"
+				/>
+				<span class="slider round"></span>
+			</label>
+
 			<label for="autoSkipDisliked">
-				<span></span>
 				<p>Automatically vote to skip disliked songs</p>
 			</label>
 		</p>
+
 		<p class="control is-expanded checkbox-control">
-			<input
-				type="checkbox"
-				id="activityLogPublic"
-				v-model="localActivityLogPublic"
-			/>
+			<label class="switch">
+				<input
+					type="checkbox"
+					id="activityLogPublic"
+					v-model="localActivityLogPublic"
+				/>
+				<span class="slider round"></span>
+			</label>
+
 			<label for="activityLogPublic">
-				<span></span>
 				<p>Allow my activity log to be viewed publicly</p>
 			</label>
 		</p>
+
 		<p class="control is-expanded checkbox-control">
-			<input
-				type="checkbox"
-				id="anonymousSongRequests"
-				v-model="localAnonymousSongRequests"
-			/>
+			<label class="switch">
+				<input
+					type="checkbox"
+					id="anonymousSongRequests"
+					v-model="localAnonymousSongRequests"
+				/>
+				<span class="slider round"></span>
+			</label>
+
 			<label for="anonymousSongRequests">
 				<span></span>
 				<p>Request songs anonymously</p>
 			</label>
 		</p>
+
 		<p class="control is-expanded checkbox-control">
-			<input
-				type="checkbox"
-				id="activityWatch"
-				v-model="localActivityWatch"
-			/>
+			<label class="switch">
+				<input
+					type="checkbox"
+					id="activityWatch"
+					v-model="localActivityWatch"
+				/>
+				<span class="slider round"></span>
+			</label>
+
 			<label for="activityWatch">
 				<span></span>
 				<p>Use ActivityWatch integration (requires extension)</p>
@@ -169,41 +194,62 @@ export default {
 
 <style lang="scss" scoped>
 .checkbox-control {
-	input[type="checkbox"] {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+
+	p {
+		margin-left: 10px;
+	}
+
+	.switch {
+		position: relative;
+		display: inline-block;
+		flex-shrink: 0;
+		width: 40px;
+		height: 24px;
+	}
+
+	.switch input {
 		opacity: 0;
-		position: absolute;
+		width: 0;
+		height: 0;
 	}
 
-	label {
-		display: flex;
-		flex-direction: row;
-		align-items: center;
-
-		span {
-			cursor: pointer;
-			width: 24px;
-			height: 24px;
-			background-color: var(--white);
-			display: inline-block;
-			border: 1px solid var(--dark-grey-2);
-			position: relative;
-			border-radius: 3px;
-		}
-
-		p {
-			margin-left: 10px;
-		}
+	.slider {
+		position: absolute;
+		cursor: pointer;
+		top: 0;
+		left: 0;
+		right: 0;
+		bottom: 0;
+		background-color: #ccc;
+		transition: 0.2s;
+		border-radius: 34px;
 	}
 
-	input[type="checkbox"]:checked + label span::after {
+	.slider:before {
+		position: absolute;
 		content: "";
-		width: 18px;
-		height: 18px;
-		left: 2px;
-		top: 2px;
-		border-radius: 3px;
+		height: 16px;
+		width: 16px;
+		left: 4px;
+		bottom: 4px;
+		background-color: white;
+		transition: 0.2s;
+		border-radius: 50%;
+	}
+
+	input:checked + .slider {
 		background-color: var(--primary-color);
-		position: absolute;
+	}
+
+	input:focus + .slider {
+		box-shadow: 0 0 1px var(--primary-color);
+	}
+
+	input:checked + .slider:before {
+		transform: translateX(16px);
 	}
 }
 </style>

+ 10 - 2
frontend/src/pages/Settings/index.vue

@@ -154,6 +154,7 @@ export default {
 	#page-title {
 		margin-top: 0;
 		font-size: 35px;
+		text-align: center;
 	}
 
 	#sidebar-with-content {
@@ -161,7 +162,7 @@ export default {
 		flex-direction: column;
 	}
 
-	@media only screen and (min-width: 900px) {
+	@media only screen and (min-width: 700px) {
 		#page-title {
 			margin: 0;
 			font-size: 40px;
@@ -169,10 +170,16 @@ export default {
 
 		#sidebar-with-content {
 			width: 962px;
+			max-width: 100%;
 			margin: 0 auto;
 			margin-top: 30px;
 			flex-direction: row;
 
+			.nav-links {
+				margin-left: 0;
+				margin-right: 64px;
+			}
+
 			.content {
 				width: 600px;
 				margin-top: 0px !important;
@@ -182,7 +189,8 @@ export default {
 
 	.nav-links {
 		width: 250px;
-		margin-right: 64px;
+		margin-left: auto;
+		margin-right: auto;
 
 		a {
 			outline: none;

+ 4 - 8
frontend/src/pages/Station/index.vue

@@ -55,15 +55,11 @@
 			<li></li>
 		</ul>
 
-		<div v-show="!loading">
-			<main-header v-if="exists" />
+		<div v-show="!loading && exists">
+			<main-header />
 
-			<div
-				id="station-outer-container"
-				:style="[!exists ? { margin: 0, padding: 0 } : {}]"
-			>
+			<div id="station-outer-container">
 				<div
-					v-show="exists"
 					id="station-inner-container"
 					:class="{ 'nothing-here': noSong }"
 				>
@@ -570,7 +566,7 @@
 				<report v-if="modals.report" />
 			</div>
 
-			<main-footer v-if="exists" />
+			<main-footer />
 		</div>
 
 		<edit-song v-if="modals.editSong" song-type="songs" sector="station" />