9 次代碼提交 8c9c04657f ... d92be8c7a0

作者 SHA1 備註 提交日期
  Owen Diffey d92be8c7a0 refactor: Utilise components in dj search 6 天之前
  Owen Diffey 1bc536554d fix: Style fixes 6 天之前
  Owen Diffey 835cda46b1 feat: Add media player overlay 6 天之前
  Owen Diffey 0791c9da56 fix: Prevent double tab selector border 6 天之前
  Owen Diffey 9743180cf2 feat: Add button type prop 6 天之前
  Owen Diffey 5200c80abb feat: Add grey button theme 6 天之前
  Owen Diffey 7d5ae21eb1 feat: Add select component 6 天之前
  Owen Diffey 4238cf69c1 feat: Add input group component 6 天之前
  Owen Diffey 4de614fb55 feat: Add input component 6 天之前

+ 1 - 1
frontend/src/pages/NewStation/Components/AddToPlaylistDropdown.vue

@@ -82,7 +82,7 @@ const createPlaylist = () => {
 				<label class="dropdown-list-item__action">
 				<label class="dropdown-list-item__action">
 					<Checkbox
 					<Checkbox
 						:model-value="existsInPlaylist(playlist)"
 						:model-value="existsInPlaylist(playlist)"
-						@click.prevent="() => toggleInPlaylist(playlist)"
+						@click.prevent="toggleInPlaylist(playlist)"
 					/>
 					/>
 					{{ playlist.displayName }}
 					{{ playlist.displayName }}
 				</label>
 				</label>

+ 14 - 2
frontend/src/pages/NewStation/Components/Button.vue

@@ -1,29 +1,35 @@
 <script lang="ts" setup>
 <script lang="ts" setup>
 withDefaults(
 withDefaults(
 	defineProps<{
 	defineProps<{
+		type?: HTMLButtonElement["type"];
 		icon?: string;
 		icon?: string;
 		disabled?: boolean;
 		disabled?: boolean;
 		square?: boolean;
 		square?: boolean;
 		inverse?: boolean;
 		inverse?: boolean;
 		danger?: boolean;
 		danger?: boolean;
+		grey?: boolean;
 	}>(),
 	}>(),
 	{
 	{
+		type: "button",
 		icon: null,
 		icon: null,
 		disabled: false,
 		disabled: false,
 		square: false,
 		square: false,
 		inverse: false,
 		inverse: false,
-		danger: false
+		danger: false,
+		grey: false
 	}
 	}
 );
 );
 </script>
 </script>
 
 
 <template>
 <template>
 	<button
 	<button
+		:type="type"
 		:class="{
 		:class="{
 			btn: true,
 			btn: true,
 			'btn--square': square,
 			'btn--square': square,
 			'btn--inverse': inverse,
 			'btn--inverse': inverse,
-			'btn--danger': danger
+			'btn--danger': danger,
+			'btn--grey': grey
 		}"
 		}"
 		:disabled="disabled"
 		:disabled="disabled"
 	>
 	>
@@ -78,6 +84,12 @@ withDefaults(
 		--primary-color: var(--red);
 		--primary-color: var(--red);
 	}
 	}
 
 
+	&--grey {
+		background-color: var(--light-grey-2);
+		color: var(--primary-color);
+		border-color: var(--light-grey-1);
+	}
+
 	&__icon {
 	&__icon {
 		font-size: 18px;
 		font-size: 18px;
 	}
 	}

+ 59 - 0
frontend/src/pages/NewStation/Components/Input.vue

