Browse Source

refactor: new design for Edit Playlist modal

Signed-off-by: Jonathan <theflametrooper@gmail.com>
Jonathan 4 years ago
parent
commit
813f3e5182

+ 6 - 3
backend/logic/actions/playlists.js

@@ -866,14 +866,17 @@ export default {
 						.catch(next);
 						.catch(next);
 				},
 				},
 				(position, next) => {
 				(position, next) => {
-					SongsModule.runJob("GET_SONG", { id: songId }, this)
-						.then(response => {
-							const { song } = response;
+					SongsModule.runJob("GET_SONG_FROM_ID", { songId }, this)
+						.then(res => {
+							const { song } = res;
+
 							next(null, {
 							next(null, {
 								_id: song._id,
 								_id: song._id,
 								songId,
 								songId,
 								title: song.title,
 								title: song.title,
 								duration: song.duration,
 								duration: song.duration,
+								thumbnail: song.thumbnail,
+								artists: song.artists,
 								position
 								position
 							});
 							});
 						})
 						})

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

@@ -6,6 +6,8 @@ export default {
 			songId: { type: String },
 			songId: { type: String },
 			title: { type: String },
 			title: { type: String },
 			duration: { type: Number },
 			duration: { type: Number },
+			thumbnail: { type: String, required: false },
+			artists: { type: Array, required: false },
 			position: { type: Number }
 			position: { type: Number }
 		}
 		}
 	],
 	],

+ 1 - 3
backend/logic/songs.js

