Browse Source

Started on Manage Station modal

Owen Diffey 3 years ago
parent
commit
3b66fe6bb4

+ 2 - 0
docker-compose.yml

@@ -23,6 +23,8 @@ services:
       - /opt/app/node_modules/
     environment:
       - FRONTEND_MODE=${FRONTEND_MODE}
+    links:
+      - backend
 
   mongo:
     image: mongo:4.0

+ 10 - 0
frontend/src/App.vue

@@ -647,6 +647,16 @@ a {
 			}
 		}
 	}
+	.tippy-content > div {
+		display: flex;
+		flex-direction: column;
+		button {
+			width: 150px;
+			&:not(:last-of-type) {
+				margin-bottom: 10px;
+			}
+		}
+	}
 }
 
 .select {

+ 335 - 0
frontend/src/components/modals/ManageStation/Tabs/Playlists.vue

@@ -0,0 +1,335 @@
+<template>
+	<div class="station-playlists">
+		<div class="tabs-container">
+			<div class="tab-selection">
+				<button
+					class="button is-default"
+					:class="{ selected: tab === 'current' }"
+					@click="showTab('current')"
+				>
+					Current
+				</button>
+				<button
+					class="button is-default"
+					:class="{ selected: tab === 'search' }"
+					@click="showTab('search')"
+				>
+					Search
+				</button>
+				<button
+					v-if="station.type === 'community'"
+					class="button is-default"
+					:class="{ selected: tab === 'my-playlists' }"
+					@click="showTab('my-playlists')"
+				>
+					My Playlists
+				</button>
+			</div>
+			<div class="tab" v-show="tab === 'current'">
+				<!-- <div v-if="station.includedPlaylists.length > 0">
+					<playlist-item
+						:playlist="playlist"
+						v-for="(playlist, index) in station.includedPlaylists"
+						:key="'key-' + index"
+					>
+						<div class="icons-group" slot="actions">
+							<i
+								class="material-icons stop-icon"
+								content="Stop playing songs from this playlist
+							"
+								v-tippy
+								>stop</i
+							>
+							<i
+								v-if="playlist.createdBy === myUserId"
+								@click="showPlaylist(playlist._id)"
+								class="material-icons edit-icon"
+								content="Edit Playlist"
+								v-tippy
+								>edit</i
+							>
+							<i
+								v-else
+								@click="showPlaylist(playlist._id)"
+								class="material-icons edit-icon"
+								content="View Playlist"
+								v-tippy
+								>visibility</i
+							>
+						</div>
+					</playlist-item>
+				</div> -->
+				<p class="nothing-here-text scrollable-list">
+					No playlists currently selected.
+				</p>
+			</div>
+			<div class="tab" v-show="tab === 'search'">
+				Searching genre and public user playlists has yet to be added.
+			</div>
+			<div
+				v-if="station.type === 'community'"
+				class="tab"
+				v-show="tab === 'my-playlists'"
+			>
+				<button
+					class="button is-primary"
+					id="create-new-playlist-button"
+					@click="
+						openModal({
+							sector: 'station',
+							modal: 'createPlaylist'
+						})
+					"
+				>
+					Create new playlist
+				</button>
+				<draggable
+					class="menu-list scrollable-list"
+					v-if="playlists.length > 0"
+					v-model="playlists"
+					v-bind="dragOptions"
+					@start="drag = true"
+					@end="drag = false"
+					@change="savePlaylistOrder"
+				>
+					<transition-group
+						type="transition"
+						:name="!drag ? 'draggable-list-transition' : null"
+					>
+						<playlist-item
+							class="item-draggable"
+							v-for="playlist in playlists"
+							:key="playlist._id"
+							:playlist="playlist"
+						>
+							<div slot="actions">
+								<i
+									class="material-icons play-icon"
+									:content="
+										station.partyMode
+											? 'Request songs from this playlist'
+											: 'Play songs from this playlist'
+									"
+									v-tippy
+									>play_arrow</i
+								>
+								<i
+									class="material-icons stop-icon"
+									:content="
+										station.partyMode
+											? 'Stop requesting songs from this playlist'
+											: 'Stop playing songs from this playlist'
+									"
+									v-tippy
+									>stop</i
+								>
+								<i
+									@click="showPlaylist(playlist._id)"
+									class="material-icons edit-icon"
+									content="Edit Playlist"
+									v-tippy
+									>edit</i
+								>
+							</div>
+						</playlist-item>
+					</transition-group>
+				</draggable>
+				<p v-else class="nothing-here-text scrollable-list">
+					You don't have any playlists!
+				</p>
+			</div>
+		</div>
+	</div>
+</template>
+<script>
+import { mapActions, mapState, mapGetters } from "vuex";
+
+import Toast from "toasters";
+import draggable from "vuedraggable";
+import PlaylistItem from "@/components/PlaylistItem.vue";
+
+import TabQueryHandler from "@/mixins/TabQueryHandler.vue";
+import SortablePlaylists from "@/mixins/SortablePlaylists.vue";
+
+export default {
+	components: {
+		draggable,
+		PlaylistItem,
+		CreatePlaylist: () => import("@/components/modals/CreatePlaylist.vue")
+	},
+	mixins: [TabQueryHandler, SortablePlaylists],
+	data() {
+		return {
+			tab: "current"
+		};
+	},
+	computed: {
+		playlists: {
+			get() {
+				return this.$store.state.user.playlists.playlists;
+			},
+			set(playlists) {
+				this.$store.commit("user/playlists/setPlaylists", playlists);
+			}
+		},
+		...mapState({
+			role: state => state.user.auth.role,
+			myUserId: state => state.user.auth.userId,
+			userId: state => state.user.auth.userId
+		}),
+		...mapState("modals/editStation", {
+			station: state => state.station,
+			originalStation: state => state.originalStation
+		}),
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	mounted() {
+		this.socket.dispatch("playlists.indexMyPlaylists", true, res => {
+			if (res.status === "success") this.playlists = res.data.playlists;
+			this.orderOfPlaylists = this.calculatePlaylistOrder(); // order in regards to the database
+		});
+
+		this.socket.on("event:playlist.create", playlist => {
+			this.playlists.push(playlist);
+		});
+
+		this.socket.on("event:playlist.delete", playlistId => {
+			this.playlists.forEach((playlist, index) => {
+				if (playlist._id === playlistId) {
+					this.playlists.splice(index, 1);
+				}
+			});
+		});
+
+		this.socket.on("event:playlist.addSong", data => {
+			this.playlists.forEach((playlist, index) => {
+				if (playlist._id === data.playlistId) {
+					this.playlists[index].songs.push(data.song);
+				}
+			});
+		});
+
+		this.socket.on("event:playlist.removeSong", data => {
+			this.playlists.forEach((playlist, index) => {
+				if (playlist._id === data.playlistId) {
+					this.playlists[index].songs.forEach((song, index2) => {
+						if (song.youtubeId === data.youtubeId) {
+							this.playlists[index].songs.splice(index2, 1);
+						}
+					});
+				}
+			});
+		});
+
+		this.socket.on("event:playlist.updateDisplayName", data => {
+			this.playlists.forEach((playlist, index) => {
+				if (playlist._id === data.playlistId) {
+					this.playlists[index].displayName = data.displayName;
+				}
+			});
+		});
+
+		this.socket.on("event:playlist.updatePrivacy", data => {
+			this.playlists.forEach((playlist, index) => {
+				if (playlist._id === data.playlist._id) {
+					this.playlists[index].privacy = data.playlist.privacy;
+				}
+			});
+		});
+
+		this.socket.on(
+			"event:user.orderOfPlaylists.changed",
+			orderOfPlaylists => {
+				const sortedPlaylists = [];
+
+				this.playlists.forEach(playlist => {
+					sortedPlaylists[
+						orderOfPlaylists.indexOf(playlist._id)
+					] = playlist;
+				});
+
+				this.playlists = sortedPlaylists;
+				this.orderOfPlaylists = this.calculatePlaylistOrder();
+			}
+		);
+
+		this.socket.dispatch(
+			`stations.getStationIncludedPlaylistsById`,
+			this.station._id,
+			res => {
+				if (res.status === "success") {
+					this.station.includedPlaylists = res.playlists;
+					this.originalStation.includedPlaylists = res.playlists;
+				}
+			}
+		);
+
+		this.socket.dispatch(
+			`stations.getStationExcludedPlaylistsById`,
+			this.station._id,
+			res => {
+				if (res.status === "success") {
+					this.station.excludedPlaylists = res.playlists;
+					this.originalStation.excludedPlaylists = res.playlists;
+				}
+			}
+		);
+	},
+	methods: {
+		showPlaylist(playlistId) {
+			this.editPlaylist(playlistId);
+			this.openModal({ sector: "station", modal: "editPlaylist" });
+		},
+		...mapActions("station", ["updatePrivatePlaylistQueueSelected"]),
+		...mapActions("modalVisibility", ["openModal"]),
+		...mapActions("user/playlists", ["editPlaylist", "setPlaylists"])
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.station-playlists {
+	.tabs-container {
+		.tab-selection {
+			display: flex;
+			.button {
+				border-radius: 0;
+				border: 0;
+				text-transform: uppercase;
+				font-size: 14px;
+				color: var(--dark-grey-3);
+				background-color: var(--light-grey-2);
+				flex-grow: 1;
+				height: 32px;
+
+				&:not(:first-of-type) {
+					margin-left: 5px;
+				}
+			}
+
+			.selected {
+				background-color: var(--dark-grey-3) !important;
+				color: var(--white) !important;
+			}
+		}
+		.tab {
+			padding: 15px 0;
+			border-radius: 0;
+			.playlist-item:not(:last-of-type),
+			.item.item-draggable:not(:last-of-type) {
+				margin-bottom: 10px;
+			}
+		}
+	}
+}
+.draggable-list-transition-move {
+	transition: transform 0.5s;
+}
+
+.draggable-list-ghost {
+	opacity: 0.5;
+	filter: brightness(95%);
+}
+</style>

+ 392 - 0
frontend/src/components/modals/ManageStation/Tabs/Settings.vue

@@ -0,0 +1,392 @@
+<template>
+	<div class="station-settings">
+		<label class="label">Change Station Name</label>
+		<div class="control is-grouped input-with-button">
+			<p class="control is-expanded">
+				<input class="input" type="text" v-model="station.name" />
+			</p>
+			<p class="control">
+				<a class="button is-info" @click.prevent="saveChanges()"
+					>Save</a
+				>
+			</p>
+		</div>
+		<label class="label">Change Station Display Name</label>
+		<div class="control is-grouped input-with-button">
+			<p class="control is-expanded">
+				<input
+					class="input"
+					type="text"
+					v-model="station.displayName"
+				/>
+			</p>
+			<p class="control">
+				<a class="button is-info" @click.prevent="saveChanges()"
+					>Save</a
+				>
+			</p>
+		</div>
+		<label class="label">Change Description</label>
+		<div class="control is-grouped input-with-button">
+			<p class="control is-expanded">
+				<input
+					class="input"
+					type="text"
+					v-model="station.description"
+				/>
+			</p>
+			<p class="control">
+				<a class="button is-info" @click.prevent="saveChanges()"
+					>Save</a
+				>
+			</p>
+		</div>
+		<div class="settings-buttons">
+			<div class="small-section">
+				<label class="label">Theme</label>
+				<tippy
+					class="button-wrapper"
+					theme="addToPlaylist"
+					interactive="true"
+					placement="bottom"
+					trigger="click"
+					append-to="parent"
+				>
+					<template #trigger>
+						<button :class="station.theme">
+							<i class="material-icons">palette</i>
+							{{ station.theme }}
+						</button>
+					</template>
+					<button
+						class="blue"
+						v-if="station.theme !== 'blue'"
+						@click="updateThemeLocal('blue')"
+					>
+						<i class="material-icons">palette</i>
+						Blue
+					</button>
+					<button
+						class="purple"
+						v-if="station.theme !== 'purple'"
+						@click="updateThemeLocal('purple')"
+					>
+						<i class="material-icons">palette</i>
+						Purple
+					</button>
+					<button
+						class="teal"
+						v-if="station.theme !== 'teal'"
+						@click="updateThemeLocal('teal')"
+					>
+						<i class="material-icons">palette</i>
+						Teal
+					</button>
+					<button
+						class="orange"
+						v-if="station.theme !== 'orange'"
+						@click="updateThemeLocal('orange')"
+					>
+						<i class="material-icons">palette</i>
+						Orange
+					</button>
+				</tippy>
+			</div>
+			<div class="small-section">
+				<label class="label">Privacy</label>
+				<tippy
+					class="button-wrapper"
+					theme="addToPlaylist"
+					interactive="true"
+					placement="bottom"
+					trigger="click"
+					append-to="parent"
+				>
+					<template #trigger>
+						<button :class="privacyButtons[station.privacy].style">
+							<i class="material-icons">{{
+								privacyButtons[station.privacy].iconName
+							}}</i>
+							{{ station.privacy }}
+						</button>
+					</template>
+					<button
+						class="green"
+						v-if="station.privacy !== 'public'"
+						@click="updatePrivacyLocal('public')"
+					>
+						<i class="material-icons">{{
+							privacyButtons["public"].iconName
+						}}</i>
+						Public
+					</button>
+					<button
+						class="orange"
+						v-if="station.privacy !== 'unlisted'"
+						@click="updatePrivacyLocal('unlisted')"
+					>
+						<i class="material-icons">{{
+							privacyButtons["unlisted"].iconName
+						}}</i>
+						Unlisted
+					</button>
+					<button
+						class="red"
+						v-if="station.privacy !== 'private'"
+						@click="updatePrivacyLocal('private')"
+					>
+						<i class="material-icons">{{
+							privacyButtons["private"].iconName
+						}}</i>
+						Private
+					</button>
+				</tippy>
+			</div>
+			<div class="small-section">
+				<label class="label">Station Mode</label>
+				<tippy
+					class="button-wrapper"
+					theme="addToPlaylist"
+					interactive="true"
+					placement="bottom"
+					trigger="click"
+					append-to="parent"
+				>
+					<template #trigger>
+						<button
+							:class="{
+								blue: !station.partyMode,
+								yellow: station.partyMode
+							}"
+						>
+							<i class="material-icons">{{
+								station.partyMode
+									? "emoji_people"
+									: "playlist_play"
+							}}</i>
+							{{ station.partyMode ? "Party" : "Playlist" }}
+						</button>
+					</template>
+					<button
+						class="blue"
+						v-if="station.partyMode"
+						@click="updatePartyModeLocal(false)"
+					>
+						<i class="material-icons">playlist_play</i>
+						Playlist
+					</button>
+					<button
+						class="yellow"
+						v-if="!station.partyMode"
+						@click="updatePartyModeLocal(true)"
+					>
+						<i class="material-icons">emoji_people</i>
+						Party
+					</button>
+				</tippy>
+			</div>
+			<div v-if="!station.partyMode" class="small-section">
+				<label class="label">Play Mode</label>
+				<tippy
+					class="button-wrapper"
+					theme="addToPlaylist"
+					interactive="true"
+					placement="bottom"
+					trigger="click"
+					append-to="parent"
+				>
+					<template #trigger>
+						<button class="blue">
+							<i class="material-icons">{{
+								station.playMode === "random"
+									? "shuffle"
+									: "format_list_numbered"
+							}}</i>
+							{{
+								station.playMode === "random"
+									? "Random"
+									: "Sequential"
+							}}
+						</button>
+					</template>
+					<button
+						class="blue"
+						v-if="station.playMode === 'sequential'"
+						@click="updatePlayModeLocal('random')"
+					>
+						<i class="material-icons">shuffle</i>
+						Random
+					</button>
+					<button
+						class="blue"
+						v-if="station.playMode === 'random'"
+						@click="updatePlayModeLocal('sequential')"
+					>
+						<i class="material-icons">format_list_numbered</i>
+						Sequential
+					</button>
+				</tippy>
+			</div>
+			<div
+				v-if="
+					station.type === 'community' && station.partyMode === true
+				"
+				class="small-section"
+			>
+				<label class="label">Queue lock</label>
+				<div class="button-wrapper">
+					<button
+						:class="{
+							green: station.locked,
+							red: !station.locked
+						}"
+						@click="
+							station.locked
+								? updateQueueLockLocal(true)
+								: updateQueueLockLocal(false)
+						"
+					>
+						<i class="material-icons">{{
+							station.locked ? "lock" : "lock_open"
+						}}</i>
+						{{ station.locked ? "Locked" : "Unlocked" }}
+					</button>
+					<transition name="slide-down">
+						<button
+							class="green"
+							v-if="!station.locked"
+							@click="updateQueueLockLocal(true)"
+						>
+							<i class="material-icons">lock</i>
+							Locked
+						</button>
+					</transition>
+					<transition name="slide-down">
+						<button
+							class="red"
+							v-if="station.locked"
+							@click="updateQueueLockLocal(false)"
+						>
+							<i class="material-icons">lock_open</i>
+							Unlocked
+						</button>
+					</transition>
+				</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script>
+import { mapState, mapGetters } from "vuex";
+
+export default {
+	data() {
+		return {
+			privacyButtons: {
+				public: {
+					style: "green",
+					iconName: "public"
+				},
+				private: {
+					style: "red",
+					iconName: "lock"
+				},
+				unlisted: {
+					style: "orange",
+					iconName: "link"
+				}
+			}
+		};
+	},
+	computed: {
+		...mapState({
+			station: state => state.station.station,
+			originalStation: state => state.originalStation
+		}),
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	methods: {
+		updateThemeLocal() {},
+		updatePrivacyLocal() {},
+		updatePartyModeLocal() {},
+		updatePlayModeLocal() {},
+		updateQueueLockLocal() {}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.station-settings {
+	.settings-buttons {
+		display: flex;
+		justify-content: center;
+		flex-wrap: wrap;
+		.small-section {
+			width: calc(50% - 10px);
+			min-width: 150px;
+			margin: 5px auto;
+		}
+	}
+	.button-wrapper {
+		display: flex;
+		flex-direction: column;
+
+		button {
+			width: 100%;
+			height: 36px;
+			border: 0;
+			border-radius: 3px;
+			font-size: 18px;
+			color: var(--white);
+			box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.25);
+			display: block;
+			text-align: center;
+			justify-content: center;
+			display: inline-flex;
+			-ms-flex-align: center;
+			align-items: center;
+			-moz-user-select: none;
+			user-select: none;
+			cursor: pointer;
+			padding: 0;
+			text-transform: capitalize;
+
+			&.red {
+				background-color: var(--red);
+			}
+
+			&.green {
+				background-color: var(--green);
+			}
+
+			&.blue {
+				background-color: var(--blue);
+			}
+
+			&.orange {
+				background-color: var(--orange);
+			}
+
+			&.yellow {
+				background-color: var(--yellow);
+			}
+
+			&.purple {
+				background-color: var(--purple);
+			}
+
+			&.teal {
+				background-color: var(--teal);
+			}
+
+			i {
+				font-size: 20px;
+				margin-right: 4px;
+			}
+		}
+	}
+}
+</style>

+ 123 - 0
frontend/src/components/modals/ManageStation/Tabs/YoutubeSearch.vue

@@ -0,0 +1,123 @@
+<template>
+	<div class="youtube-search">
+		<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="search.songs.query"
+					autofocus
+					@keyup.enter="searchForSongs()"
+				/>
+			</p>
+			<p class="control">
+				<a
+					class="button is-info"
+					@click.prevent="searchForSongs()"
+					href="#"
+					><i class="material-icons icon-with-button">search</i
+					>Search</a
+				>
+			</p>
+		</div>
+
+		<div v-if="search.songs.results.length > 0" id="song-query-results">
+			<search-query-item
+				v-for="(result, index) in search.songs.results"
+				: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-queue"
+						>
+							<i class="material-icons icon-with-button">done</i>
+							Added to queue
+						</a>
+						<a
+							class="button is-dark"
+							v-else
+							@click.prevent="addSongToQueue(result.id, index)"
+							href="#"
+							key="add-to-queue"
+						>
+							<i class="material-icons icon-with-button">add</i>
+							Add to queue
+						</a>
+					</transition>
+				</div>
+			</search-query-item>
+
+			<a
+				class="button is-default load-more-button"
+				@click.prevent="loadMoreSongs()"
+				href="#"
+			>
+				Load more...
+			</a>
+		</div>
+	</div>
+</template>
+
+<script>
+import { mapState, mapGetters } from "vuex";
+
+import Toast from "toasters";
+import SearchYoutube from "@/mixins/SearchYoutube.vue";
+
+import SearchQueryItem from "../../../SearchQueryItem.vue";
+
+export default {
+	components: {
+		SearchQueryItem
+	},
+	mixins: [SearchYoutube],
+	computed: {
+		...mapState("modals/editStation", {
+			station: state => state.station,
+			originalStation: state => state.originalStation
+		}),
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	methods: {
+		addSongToQueue(youtubeId, index) {
+			if (this.station.type === "community") {
+				this.socket.dispatch(
+					"stations.addToQueue",
+					this.station._id,
+					youtubeId,
+					data => {
+						if (data.status !== "success")
+							new Toast(`Error: ${data.message}`);
+						else {
+							this.search.songs.results[
+								index
+							].isAddedToQueue = true;
+
+							new Toast(data.message);
+						}
+					}
+				);
+			} else {
+				this.socket.dispatch("songs.request", youtubeId, data => {
+					if (data.status !== "success")
+						new Toast(`Error: ${data.message}`);
+					else {
+						this.search.songs.results[index].isAddedToQueue = true;
+
+						new Toast(data.message);
+					}
+				});
+			}
+		}
+	}
+};
+</script>

+ 210 - 0
frontend/src/components/modals/ManageStation/index.vue

@@ -0,0 +1,210 @@
+<template>
+	<modal title="Manage Station" class="manage-station-modal">
+		<template #body>
+			<div class="custom-modal-body" v-if="station && station._id">
+				<div class="left-section">
+					<div class="section tabs-container">
+						<h4 class="section-title">Manage Station</h4>
+						<hr class="section-horizontal-rule" />
+						<div class="tab-selection">
+							<button
+								class="button is-default"
+								:class="{ selected: tab === 'settings' }"
+								@click="showTab('settings')"
+							>
+								Settings
+							</button>
+							<button
+								class="button is-default"
+								:class="{ selected: tab === 'playlists' }"
+								@click="showTab('playlists')"
+							>
+								Playlists
+							</button>
+							<button
+								v-if="station.type === 'community'"
+								class="button is-default"
+								:class="{ selected: tab === 'youtube' }"
+								@click="showTab('youtube')"
+							>
+								YouTube
+							</button>
+						</div>
+						<settings class="tab" v-show="tab === 'settings'" />
+						<youtube-search
+							v-if="station.type === 'community'"
+							class="tab"
+							v-show="tab === 'youtube'"
+						/>
+						<playlists class="tab" v-show="tab === 'playlists'" />
+					</div>
+				</div>
+				<div class="right-section">
+					<div class="section">
+						<h4 class="section-title">Queue</h4>
+						<hr class="section-horizontal-rule" />
+						<queue />
+					</div>
+				</div>
+			</div>
+		</template>
+		<template #footer>
+			<div class="right">
+				<confirm @confirm="clearAndRefillStationQueue()">
+					<a class="button is-danger">
+						Clear and refill station queue
+					</a>
+				</confirm>
+				<confirm
+					v-if="station && station.type === 'community'"
+					@confirm="deleteStation()"
+				>
+					<button class="button is-danger">Delete station</button>
+				</confirm>
+			</div>
+		</template>
+	</modal>
+</template>
+
+<script>
+import { mapState, mapGetters, mapActions } from "vuex";
+
+import Toast from "toasters";
+import TabQueryHandler from "@/mixins/TabQueryHandler.vue";
+
+import validation from "@/validation";
+import Confirm from "@/components/Confirm.vue";
+import Modal from "../../Modal.vue";
+
+import Queue from "../../../pages/Station/Sidebar/Queue.vue";
+import Settings from "./Tabs/Settings.vue";
+import Playlists from "./Tabs/Playlists.vue";
+import YoutubeSearch from "./Tabs/YoutubeSearch.vue";
+
+export default {
+	components: {
+		Modal,
+		Confirm,
+		Queue,
+		Settings,
+		Playlists,
+		YoutubeSearch
+	},
+	mixins: [TabQueryHandler],
+	props: {
+		stationId: { type: String, default: "" },
+		sector: { type: String, default: "admin" }
+	},
+	data() {
+		return {
+			tab: "settings"
+		};
+	},
+	computed: {
+		...mapState("modals/editStation", {
+			station: state => state.station,
+			originalStation: state => state.originalStation
+		}),
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	mounted() {
+		this.socket.dispatch(`stations.getStationById`, this.stationId, res => {
+			if (res.status === "success") {
+				const { station } = res.data;
+				this.editStation(station);
+			} else {
+				new Toast(`Station with that ID not found${this.stationId}`);
+				this.closeModal({
+					sector: this.sector,
+					modal: "manageStation"
+				});
+			}
+		});
+	},
+	beforeDestroy() {
+		this.clearStation();
+	},
+	methods: {
+		...mapActions("modals/editStation", ["editStation", "clearStation"]),
+		...mapActions("modalVisibility", ["closeModal"])
+	}
+};
+</script>
+
+<style lang="scss">
+.manage-station-modal.modal {
+	z-index: 1800;
+	.modal-card {
+		width: 1300px;
+		overflow: auto;
+		.tab > button {
+			width: 100%;
+			margin-bottom: 10px;
+		}
+	}
+}
+</style>
+
+<style lang="scss" scoped>
+.manage-station-modal.modal .modal-card-body .custom-modal-body {
+	display: flex;
+	flex-wrap: wrap;
+	height: 100%;
+
+	.section {
+		display: flex;
+		flex-direction: column;
+		flex-grow: 1;
+		width: auto;
+		max-width: 600px;
+		padding: 15px !important;
+		margin: 0 10px;
+	}
+
+	.left-section {
+		max-width: 50%;
+		height: 100%;
+		overflow-y: auto;
+		flex-grow: 1;
+
+		.tabs-container {
+			.tab-selection {
+				display: flex;
+
+				.button {
+					border-radius: 5px 5px 0 0;
+					border: 0;
+					text-transform: uppercase;
+					font-size: 14px;
+					color: var(--dark-grey-3);
+					background-color: var(--light-grey-2);
+					flex-grow: 1;
+					height: 32px;
+
+					&:not(:first-of-type) {
+						margin-left: 5px;
+					}
+				}
+
+				.selected {
+					background-color: var(--dark-grey-3) !important;
+					color: var(--white) !important;
+				}
+			}
+			.tab {
+				border: 1px solid var(--light-grey-3);
+				padding: 15px;
+				border-radius: 0 0 5px 5px;
+			}
+		}
+	}
+	.right-section {
+		max-width: 50%;
+		height: 100%;
+		overflow-y: auto;
+		flex-grow: 1;
+	}
+}
+</style>

+ 23 - 0
frontend/src/pages/Station/index.vue

@@ -170,6 +170,22 @@
 										Station settings
 									</span>
 								</button>
+								<button
+									class="button is-primary"
+									@click="
+										openModal({
+											sector: 'station',
+											modal: 'manageStation'
+										})
+									"
+								>
+									<i class="material-icons icon-with-button"
+										>settings</i
+									>
+									<span class="optional-desktop-only-text">
+										Manage Station
+									</span>
+								</button>
 							</div>
 						</div>
 						<div id="sidebar-container" class="quadrant">
@@ -575,6 +591,11 @@
 					:station-id="station._id"
 					sector="station"
 				/>
+				<manage-station
+					v-if="modals.station.manageStation"
+					:station-id="station._id"
+					sector="station"
+				/>
 				<report v-if="modals.station.report" />
 			</div>
 
@@ -666,6 +687,8 @@ export default {
 		EditPlaylist: () => import("@/components/modals/EditPlaylist.vue"),
 		CreatePlaylist: () => import("@/components/modals/CreatePlaylist.vue"),
 		EditStation: () => import("@/components/modals/EditStation.vue"),
+		ManageStation: () =>
+			import("@/components/modals/ManageStation/index.vue"),
 		Report: () => import("@/components/modals/Report.vue"),
 		Z404,
 		FloatingBox,

+ 1 - 0
frontend/src/store/modules/modalVisibility.js

@@ -15,6 +15,7 @@ const state = {
 			editPlaylist: false,
 			createPlaylist: false,
 			editStation: false,
+			manageStation: false,
 			report: false
 		},
 		admin: {