@@ -0,0 +1,59 @@
+<script lang="ts" setup>
+import { InputTypeHTMLAttribute } from "vue";
+
+withDefaults(
+	defineProps<{
+		type?: InputTypeHTMLAttribute;
+		required?: boolean;
+		autocomplete?: string;
+	}>(),
+	{
+		type: "text",
+		required: false,
+		autocomplete: "off"
+	}
+);
+
+const value = defineModel<any>();
+</script>
+
+<template>
+	<label class="inpt">
+		<strong class="inpt__label">
+			<slot />
+		</strong>
+
+		<slot v-if="$slots.input" name="input" />
+		<input
+			v-else
+			class="inpt__input"
+			:type="type"
+			v-model="value"
+			:required="required"
+			:autocomplete="autocomplete"
+		/>
+	</label>
+</template>
+
+<style lang="less" scoped>
+.inpt {
+	display: inline-flex;
+	flex-direction: column;
+	gap: 5px;
+
+	&__label {
+		color: var(--dark-grey-1);
+		font-size: 0.9em;
+	}
+
+	:deep(&__input) {
+		flex-grow: 1;
+		line-height: 18px;
+		font-size: 12px !important;
+		padding: 5px 10px;
+		background-color: var(--light-grey-2);
+		border-radius: 5px;
+		border: solid 1px var(--light-grey-1);
+	}
+}
+</style>

+ 63 - 0
frontend/src/pages/NewStation/Components/InputGroup.vue

@@ -0,0 +1,63 @@
+<script lang="ts" setup>
+withDefaults(
+	defineProps<{
+		is?: string | object;
+	}>(),
+	{
+		is: "div"
+	}
+);
+</script>
+
+<template>
+	<component :is="is" class="input_group">
+		<slot />
+	</component>
+</template>
+
+<style lang="less" scoped>
+.input_group {
+	display: flex;
+
+	:deep(&__expanding) {
+		flex-grow: 1;
+		min-width: 0;
+	}
+
+	& > :deep(.btn) {
+		align-self: end;
+		border-radius: 0;
+
+		&:first-child:not(:last-child) {
+			border-radius: 5px 0 0 5px;
+		}
+
+		&:last-child:not(:first-child) {
+			border-radius: 0 5px 5px 0;
+		}
+	}
+
+	& > :deep(.inpt) {
+		.inpt__input {
+			border-radius: 0;
+		}
+
+		&:first-child:not(:last-child) .inpt__input {
+			border-radius: 5px 0 0 5px;
+		}
+
+		&:last-child:not(:first-child) .inpt__input {
+			border-radius: 0 5px 5px 0;
+		}
+
+		&:not(:first-child) .inpt__label {
+			padding-left: 5px;
+			border-left: solid 1px var(--light-grey-1);
+		}
+
+		&:not(:last-child) .inpt__input {
+			border-right-width: 0;
+		}
+	}
+}
+</style>

+ 82 - 23
frontend/src/pages/NewStation/Components/MediaPlayer.vue

@@ -69,10 +69,11 @@ const mediaPausedAt = computed(() =>
 const mediaTimePaused = computed(() =>
 const mediaTimePaused = computed(() =>
 	isSourceControlled.value ? props.sourceTimePaused : playerTimePaused.value
 	isSourceControlled.value ? props.sourceTimePaused : playerTimePaused.value
 );
 );
+const isSourcePaused = computed(
+	() => isSourceControlled.value && !!props.sourcePausedAt
+);
 const isMediaPaused = computed(
 const isMediaPaused = computed(
-	() =>
-		!!playerPausedAt.value ||
-		(isSourceControlled.value && !!props.sourcePausedAt)
+	() => !!playerPausedAt.value || isSourcePaused.value
 );
 );
 const playerState = computed(() => {
 const playerState = computed(() => {
 	if (!props.source) return "no_song";
 	if (!props.source) return "no_song";
@@ -218,6 +219,7 @@ const resumePlayer = () => {
 		playerPausedAt.value?.diff() ?? 0
 		playerPausedAt.value?.diff() ?? 0
 	);
 	);
 	playerPausedAt.value = null;
 	playerPausedAt.value = null;
+	isAutomaticallyPaused.value = false;
 
 
 	applySourceState();
 	applySourceState();
 };
 };
@@ -288,7 +290,7 @@ const onYoutubeStateChange = (event: YT.OnStateChangeEvent) => {
 		return;
 		return;
 	}
 	}
 
 
-	if (isSourceControlled.value && !!props.sourcePausedAt) {
+	if (isSourcePaused.value) {
 		seekPlayer();
 		seekPlayer();
 
 
 		pauseMedia();
 		pauseMedia();
@@ -306,8 +308,6 @@ const onYoutubeStateChange = (event: YT.OnStateChangeEvent) => {
 		return;
 		return;
 	}
 	}
 
 
-	isAutomaticallyPaused.value = false;
-
 	resumePlayer();
 	resumePlayer();
 };
 };
 
 