@@ -116,9 +116,7 @@ class _SongsModule extends CoreClass {
 					next => {
 					next => {
 						if (!mongoose.Types.ObjectId.isValid(payload.id)) return next("Id is not a valid ObjectId.");
 						if (!mongoose.Types.ObjectId.isValid(payload.id)) return next("Id is not a valid ObjectId.");
 						return CacheModule.runJob("HGET", { table: "songs", key: payload.id }, this)
 						return CacheModule.runJob("HGET", { table: "songs", key: payload.id }, this)
-							.then(song => {
-								next(null, song);
-							})
+							.then(song => next(null, song))
 							.catch(next);
 							.catch(next);
 					},
 					},
 
 

+ 1 - 0
backend/logic/youtube.js

@@ -99,6 +99,7 @@ class _YouTubeModule extends CoreClass {
 					const song = {
 					const song = {
 						songId: body.items[0].id,
 						songId: body.items[0].id,
 						title: body.items[0].snippet.title,
 						title: body.items[0].snippet.title,
+						thumbnail: body.items[0].snippet.thumbnails.default.url,
 						duration
 						duration
 					};
 					};
 
 

+ 16 - 2
frontend/src/App.vue

@@ -510,7 +510,9 @@ button.delete:focus {
 		margin-right: 0px !important;
 		margin-right: 0px !important;
 	}
 	}
 
 
-	input {
+	input,
+	select {
+		width: 100%;
 		height: 36px;
 		height: 36px;
 		border-radius: 3px 0 0 3px;
 		border-radius: 3px 0 0 3px;
 		border-right: 0;
 		border-right: 0;
@@ -580,6 +582,13 @@ h4.section-title {
 	border: 1px solid $light-grey-2;
 	border: 1px solid $light-grey-2;
 	border-radius: 3px;
 	border-radius: 3px;
 
 
+	.item-thumbnail {
+		width: 65px;
+		height: 65px;
+		margin: -7.5px;
+		border-radius: 3px 0 0 3px;
+	}
+
 	.item-title {
 	.item-title {
 		font-size: 20px;
 		font-size: 20px;
 		overflow: hidden;
 		overflow: hidden;
@@ -604,8 +613,13 @@ h4.section-title {
 			flex-wrap: wrap;
 			flex-wrap: wrap;
 		}
 		}
 
 
+		.button {
+			width: 146px;
+		}
+
 		i {
 		i {
 			cursor: pointer;
 			cursor: pointer;
+			color: #4a4a4a;
 
 
 			&:hover,
 			&:hover,
 			&:focus {
 			&:focus {
@@ -630,7 +644,7 @@ h4.section-title {
 		}
 		}
 
 
 		.stop-icon,
 		.stop-icon,
-		.remove-from-queue-icon {
+		.delete-icon {
 			color: $red;
 			color: $red;
 		}
 		}
 
 

+ 0 - 544
frontend/src/components/modals/EditPlaylist.vue

@@ -1,544 +0,0 @@
-<template>
-	<modal title="Edit Playlist">
-		<div slot="body">
-			<nav class="level">
-				<div class="level-item has-text-centered">
-					<div>
-						<p class="heading">Total Length</p>
-						<p class="title">
-							{{ totalLength() }}
-						</p>
-					</div>
-				</div>
-			</nav>
-			<hr />
-			<aside class="menu">
-				<draggable
-					class="menu-list scrollable-list"
-					tag="ul"
-					v-if="playlist.songs.length > 0"
-					v-model="playlist.songs"
-					v-bind="dragOptions"
-					@start="drag = true"
-					@end="drag = false"
-					@change="updateSongPositioning"
-				>
-					<transition-group
-						type="transition"
-						:name="!drag ? 'draggable-list-transition' : null"
-					>
-						<li
-							v-for="(song, index) in playlist.songs"
-							:key="'key-' + index"
-						>
-							<a href="#" target="_blank"
-								>({{ song.position }}) {{ song.title }}</a
-							>
-							<div
-								class="controls"
-								v-if="playlist.isUserModifiable"
-							>
-								<a href="#" @click="moveSongToTop(index)">
-									<i class="material-icons" v-if="index > 0"
-										>keyboard_arrow_up</i
-									>
-									<i
-										v-else
-										class="material-icons"
-										style="opacity: 0"
-										>error</i
-									>
-								</a>
-								<a href="#" @click="moveSongToBottom(index)">
-									<i
-										v-if="
-											playlist.songs.length - 1 !== index
-										"
-										class="material-icons"
-										>keyboard_arrow_down</i
-									>
-									<i
-										v-else
-										class="material-icons"
-										style="opacity: 0"
-										>error</i
-									>
-								</a>
-								<a
-									href="#"
-									@click="removeSongFromPlaylist(song.songId)"
-								>
-									<i class="material-icons">delete</i>
-								</a>
-							</div>
-						</li>
-					</transition-group>
-				</draggable>
-				<br />
-			</aside>
-			<div class="control is-grouped" v-if="playlist.isUserModifiable">
-				<p class="control is-expanded">
-					<input
-						v-model="searchSongQuery"
-						class="input"
-						type="text"
-						placeholder="Search for Song to add"
-						autofocus
-						@keyup.enter="searchForSongs()"
-					/>
-				</p>
-				<p class="control">
-					<a class="button is-info" @click="searchForSongs()" href="#"
-						>Search</a
-					>
-				</p>
-			</div>
-			<table
-				v-if="songQueryResults.length > 0 && playlist.isUserModifiable"
-				class="table"
-			>
-				<tbody>
-					<tr
-						v-for="(result, index) in songQueryResults"
-						:key="index"
-					>
-						<td>
-							<img :src="result.thumbnail" />
-						</td>
-						<td>{{ result.title }}</td>
-						<td>
-							<a
-								class="button is-success"
-								href="#"
-								@click="addSongToPlaylist(result.id)"
-								>Add</a
-							>
-						</td>
-					</tr>
-				</tbody>
-			</table>
-			<div class="control is-grouped" v-if="playlist.isUserModifiable">
-				<p class="control is-expanded">
-					<input
-						v-model="directSongQuery"
-						class="input"
-						type="text"
-						placeholder="Enter a YouTube id or URL directly"
-						autofocus
-						@keyup.enter="addSong()"
-					/>
-				</p>
-				<p class="control">
-					<a class="button is-info" @click="addSong()" href="#"
-						>Add</a
-					>
-				</p>
-			</div>
-			<div class="control is-grouped" v-if="playlist.isUserModifiable">
-				<p class="control is-expanded">
-					<input
-						v-model="importQuery"
-						class="input"
-						type="text"
-						placeholder="YouTube Playlist URL"
-						@keyup.enter="importPlaylist(false)"
-					/>
-				</p>
-				<p class="control">
-					<a
-						class="button is-info"
-						@click="importPlaylist(true)"
-						href="#"
-						>Import music</a
-					>
-				</p>
-				<p class="control">
-					<a
-						class="button is-info"
-						@click="importPlaylist(false)"
-						href="#"
-						>Import all</a
-					>
-				</p>
-			</div>
-			<button
-				class="button is-info"
-				@click="shuffle()"
-				v-if="playlist.isUserModifiable"
-			>
-				Shuffle
-			</button>
-			<h5>Edit playlist details:</h5>
-			<div class="control is-grouped" v-if="playlist.isUserModifiable">
-				<p class="control is-expanded">
-					<input
-						v-model="playlist.displayName"
-						class="input"
-						type="text"
-						placeholder="Playlist Display Name"
-						@keyup.enter="renamePlaylist()"
-					/>
-				</p>
-				<p class="control">
-					<a class="button is-info" @click="renamePlaylist()" href="#"
-						>Rename</a
-					>
-				</p>
-			</div>
-			<div class="control is-grouped">
-				<div class="control select">
-					<select v-model="playlist.privacy">
-						<option value="private">Private</option>
-						<option value="public">Public</option>
-					</select>
-				</div>
-				<p class="control">
-					<a class="button is-info" @click="updatePrivacy()" href="#"
-						>Update Privacy</a
-					>
-				</p>
-			</div>
-		</div>
-		<div slot="footer" v-if="playlist.isUserModifiable">
-			<a class="button is-danger" @click="removePlaylist()" href="#"
-				>Remove Playlist</a
-			>
-		</div>
-	</modal>
-</template>
-
-<script>
-import { mapState, mapActions } from "vuex";
-import draggable from "vuedraggable";
-
-import Toast from "toasters";
-import Modal from "../Modal.vue";
-import io from "../../io";
-import validation from "../../validation";
-import utils from "../../../js/utils";
-
-export default {
-	components: { Modal, draggable },
-	data() {
-		return {
-			utils,
-			drag: false,
-			playlist: { songs: [] },
-			songQueryResults: [],
-			searchSongQuery: "",
-			directSongQuery: "",
-			importQuery: ""
-		};
-	},
-	computed: {
-		...mapState("user/playlists", {
-			editing: state => state.editing
-		}),
-		dragOptions() {
-			return {
-				animation: 200,
-				group: "description",
-				disabled: !this.playlist.isUserModifiable,
-				ghostClass: "draggable-list-ghost"
-			};
-		}
-	},
-	mounted() {
-		io.getSocket(socket => {
-			this.socket = socket;
-
-			this.socket.emit("playlists.getPlaylist", this.editing, res => {
-				if (res.status === "success") {
-					this.playlist = res.data;
-					this.playlist.songs.sort((a, b) => a.position - b.position);
-				}
-				this.playlist.oldId = res.data._id;
-			});
-
-			this.socket.on("event:playlist.addSong", data => {
-				if (this.playlist._id === data.playlistId)
-					this.playlist.songs.push(data.song);
-			});
-
-			this.socket.on("event:playlist.removeSong", data => {
-				if (this.playlist._id === data.playlistId) {
-					this.playlist.songs.forEach((song, index) => {
-						if (song.songId === data.songId)
-							this.playlist.songs.splice(index, 1);
-					});
-				}
-			});
-
-			this.socket.on("event:playlist.updateDisplayName", data => {
-				if (this.playlist._id === data.playlistId)
-					this.playlist.displayName = data.displayName;
-			});
-
-			this.socket.on("event:playlist.repositionSongs", data => {
-				if (this.playlist._id === data.playlistId) {
-					// for each song that has a new position
-					data.songsBeingChanged.forEach(changedSong => {
-						this.playlist.songs.forEach((song, index) => {
-							// find song locally
-							if (song.songId === changedSong.songId) {
-								// 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]
-									);
-							}
-						});
-					});
-				}
-			});
-		});
-	},
-	methods: {
-		updateSongPositioning({ 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({
-						songId: song.songId,
-						position: index + 1
-					});
-			});
-
-			this.socket.emit(
-				"playlists.repositionSongs",
-				this.playlist._id,
-				songsBeingChanged,
-				res => {
-					new Toast({ content: res.message, timeout: 4000 });
-				}
-			);
-		},
-		totalLength() {
-			let length = 0;
-			this.playlist.songs.forEach(song => {
-				length += song.duration;
-			});
-			return this.utils.formatTimeLong(length);
-		},
-		searchForSongs() {
-			let query = this.searchSongQuery;
-			if (query.indexOf("&index=") !== -1) {
-				query = query.split("&index=");
-				query.pop();
-				query = query.join("");
-			}
-			if (query.indexOf("&list=") !== -1) {
-				query = query.split("&list=");
-				query.pop();
-				query = query.join("");
-			}
-			this.socket.emit("apis.searchYoutube", query, res => {
-				if (res.status === "success") {
-					this.songQueryResults = [];
-					for (let i = 0; i < res.data.items.length; i += 1) {
-						this.songQueryResults.push({
-							id: res.data.items[i].id.videoId,
-							url: `https://www.youtube.com/watch?v=${this.id}`,
-							title: res.data.items[i].snippet.title,
-							thumbnail:
-								res.data.items[i].snippet.thumbnails.default.url
-						});
-					}
-				} else if (res.status === "error")
-					new Toast({ content: res.message, timeout: 3000 });
-			});
-		},
-		addSongToPlaylist(id) {
-			this.socket.emit(
-				"playlists.addSongToPlaylist",
-				false,
-				id,
-				this.playlist._id,
-				res => {
-					new Toast({ content: res.message, timeout: 4000 });
-				}
-			);
-		},
-		/* eslint-disable prefer-destructuring */
-		addSong() {
-			let id = "";
-
-			if (this.directSongQuery.length === 11) id = this.directSongQuery;
-			else {
-				const match = this.directSongQuery.match("v=([0-9A-Za-z_-]+)");
-				if (match.length > 0) id = match[1];
-			}
-
-			this.addSongToPlaylist(id);
-		},
-		/* eslint-enable prefer-destructuring */
-		shuffle() {
-			this.socket.emit("playlists.shuffle", this.playlist._id, res => {
-				new Toast({ content: res.message, timeout: 4000 });
-				if (res.status === "success") {
-					this.playlist.songs = res.data.songs.sort(
-						(a, b) => a.position - b.position
-					);
-				}
-			});
-		},
-		importPlaylist(musicOnly) {
-			new Toast({
-				content:
-					"Starting to import your playlist. This can take some time to do.",
-				timeout: 4000
-			});
-			this.socket.emit(
-				"playlists.addSetToPlaylist",
-				this.importQuery,
-				this.playlist._id,
-				musicOnly,
-				res => {
-					new Toast({ content: res.message, timeout: 20000 });
-					if (res.status === "success") {
-						if (musicOnly) {
-							new Toast({
-								content: `${res.stats.songsInPlaylistTotal} of the ${res.stats.videosInPlaylistTotal} videos in the playlist were songs.`,
-								timeout: 20000
-							});
-						}
-					}
-				}
-			);
-		},
-		removeSongFromPlaylist(id) {
-			this.socket.emit(
-				"playlists.removeSongFromPlaylist",
-				id,
-				this.playlist._id,
-				res => {
-					new Toast({ content: res.message, timeout: 4000 });
-				}
-			);
-		},
-		renamePlaylist() {
-			const { displayName } = this.playlist;
-			if (!validation.isLength(displayName, 2, 32))
-				return new Toast({
-					content:
-						"Display name must have between 2 and 32 characters.",
-					timeout: 8000
-				});
-			if (!validation.regex.ascii.test(displayName))
-				return new Toast({
-					content:
-						"Invalid display name format. Only ASCII characters are allowed.",
-					timeout: 8000
-				});
-
-			return this.socket.emit(
-				"playlists.updateDisplayName",
-				this.playlist._id,
-				this.playlist.displayName,
-				res => {
-					new Toast({ content: res.message, timeout: 4000 });
-				}
-			);
-		},
-		removePlaylist() {
-			this.socket.emit("playlists.remove", this.playlist._id, res => {
-				new Toast({ content: res.message, timeout: 3000 });
-				if (res.status === "success") {
-					this.closeModal({
-						sector: "station",
-						modal: "editPlaylist"
-					});
-				}
-			});
-		},
-		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") {
-				this.socket.emit(
-					"playlists.updatePrivacy",
-					this.playlist._id,
-					privacy,
-					res => {
-						new Toast({ content: res.message, timeout: 4000 });
-					}
-				);
-			}
-		},
-		...mapActions("modalVisibility", ["closeModal"])
-	}
-};
-</script>
-
-<style lang="scss" scoped>
-@import "../../styles/global.scss";
-
-.menu {
-	padding: 0 20px;
-}
-
-.menu-list li {
-	display: flex;
-	justify-content: space-between;
-
-	a {
-		display: flex;
-		align-items: center;
-		cursor: move;
-
-		&:hover {
-			color: $black !important;
-		}
-	}
-}
-
-.controls {
-	display: flex;
-
-	a {
-		display: flex;
-		align-items: center;
-	}
-}
-
-.table {
-	margin-bottom: 0;
-}
-
-h5 {
-	padding: 20px 0;
-}
-
-.control.select {
-	flex-grow: 1;
-	select {
-		width: 100%;
-	}
-}
-</style>

+ 87 - 0
frontend/src/components/modals/EditPlaylist/components/PlaylistSongItem.vue

@@ -0,0 +1,87 @@
+<template>
+	<div class="universal-item playlist-song-item">
+		<div id="thumbnail-and-info">
+			<img class="item-thumbnail" :src="song.thumbnail" />
+			<div id="song-info">
+				<h4 class="item-title" :title="song.title">
+					{{ song.title }}
+				</h4>
+				<h5
+					class="item-description"
+					v-if="song.artists"
+					:style="
+						song.artists.length < 1 ? { fontSize: '16px' } : null
+					"
+					:title="song.artists.join(', ')"
+				>
+					{{ song.artists.join(", ") }}
+				</h5>
+			</div>
+		</div>
+		<div class="universal-item-actions">
+			<slot name="actions" />
+		</div>
+	</div>
+</template>
+
+<script>
+export default {
+	props: {
+		song: {
+			type: Object,
+			default: () => {}
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../../../../styles/global.scss";
+
+.night-mode {
+	.playlist-song-item {
+		background-color: $night-mode-bg-secondary !important;
+		border: 0 !important;
+	}
+}
+
+.playlist-song-item {
+	width: 100%;
+
+	#thumbnail-and-info,
+	.universal-item-actions div {
+		display: flex;
+		align-items: center;
+	}
+
+	.universal-item-actions {
+		margin-left: 5px;
+	}
+
+	.item-thumbnail {
+		width: 55px;
+		height: 55px;
+	}
+
+	#thumbnail-and-info {
+		width: calc(100% - 160px);
+	}
+
+	#song-info {
+		display: flex;
+		flex-direction: column;
+		justify-content: center;
+		margin-left: 20px;
+		width: calc(100% - 65px);
+
+		.item-title {
+			font-size: 16px;
+		}
+
+		*:not(i) {
+			margin: 0;
+			font-family: Karla, Arial, sans-serif;
+		}
+	}
+}
+</style>

+ 797 - 0
frontend/src/components/modals/EditPlaylist/index.vue

@@ -0,0 +1,797 @@
+<template>
+	<modal title="Edit Playlist" class="edit-playlist-modal">
+		<div slot="body">
+			<div
+				:class="{
+					'view-only': !playlist.isUserModifiable,
+					'edit-playlist-modal-inner-container': true
+				}"
+			>
+				<div id="first-column">
+					<div id="playlist-info-section" class="section">
+						<h3>{{ playlist.displayName }}</h3>
+						<h5>Duration: {{ totalLength() }}</h5>
+					</div>
+
+					<div class="section-margin-bottom" />
+
+					<div id="playlist-settings-section" class="section">
+						<div v-if="playlist.isUserModifiable">
+							<h4 class="section-title">Edit Details</h4>
+
+							<p class="section-description">
+								Change the display name and privacy of the
+								playlist.
+							</p>
+
+							<hr class="section-horizontal-rule" />
+
+							<label class="label">
+								Change display name
+							</label>
+
+							<div class="control is-grouped input-with-button">
+								<p class="control is-expanded">
+									<input
+										v-model="playlist.displayName"
+										class="input"
+										type="text"
+										placeholder="Playlist Display Name"
+										@keyup.enter="renamePlaylist()"
+									/>
+								</p>
+								<p class="control">
+									<a
+										class="button is-info"
+										@click="renamePlaylist()"
+										href="#"
+										>Rename</a
+									>
+								</p>
+							</div>
+						</div>
+
+						<label class="label">
+							Change privacy
+						</label>
+						<div class="control is-grouped input-with-button">
+							<div class="control is-expanded select">
+								<select v-model="playlist.privacy">
+									<option value="private">Private</option>
+									<option value="public">Public</option>
+								</select>
+							</div>
+							<p class="control">
+								<a
+									class="button is-info"
+									@click="updatePrivacy()"
+									href="#"
+									>Update Privacy</a
+								>
+							</p>
+						</div>
+					</div>
+
+					<div class="section-margin-bottom" />
+
+					<div
+						id="import-from-youtube-section"
+						class="section"
+						v-if="playlist.isUserModifiable"
+					>
+						<h4 class="section-title">Import from YouTube</h4>
+
+						<p class="section-description">
+							Import a playlist or song by searching or using a
+							link from YouTube.
+						</p>
+
+						<hr class="section-horizontal-rule" />
+
+						<label class="label">
+							Search for a playlist from YouTube
+						</label>
+						<div class="control is-grouped input-with-button">
+							<p class="control is-expanded">
+								<input
+									class="input"
+									type="text"
+									placeholder="Enter YouTube Playlist URL here..."
+									v-model="importQuery"
+									@keyup.enter="importPlaylist()"
+								/>
+							</p>
+							<p class="control has-addons">
+								<span class="select" id="playlist-import-type">
+									<select
+										v-model="isImportingOnlyMusicOfPlaylist"
+									>
+										<option :value="false"
+											>Import all</option
+										>
+										<option :value="true"
+											>Import only music</option
+										>
+									</select>
+								</span>
+								<a
+									class="button is-info"
+									@click="importPlaylist()"
+									href="#"
+									><i class="material-icons icon-with-button"
+										>publish</i
+									>Import</a
+								>
+							</p>
+						</div>
+
+						<label class="label">
+							Search for a song from YouTube
+						</label>
+						<div class="control is-grouped input-with-button">
+							<p class="control is-expanded">
+								<input
+									class="input"
+									type="text"
+									placeholder="Enter your YouTube query here..."
+									v-model="searchSongQuery"
+									autofocus
+									@keyup.enter="searchForSongs()"
+								/>
+							</p>
+							<p class="control">
+								<a
+									class="button is-info"
+									@click="searchForSongs()"
+									href="#"
+									><i class="material-icons icon-with-button"
+										>search</i
+									>Search</a
+								>
+							</p>
+						</div>
+
+						<div id="song-query-results">
+							<search-query-item
+								v-for="(result, index) in queryResults"
+								:key="index"
+								:result="result"
+							>
+								<div slot="actions">
+									<transition
+										name="search-query-actions"
+										mode="out-in"
+									>
+										<a
+											class="button is-success"
+											v-if="result.isAddedToQueue"
+											href="#"
+											key="added-to-playlist"
+										>
+											<i
+												class="material-icons icon-with-button"
+												>done</i
+											>
+											Added to playlist
+										</a>
+										<a
+											class="button is-dark"
+											v-else
+											@click="
+												addSongToPlaylist(
+													result.id,
+													index
+												)
+											"
+											href="#"
+											key="add-to-playlist"
+										>
+											<i
+												class="material-icons icon-with-button"
+												>add</i
+											>
+											Add to playlist
+										</a>
+									</transition>
+								</div>
+							</search-query-item>
+						</div>
+
+						<div class="section-margin-bottom" />
+					</div>
+				</div>
+
+				<div id="second-column">
+					<div id="rearrange-songs-section" class="section">
+						<div v-if="playlist.isUserModifiable">
+							<h4 class="section-title">Rearrange Songs</h4>
+
+							<p class="section-description">
+								Drag and drop songs to change their order
+							</p>
+
+							<hr class="section-horizontal-rule" />
+						</div>
+
+						<aside class="menu">
+							<draggable
+								class="menu-list scrollable-list"
+								tag="ul"
+								v-if="playlist.songs.length > 0"
+								v-model="playlist.songs"
+								v-bind="dragOptions"
+								@start="drag = true"
+								@end="drag = false"
+								@change="updateSongPositioning"
+							>
+								<transition-group
+									type="transition"
+									:name="
+										!drag
+											? 'draggable-list-transition'
+											: null
+									"
+								>
+									<li
+										v-for="(song, index) in playlist.songs"
+										:key="'key-' + index"
+									>
+										<playlist-song-item
+											:song="song"
+											:class="{
+												'item-draggable':
+													playlist.isUserModifiable
+											}"
+										>
+											<div
+												slot="actions"
+												v-if="playlist.isUserModifiable"
+											>
+												<i
+													class="material-icons"
+													v-if="index > 0"
+													@click="
+														moveSongToTop(index)
+													"
+													>vertical_align_top</i
+												>
+												<i
+													v-else
+													class="material-icons"
+													style="opacity: 0"
+													>error</i
+												>
+
+												<i
+													v-if="
+														playlist.songs.length -
+															1 !==
+															index
+													"
+													@click="
+														moveSongToBottom(index)
+													"
+													class="material-icons"
+													>vertical_align_bottom</i
+												>
+												<i
+													v-else
+													class="material-icons"
+													style="opacity: 0"
+													>error</i
+												>
+
+												<i
+													@click="
+														removeSongFromPlaylist(
+															song.songId
+														)
+													"
+													class="material-icons delete-icon"
+													>delete</i
+												>
+											</div>
+										</playlist-song-item>
+									</li>
+								</transition-group>
+							</draggable>
+						</aside>
+					</div>
+				</div>
+
+				<!--
+			
+			
+			<button
+				class="button is-info"
+				@click="shuffle()"
+				v-if="playlist.isUserModifiable"
+			>
+				Shuffle
+			</button>
+			<h5>Edit playlist details:</h5>
+			 -->
+			</div>
+		</div>
+		<div slot="footer">
+			<a
+				class="button is-danger"
+				@click="removePlaylist()"
+				href="#"
+				v-if="playlist.isUserModifiable"
+			>
+				Remove Playlist
+			</a>
+			<a
+				class="button is-default"
+				@click="
+					closeModal({
+						sector: 'station',
+						modal: 'editPlaylist'
+					})
+				"
+				href="#"
+				>Close Modal</a
+			>
+		</div>
+	</modal>
+</template>
+
+<script>
+import { mapState, mapActions } from "vuex";
+import draggable from "vuedraggable";
+import Toast from "toasters";
+
+import Modal from "../../Modal.vue";
+import SearchQueryItem from "../../ui/SearchQueryItem.vue";
+import PlaylistSongItem from "./components/PlaylistSongItem.vue";
+
+import io from "../../../io";
+import validation from "../../../validation";
+import utils from "../../../../js/utils";
+
+export default {
+	components: { Modal, draggable, SearchQueryItem, PlaylistSongItem },
+	data() {
+		return {
+			utils,
+			drag: false,
+			playlist: { songs: [] },
+			queryResults: [],
+			searchSongQuery: "",
+			directSongQuery: "",
+			importQuery: "",
+			isImportingOnlyMusicOfPlaylist: true
+		};
+	},
+	computed: {
+		...mapState("user/playlists", {
+			editing: state => state.editing
+		}),
+		dragOptions() {
+			return {
+				animation: 200,
+				group: "description",
+				disabled: !this.playlist.isUserModifiable,
+				ghostClass: "draggable-list-ghost"
+			};
+		}
+	},
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+
+			this.socket.emit("playlists.getPlaylist", this.editing, res => {
+				if (res.status === "success") {
+					this.playlist = res.data;
+					this.playlist.songs.sort((a, b) => a.position - b.position);
+				}
+				this.playlist.oldId = res.data._id;
+			});
+
+			this.socket.on("event:playlist.addSong", data => {
+				if (this.playlist._id === data.playlistId)
+					this.playlist.songs.push(data.song);
+			});
+
+			this.socket.on("event:playlist.removeSong", data => {
+				if (this.playlist._id === data.playlistId) {
+					this.playlist.songs.forEach((song, index) => {
+						if (song.songId === data.songId)
+							this.playlist.songs.splice(index, 1);
+					});
+				}
+			});
+
+			this.socket.on("event:playlist.updateDisplayName", data => {
+				if (this.playlist._id === data.playlistId)
+					this.playlist.displayName = data.displayName;
+			});
+
+			this.socket.on("event:playlist.repositionSongs", data => {
+				if (this.playlist._id === data.playlistId) {
+					// for each song that has a new position
+					data.songsBeingChanged.forEach(changedSong => {
+						this.playlist.songs.forEach((song, index) => {
+							// find song locally
+							if (song.songId === changedSong.songId) {
+								// 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]
+									);
+							}
+						});
+					});
+				}
+			});
+		});
+	},
+	methods: {
+		updateSongPositioning({ 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({
+						songId: song.songId,
+						position: index + 1
+					});
+			});
+
+			this.socket.emit(
+				"playlists.repositionSongs",
+				this.playlist._id,
+				songsBeingChanged,
+				res => {
+					new Toast({ content: res.message, timeout: 4000 });
+				}
+			);
+		},
+		totalLength() {
+			let length = 0;
+			this.playlist.songs.forEach(song => {
+				length += song.duration;
+			});
+			return this.utils.formatTimeLong(length);
+		},
+		searchForSongs() {
+			let query = this.searchSongQuery;
+			if (query.indexOf("&index=") !== -1) {
+				query = query.split("&index=");
+				query.pop();
+				query = query.join("");
+			}
+			if (query.indexOf("&list=") !== -1) {
+				query = query.split("&list=");
+				query.pop();
+				query = query.join("");
+			}
+			this.socket.emit("apis.searchYoutube", query, res => {
+				if (res.status === "success") {
+					this.queryResults = [];
+					for (let i = 0; i < res.data.items.length; i += 1) {
+						this.queryResults.push({
+							id: res.data.items[i].id.videoId,
+							url: `https://www.youtube.com/watch?v=${this.id}`,
+							title: res.data.items[i].snippet.title,
+							thumbnail:
+								res.data.items[i].snippet.thumbnails.default
+									.url,
+							isAddedToQueue: false
+						});
+					}
+				} else if (res.status === "error")
+					new Toast({ content: res.message, timeout: 3000 });
+			});
+		},
+		shuffle() {
+			this.socket.emit("playlists.shuffle", this.playlist._id, res => {
+				new Toast({ content: res.message, timeout: 4000 });
+				if (res.status === "success") {
+					this.playlist.songs = res.data.songs.sort(
+						(a, b) => a.position - b.position
+					);
+				}
+			});
+		},
+		importPlaylist() {
+			let isImportingPlaylist = true;
+
+			// don't give starting import message instantly in case of instant error
+			setTimeout(() => {
+				if (isImportingPlaylist) {
+					new Toast({
+						content:
+							"Starting to import your playlist. This can take some time to do.",
+						timeout: 4000
+					});
+				}
+			}, 750);
+
+			this.socket.emit(
+				"playlists.addSetToPlaylist",
+				this.importQuery,
+				this.playlist._id,
+				this.isImportingOnlyMusicOfPlaylist,
+				res => {
+					new Toast({ content: res.message, timeout: 20000 });
+					if (res.status === "success") {
+						isImportingPlaylist = false;
+						if (this.isImportingOnlyMusicOfPlaylist) {
+							new Toast({
+								content: `${res.stats.songsInPlaylistTotal} of the ${res.stats.videosInPlaylistTotal} videos in the playlist were songs.`,
+								timeout: 20000
+							});
+						}
+					}
+				}
+			);
+		},
+		addSongToPlaylist(id, index) {
+			this.socket.emit(
+				"playlists.addSongToPlaylist",
+				false,
+				id,
+				this.playlist._id,
+				res => {
+					new Toast({ content: res.message, timeout: 4000 });
+					if (res.status === "success")
+						this.queryResults[index].isAddedToQueue = true;
+				}
+			);
+		},
+		removeSongFromPlaylist(id) {
+			this.socket.emit(
+				"playlists.removeSongFromPlaylist",
+				id,
+				this.playlist._id,
+				res => {
+					new Toast({ content: res.message, timeout: 4000 });
+				}
+			);
+		},
+		renamePlaylist() {
+			const { displayName } = this.playlist;
+			if (!validation.isLength(displayName, 2, 32))
+				return new Toast({
+					content:
+						"Display name must have between 2 and 32 characters.",
+					timeout: 8000
+				});
+			if (!validation.regex.ascii.test(displayName))
+				return new Toast({
+					content:
+						"Invalid display name format. Only ASCII characters are allowed.",
+					timeout: 8000
+				});
+
+			return this.socket.emit(
+				"playlists.updateDisplayName",
+				this.playlist._id,
+				this.playlist.displayName,
+				res => {
+					new Toast({ content: res.message, timeout: 4000 });
+				}
+			);
+		},
+		removePlaylist() {
+			this.socket.emit("playlists.remove", this.playlist._id, res => {
+				new Toast({ content: res.message, timeout: 3000 });
+				if (res.status === "success") {
+					this.closeModal({
+						sector: "station",
+						modal: "editPlaylist"
+					});
+				}
+			});
+		},
+		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") {
+				this.socket.emit(
+					"playlists.updatePrivacy",
+					this.playlist._id,
+					privacy,
+					res => {
+						new Toast({ content: res.message, timeout: 4000 });
+					}
+				);
+			}
+		},
+		...mapActions("modalVisibility", ["closeModal"])
+	}
+};
+</script>
+
+<style lang="scss">
+.edit-playlist-modal {
+	.modal-card {
+		width: 1300px;
+
+		.modal-card-body {
+			padding: 16px;
+		}
+	}
+
+	.modal-card-foot {
+		justify-content: flex-end;
+	}
+}
+</style>
+
+<style lang="scss" scoped>
+@import "../../../styles/global.scss";
+
+.night-mode {
+	.section {
+		background-color: $night-mode-bg-secondary !important;
+	}
+
+	.label,
+	p,
+	strong {
+		color: $night-mode-text;
+	}
+}
+
+.menu {
+	max-height: 800px;
+	overflow: auto;
+}
+
+.menu-list li {
+	display: flex;
+	justify-content: space-between;
+
+	&:not(:last-of-type) {
+		margin-bottom: 10px;
+	}
+
+	a {
+		display: flex;
+	}
+}
+
+.controls {
+	display: flex;
+
+	a {
+		display: flex;
+		align-items: center;
+	}
+}
+
+@media screen and (max-width: 1300px) {
+	#import-from-youtube-section #song-query-results,
+	.section {
+		max-width: 100% !important;
+	}
+
+	#second-column {
+		width: 100%;
+
+		.section-margin-bottom {
+			display: block !important;
+		}
+	}
+}
+
+.edit-playlist-modal {
+	.edit-playlist-modal-inner-container {
+		display: flex;
+		flex-wrap: wrap;
+		max-height: 950px;
+
+		/** playlist isn't able to modified */
+		&.view-only {
+			flex-direction: column;
+
+			.section {
+				max-width: 100%;
+			}
+		}
+	}
+
+	.section {
+		padding: 5px !important;
+		margin: 0 20px;
+		max-width: 600px;
+		display: flex;
+		flex-direction: column;
+		flex-grow: 1;
+	}
+
+	.label {
+		font-size: 1rem;
+		font-weight: normal;
+	}
+
+	.input-with-button .button {
+		width: 150px;
+	}
+
+	#first-column {
+		flex-grow: 1;
+		max-width: 100%;
+
+		.section {
+			width: auto;
+		}
+
+		#playlist-info-section {
+			border: 1px solid $light-grey-2;
+			border-radius: 3px;
+			padding: 15px !important;
+
+			h3 {
+				font-weight: 600;
+				font-size: 30px;
+			}
+
+			h5 {
+				font-size: 18px;
+			}
+
+			h3,
+			h5 {
+				margin: 0;
+			}
+		}
+
+		#import-from-youtube-section {
+			#playlist-import-type select {
+				border-radius: 0;
+			}
+
+			#song-query-results {
+				padding: 10px;
+				margin-top: 10px;
+				height: 230px;
+				overflow: auto;
+				border: 1px solid $light-grey-2;
+				border-radius: 3px;
+				max-width: 565px;
+
+				.search-query-item:not(:last-of-type) {
+					margin-bottom: 10px;
+				}
+			}
+		}
+	}
+
+	#second-column {
+		max-width: 100%;
+
+		.section-margin-bottom {
+			display: none;
+		}
+	}
+}
+</style>

+ 0 - 2
frontend/src/components/modals/EditSong.vue

@@ -1409,8 +1409,6 @@ export default {
 </script>
 </script>
 
 
 <style lang="scss">
 <style lang="scss">
-@import "../../styles/global.scss";
-
 .song-modal {
 .song-modal {
 	.modal-card-title {
 	.modal-card-title {
 		text-align: center;
 		text-align: center;

+ 124 - 0
frontend/src/components/ui/SearchQueryItem.vue

@@ -0,0 +1,124 @@
+<template>
+	<div class="universal-item search-query-item">
+		<div id="thumbnail-and-info">
+			<img class="item-thumbnail" :src="result.thumbnail" />
+			<div id="song-info">
+				<h4 class="item-title" :title="result.title">
+					{{ result.title }}
+				</h4>
+
+				<!-- <p
+					id="song-request-time"
+					v-if="
+						station.type === 'community' &&
+							station.partyMode === true
+					"
+				>
+					Requested by
+					<strong>
+						<user-id-to-username
+							:user-id="song.requestedBy"
+							:link="true"
+						/>
+						{{
+							formatDistance(
+								parseISO(song.requestedAt),
+								new Date(),
+								{
+									addSuffix: true
+								}
+							)
+						}}
+					</strong>
+				</p> -->
+			</div>
+		</div>
+		<div class="universal-item-actions">
+			<slot name="actions" />
+		</div>
+	</div>
+</template>
+
+<script>
+export default {
+	props: {
+		result: {
+			type: Object,
+			default: () => {}
+		}
+	}
+};
+</script>
+
+<style lang="scss">
+.search-query-item .universal-item-actions i {
+	color: #fff !important;
+}
+
+.search-query-actions-enter-active {
+	transition: all 0.2s ease;
+}
+
+.search-query-actions-leave-active {
+	transition: all 0.2s cubic-bezier(1, 0.5, 0.8, 1);
+}
+
+.search-query-actions-enter {
+	transform: translateX(-20px);
+	opacity: 0;
+}
+
+.search-query-actions-leave-to {
+	transform: translateX(20px);
+	opacity: 0;
+}
+</style>
+
+<style lang="scss" scoped>
+@import "../../styles/global.scss";
+
+.night-mode {
+	.search-query-item {
+		background-color: $night-mode-bg-secondary !important;
+		border: 0 !important;
+	}
+}
+
+.search-query-item {
+	#thumbnail-and-info,
+	.universal-item-actions {
+		display: flex;
+		align-items: center;
+	}
+
+	.universal-item-actions {
+		margin-left: 5px;
+	}
+
+	.item-thumbnail {
+		width: 55px;
+		height: 55px;
+	}
+
+	#thumbnail-and-info {
+		width: calc(100% - 160px);
+	}
+
+	#song-info {
+		display: flex;
+		flex-direction: column;
+		justify-content: center;
+		margin-left: 20px;
+		width: calc(100% - 65px);
+
+		.item-title {
+			font-size: 16px;
+		}
+
+		*:not(i) {
+			margin: 0;
+			font-family: Karla, Arial, sans-serif;
+		}
+	}
+}
+</style>

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

@@ -59,7 +59,7 @@
 <script>
 <script>
 import { mapState, mapActions } from "vuex";
 import { mapState, mapActions } from "vuex";
 
 
-// import EditPlaylist from "../../../components/modals/EditPlaylist.vue";
+// import EditPlaylist from "../../../components/modals/EditPlaylist/index.vue";
 import UserIdToUsername from "../../../components/common/UserIdToUsername.vue";
 import UserIdToUsername from "../../../components/common/UserIdToUsername.vue";
 
 
 import io from "../../../io";
 import io from "../../../io";

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

@@ -250,7 +250,8 @@ export default {
 		MainFooter,
 		MainFooter,
 		PlaylistItem,
 		PlaylistItem,
 		CreatePlaylist: () => import("../components/modals/CreatePlaylist.vue"),
 		CreatePlaylist: () => import("../components/modals/CreatePlaylist.vue"),
-		EditPlaylist: () => import("../components/modals/EditPlaylist.vue"),
+		EditPlaylist: () =>
+			import("../components/modals/EditPlaylist/index.vue"),
 		draggable
 		draggable
 	},
 	},
 	mixins: [SortablePlaylists, TabQueryHandler],
 	mixins: [SortablePlaylists, TabQueryHandler],

+ 48 - 84
frontend/src/pages/Station/AddSongToQueue.vue

@@ -36,57 +36,44 @@
 
 
 				<!-- Choosing a song from youtube - query results -->
 				<!-- Choosing a song from youtube - query results -->
 
 
-				<table class="table" v-if="queryResults.length > 0">
-					<tbody>
-						<tr
-							v-for="(result, index) in queryResults"
-							:key="index"
-						>
-							<td class="song-thumbnail">
-								<div
-									:style="
-										`background-image: url('${result.thumbnail}'`
-									"
-								></div>
-							</td>
-							<td><strong v-html="result.title"></strong></td>
-							<td class="song-actions">
-								<transition
-									name="song-actions-transition"
-									mode="out-in"
+				<div id="song-query-results" v-if="queryResults.length > 0">
+					<search-query-item
+						v-for="(result, index) in queryResults"
+						:key="index"
+						:result="result"
+					>
+						<div slot="actions">
+							<transition
+								name="search-query-actions"
+								mode="out-in"
+							>
+								<a
+									class="button is-success"
+									v-if="result.isAddedToQueue"
+									href="#"
+									key="added-to-playlist"
 								>
 								>
-									<a
-										class="button is-success"
-										v-if="result.isAddedToQueue"
-										href="#"
-										key="added-to-queue"
+									<i class="material-icons icon-with-button"
+										>done</i
 									>
 									>
-										<i
-											class="material-icons icon-with-button"
-											>done</i
-										>
-										Added to queue
-									</a>
-									<a
-										class="button is-dark"
-										v-else
-										@click="
-											addSongToQueue(result.id, index)
-										"
-										href="#"
-										key="add-to-queue"
+									Added to queue
+								</a>
+								<a
+									class="button is-dark"
+									v-else
+									@click="addSongToQueue(result.id, index)"
+									href="#"
+									key="add-to-queue"
+								>
+									<i class="material-icons icon-with-button"
+										>add</i
 									>
 									>
-										<i
-											class="material-icons icon-with-button"
-											>add</i
-										>
-										Add to queue
-									</a>
-								</transition>
-							</td>
-						</tr>
-					</tbody>
-				</table>
+									Add to queue
+								</a>
+							</transition>
+						</div>
+					</search-query-item>
+				</div>
 
 
 				<!-- Import a playlist from youtube -->
 				<!-- Import a playlist from youtube -->
 
 
@@ -211,12 +198,13 @@ import { mapState, mapActions } from "vuex";
 import Toast from "toasters";
 import Toast from "toasters";
 
 
 import PlaylistItem from "../../components/ui/PlaylistItem.vue";
 import PlaylistItem from "../../components/ui/PlaylistItem.vue";
+import SearchQueryItem from "../../components/ui/SearchQueryItem.vue";
 import Modal from "../../components/Modal.vue";
 import Modal from "../../components/Modal.vue";
 
 
 import io from "../../io";
 import io from "../../io";
 
 
 export default {
 export default {
-	components: { Modal, PlaylistItem },
+	components: { Modal, PlaylistItem, SearchQueryItem },
 	data() {
 	data() {
 		return {
 		return {
 			querySearch: "",
 			querySearch: "",
@@ -380,15 +368,11 @@ export default {
 @import "../../styles/global.scss";
 @import "../../styles/global.scss";
 
 
 .night-mode {
 .night-mode {
-	tr {
-		background-color: $night-mode-bg-secondary;
+	div {
+		color: #4d4d4d;
 	}
 	}
 }
 }
 
 
-tr td {
-	vertical-align: middle;
-}
-
 .song-actions {
 .song-actions {
 	.button {
 	.button {
 		height: 36px;
 		height: 36px;
@@ -408,12 +392,6 @@ tr td {
 	margin-top: 20px;
 	margin-top: 20px;
 }
 }
 
 
-.night-mode {
-	div {
-		color: #4d4d4d;
-	}
-}
-
 #playlist-to-queue-selection {
 #playlist-to-queue-selection {
 	margin-top: 0;
 	margin-top: 0;
 
 
@@ -450,29 +428,15 @@ tr td {
 	padding: 20px;
 	padding: 20px;
 }
 }
 
 
-#playlists {
-	.playlist-item {
-		.button {
-			width: 146px;
-		}
-	}
-}
+#song-query-results {
+	padding: 10px;
+	max-height: 500px;
+	overflow: auto;
+	border: 1px solid $light-grey-2;
+	border-radius: 3px;
 
 
-.song-actions-transition-enter-active {
-	transition: all 0.2s ease;
-}
-
-.song-actions-transition-leave-active {
-	transition: all 0.2s cubic-bezier(1, 0.5, 0.8, 1);
-}
-
-.song-actions-transition-enter {
-	transform: translateX(-20px);
-	opacity: 0;
-}
-
-.song-actions-transition-leave-to {
-	transform: translateX(20px);
-	opacity: 0;
+	.search-query-item:not(:last-of-type) {
+		margin-bottom: 10px;
+	}
 }
 }
 </style>
 </style>

+ 2 - 9
frontend/src/pages/Station/components/Sidebar/Queue/QueueItem.vue

@@ -2,7 +2,7 @@
 	<div class="universal-item queue-item">
 	<div class="universal-item queue-item">
 		<div id="thumbnail-and-info">
 		<div id="thumbnail-and-info">
 			<img
 			<img
-				id="thumbnail"
+				class="item-thumbnail"
 				:src="song.ytThumbnail ? song.ytThumbnail : song.thumbnail"
 				:src="song.ytThumbnail ? song.ytThumbnail : song.thumbnail"
 				onerror="this.src='/assets/notes-transparent.png'"
 				onerror="this.src='/assets/notes-transparent.png'"
 			/>
 			/>
@@ -81,7 +81,7 @@
 						station.type === 'community' &&
 						station.type === 'community' &&
 							($parent.isOwnerOnly() || $parent.isAdminOnly())
 							($parent.isOwnerOnly() || $parent.isAdminOnly())
 					"
 					"
-					class="material-icons remove-from-queue-icon"
+					class="material-icons delete-icon"
 					@click="$parent.removeFromQueue(song.songId)"
 					@click="$parent.removeFromQueue(song.songId)"
 					>delete_forever</i
 					>delete_forever</i
 				>
 				>
@@ -150,13 +150,6 @@ export default {
 		margin-left: 5px;
 		margin-left: 5px;
 	}
 	}
 
 
-	#thumbnail {
-		width: 65px;
-		height: 65px;
-		margin: -7.5px;
-		border-radius: 3px 0 0 3px;
-	}
-
 	#thumbnail-and-info {
 	#thumbnail-and-info {
 		width: calc(100% - 120px);
 		width: calc(100% - 120px);
 	}
 	}

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

@@ -439,7 +439,8 @@ export default {
 		MainHeader,
 		MainHeader,
 		MainFooter,
 		MainFooter,
 		SongQueue: () => import("./AddSongToQueue.vue"),
 		SongQueue: () => import("./AddSongToQueue.vue"),
-		EditPlaylist: () => import("../../components/modals/EditPlaylist.vue"),
+		EditPlaylist: () =>
+			import("../../components/modals/EditPlaylist/index.vue"),
 		CreatePlaylist: () =>
 		CreatePlaylist: () =>
 			import("../../components/modals/CreatePlaylist.vue"),
 			import("../../components/modals/CreatePlaylist.vue"),
 		EditStation: () => import("../../components/modals/EditStation.vue"),
 		EditStation: () => import("../../components/modals/EditStation.vue"),