@@ -384,23 +384,50 @@ onBeforeUnmount(() => {
 
 
 <template>
 <template>
 	<div class="media-player">
 	<div class="media-player">
-		<YoutubePlayer
-			v-show="sourceType === 'youtube'"
-			ref="youtubePlayer"
-			:video-id="sourceId"
-			@ready="onYoutubeReady"
-			@error="onYoutubeError"
-			@state-change="onYoutubeStateChange"
-		/>
-		<iframe
-			v-if="experimental.soundcloud"
-			v-show="sourceType === 'soundcloud'"
-			ref="soundcloudPlayer"
-			style="width: 100%; height: 100%; min-height: 200px"
-			scrolling="no"
-			frameborder="no"
-			allow="autoplay"
-		></iframe>
+		<div class="media-player__player">
+			<YoutubePlayer
+				v-show="sourceType === 'youtube'"
+				ref="youtubePlayer"
+				:video-id="sourceId"
+				@ready="onYoutubeReady"
+				@error="onYoutubeError"
+				@state-change="onYoutubeStateChange"
+			/>
+			<iframe
+				v-if="experimental.soundcloud"
+				v-show="sourceType === 'soundcloud'"
+				ref="soundcloudPlayer"
+				style="width: 100%; height: 100%; min-height: 200px"
+				scrolling="no"
+				frameborder="no"
+				allow="autoplay"
+			></iframe>
+			<div v-if="isSourcePaused" class="media-player__overlay">
+				<slot name="sourcePausedReason" />
+			</div>
+			<div
+				v-else-if="playerPausedAt"
+				class="media-player__overlay"
+				@click.prevent="resumePlayer"
+			>
+				<p><strong>Playback paused</strong></p>
+				<p>Click here to continue playback.</p>
+			</div>
+			<div
+				v-else-if="isAutomaticallyPaused"
+				class="media-player__overlay"
+			>
+				<img
+					class="media-player__bouncer"
+					src="/assets/notes-transparent.png"
+				/>
+				<p><strong>Unable to play</strong></p>
+				<p>
+					This media is unavailable for you, please try another
+					source.
+				</p>
+			</div>
+		</div>
 		<div class="media-player__controls">
 		<div class="media-player__controls">
 			<button
 			<button
 				v-if="playerPausedAt || isAutomaticallyPaused"
 				v-if="playerPausedAt || isAutomaticallyPaused"
@@ -424,6 +451,38 @@ onBeforeUnmount(() => {
 	flex-direction: column;
 	flex-direction: column;
 	flex-grow: 1;
 	flex-grow: 1;
 
 
+	&__player {
+		position: relative;
+		display: flex;
+		flex-direction: column;
+		aspect-ratio: 16/9;
+		overflow: hidden;
+		border: solid 1px var(--light-grey-1);
+		border-radius: 5px;
+	}
+
+	&__overlay {
+		position: absolute;
+		top: 0;
+		left: 0;
+		right: 0;
+		bottom: 0;
+		width: 100%;
+		height: 100%;
+		background: var(--primary-color);
+		display: flex;
+		flex-direction: column;
+		align-items: center;
+		justify-content: center;
+		padding: 10px;
+		gap: 5px;
+
+		:deep(p) {
+			color: var(--white);
+			text-align: center;
+		}
+	}
+
 	&__controls {
 	&__controls {
 		display: flex;
 		display: flex;
 		flex-grow: 1;
 		flex-grow: 1;

+ 46 - 0
frontend/src/pages/NewStation/Components/Select.vue

@@ -0,0 +1,46 @@
+<script lang="ts" setup>
+import { defineAsyncComponent } from "vue";
+
+const Input = defineAsyncComponent(
+	() => import("@/pages/NewStation/Components/Input.vue")
+);
+
+withDefaults(
+	defineProps<{
+		options: Record<string, string>;
+		required?: boolean;
+		autocomplete?: string;
+	}>(),
+	{
+		required: false,
+		autocomplete: "off"
+	}
+);
+
+const value = defineModel<any>();
+</script>
+
+<template>
+	<Input v-model="value" :required="required" :autocomplete="autocomplete">
+		<slot />
+
+		<template #input>
+			<select
+				class="inpt__input"
+				v-model="value"
+				:required="required"
+				:autocomplete="autocomplete"
+			>
+				<option
+					v-for="(
+						[optionValue, optionLabel], index
+					) in Object.entries(options)"
+					:key="`select-option-${index}`"
+					:value="optionValue"
+				>
+					{{ optionLabel }}
+				</option>
+			</select>
+		</template>
+	</Input>
+</template>

+ 52 - 41
frontend/src/pages/NewStation/Components/Tabs.vue

@@ -22,16 +22,19 @@ onMounted(() => {
 
 
 <template>
 <template>
 	<div class="tbs">
 	<div class="tbs">
-		<ul>
+		<ul class="tbs__selectors">
 			<li
 			<li
 				v-for="tab in tabs"
 				v-for="tab in tabs"
 				:key="`tab-option-${tab}`"
 				:key="`tab-option-${tab}`"
-				:class="{ 'tbs__li--selected': selected === tab }"
+				class="tbs__selector"
+				:class="{ 'tbs__selector--selected': selected === tab }"
 			>
 			>
 				<button @click.prevent="selectTab(tab)">
 				<button @click.prevent="selectTab(tab)">
 					{{ tab }}
 					{{ tab }}
 				</button>
 				</button>
 			</li>
 			</li>
+			<li class="tbs__divider"></li>
+			<li class="tbs__placeholder"></li>
 		</ul>
 		</ul>
 		<div class="tbs__tb">
 		<div class="tbs__tb">
 			<template v-for="tab in tabs" :key="`tab-${tab}`">
 			<template v-for="tab in tabs" :key="`tab-${tab}`">
@@ -51,61 +54,69 @@ onMounted(() => {
 	border: solid 1px var(--light-grey-1);
 	border: solid 1px var(--light-grey-1);
 	overflow: hidden;
 	overflow: hidden;
 
 
-	ul {
+	&__selectors {
 		display: flex;
 		display: flex;
 		flex-shrink: 0;
 		flex-shrink: 0;
 		overflow-x: auto;
 		overflow-x: auto;
+		background-color: var(--light-grey-2);
 		border-bottom: solid 1px var(--light-grey-1);
 		border-bottom: solid 1px var(--light-grey-1);
+	}
 
 
-		li {
-			display: inline-flex;
-			flex: 1 0 0;
+	&__selector {
+		display: inline-flex;
+		flex: 0 1 160px;
 
 
-			button {
-				display: inline-flex;
-				justify-content: center;
-				flex-grow: 1;
-				font-size: 14px;
-				text-align: center;
-				outline: none;
-				border-radius: 0;
-				padding: 5px 10px;
-				line-height: 18px;
-				font-weight: 600;
-				border: solid 1px var(--light-grey-2);
-				background-color: var(--light-grey-2);
-				color: var(--black);
-				cursor: pointer;
-				transition: filter ease-in-out 0.2s;
-				white-space: nowrap;
-
-				&:hover {
-					filter: brightness(90%);
-				}
+		button {
+			display: inline-flex;
+			justify-content: center;
+			flex-grow: 1;
+			font-size: 14px !important;
+			font-weight: 600 !important;
+			text-align: center;
+			outline: none;
+			border-radius: 0;
+			padding: 5px 10px;
+			line-height: 20px;
+			border: none;
+			border-left: solid 1px var(--light-grey-1);
+			background-color: var(--light-grey-2);
+			color: var(--black);
+			cursor: pointer;
+			transition: filter ease-in-out 0.2s;
+			white-space: nowrap;
+
+			&:hover {
+				filter: brightness(90%);
 			}
 			}
+		}
+
+		&:first-child button {
+			border-left-color: var(--light-grey-2);
+		}
 
 
-			&.tbs__li--selected button {
-				border: solid 1px var(--white);
+		&--selected {
+			button {
 				background-color: var(--white);
 				background-color: var(--white);
 				color: var(--primary-color);
 				color: var(--primary-color);
 			}
 			}
 
 
 			&:first-child button {
 			&:first-child button {
-				border-radius: 5px 0 0 0;
-			}
-
-			&:last-child button {
-				border-radius: 0 5px 0 0;
+				border-left-color: var(--white);
 			}
 			}
+		}
+	}
 
 
-			&:only-child button {
-				border-radius: 5px 5px 0 0;
-			}
+	&__divider {
+		display: inline-flex;
+		background-color: var(--light-grey-1);
+		flex: 1 0 0;
+		max-width: 1px;
+	}
 
 
-			&:not(:first-child):not(:only-child) {
-				border-left: solid 1px var(--light-grey-1);
-			}
-		}
+	&__placeholder {
+		display: inline-flex;
+		background-color: transparent;
+		flex: 1 0 0;
 	}
 	}
 
 
 	&__tb {
 	&__tb {

+ 22 - 32
frontend/src/pages/NewStation/LeftSidebar.vue

@@ -1,5 +1,5 @@
 <script lang="ts" setup>
 <script lang="ts" setup>
-import { computed, defineAsyncComponent, reactive, ref } from "vue";
+import { computed, defineAsyncComponent, reactive } from "vue";
 import Toast from "toasters";
 import Toast from "toasters";
 import { Station } from "@/types/station";
 import { Station } from "@/types/station";
 import { useUserAuthStore } from "@/stores/userAuth";
 import { useUserAuthStore } from "@/stores/userAuth";
@@ -16,6 +16,12 @@ const props = defineProps<{
 const Button = defineAsyncComponent(
 const Button = defineAsyncComponent(
 	() => import("@/pages/NewStation/Components/Button.vue")
 	() => import("@/pages/NewStation/Components/Button.vue")
 );
 );
+const Input = defineAsyncComponent(
+	() => import("@/pages/NewStation/Components/Input.vue")
+);
+const InputGroup = defineAsyncComponent(
+	() => import("@/pages/NewStation/Components/InputGroup.vue")
+);
 const Pill = defineAsyncComponent(
 const Pill = defineAsyncComponent(
 	() => import("@/pages/NewStation/Components/Pill.vue")
 	() => import("@/pages/NewStation/Components/Pill.vue")
 );
 );
@@ -192,43 +198,27 @@ const searchForDjs = (page: number) => {
 				<p style="font-size: 14px">
 				<p style="font-size: 14px">
 					Search for a user to promote to DJ.
 					Search for a user to promote to DJ.
 				</p>
 				</p>
-				<div style="display: flex">
-					<input
-						type="text"
-						style="
-							line-height: 18px;
-							font-size: 12px;
-							padding: 5px 10px;
-							background-color: var(--light-grey-2);
-							border-radius: 5px 0 0 5px;
-							border: solid 1px var(--light-grey-1);
-							flex-grow: 1;
-							border-right-width: 0;
-						"
+				<InputGroup
+					is="form"
+					@submit.prevent="searchForDjs(1)"
+					@reset.prevent="resetDjsSearch"
+				>
+					<Input
+						class="input_group__expanding"
 						v-model="djSearch.query"
 						v-model="djSearch.query"
-						@keyup.enter="searchForDjs(1)"
-					/>
+						required
+					>
+						Query
+					</Input>
 					<Button
 					<Button
+						type="reset"
 						icon="restart_alt"
 						icon="restart_alt"
 						square
 						square
-						style="
-							border-right-width: 0;
-							background-color: var(--light-grey-2);
-							border-color: var(--light-grey-1);
-							color: var(--primary-color);
-							border-radius: 0;
-						"
-						@click.prevent="resetDjsSearch()"
+						grey
 						title="Reset search"
 						title="Reset search"
 					/>
 					/>
-					<Button
-						icon="search"
-						square
-						style="border-radius: 0 5px 5px 0; flex-shrink: 0"
-						@click.prevent="searchForDjs(1)"
-						title="Search"
-					/>
-				</div>
+					<Button type="submit" icon="search" square title="Search" />
+				</InputGroup>
 
 
 				<UserItem
 				<UserItem
 					v-for="dj in djSearch.results"
 					v-for="dj in djSearch.results"

+ 3 - 3
frontend/src/pages/NewStation/Queue.vue

@@ -154,13 +154,13 @@ onMounted(() => {
 							v-if="index > 0"
 							v-if="index > 0"
 							icon="vertical_align_top"
 							icon="vertical_align_top"
 							label="Move to top of queue"
 							label="Move to top of queue"
-							@click="() => moveToQueueTop(media, index)"
+							@click="moveToQueueTop(media, index)"
 						/>
 						/>
 						<DropdownListItem
 						<DropdownListItem
 							v-if="queue.length - 1 !== index"
 							v-if="queue.length - 1 !== index"
 							icon="vertical_align_bottom"
 							icon="vertical_align_bottom"
 							label="Move to bottom of queue"
 							label="Move to bottom of queue"
-							@click="() => moveToQueueBottom(media, index)"
+							@click="moveToQueueBottom(media, index)"
 						/>
 						/>
 						<!-- TODO: Quick confirm -->
 						<!-- TODO: Quick confirm -->
 						<DropdownListItem
 						<DropdownListItem
@@ -172,7 +172,7 @@ onMounted(() => {
 							"
 							"
 							icon="delete"
 							icon="delete"
 							label="Remove from queue"
 							label="Remove from queue"
-							@click="() => removeFromQueue(media, index)"
+							@click="removeFromQueue(media, index)"
 						/>
 						/>
 					</template>
 					</template>
 				</MediaItem>
 				</MediaItem>

+ 25 - 1
frontend/src/pages/NewStation/index.vue

@@ -523,7 +523,30 @@ onBeforeUnmount(() => {
 						sync-player-time-enabled
 						sync-player-time-enabled
 						@not-allowed="automaticallySkipVote"
 						@not-allowed="automaticallySkipVote"
 						@not-found="automaticallySkipVote"
 						@not-found="automaticallySkipVote"
-					/>
+					>
+						<template #sourcePausedReason>
+							<p>
+								<strong
+									>This station is currently paused.</strong
+								>
+							</p>
+							<p
+								v-if="
+									hasPermissionForStation(
+										station._id,
+										'stations.playback.toggle'
+									)
+								"
+							>
+								To continue playback click the resume station
+								button below.
+							</p>
+							<p v-else>
+								It can only be resumed by a station owner,
+								station DJ or a site admin/moderator.
+							</p>
+						</template>
+					</MediaPlayer>
 					<h3
 					<h3
 						style="
 						style="
 							margin: 0px;
 							margin: 0px;
@@ -679,6 +702,7 @@ onBeforeUnmount(() => {
 	--light-grey-2: #ececec;
 	--light-grey-2: #ececec;
 	--red: rgb(249, 49, 0);
 	--red: rgb(249, 49, 0);
 
 
+	&,
 	h1,
 	h1,
 	h2,
 	h2,
 	h3,
 	h3,