feat: only show YouTube sidebar item on demand (#1621)

This commit is contained in:
Phan An 2022-12-07 14:31:38 +01:00 committed by GitHub
parent af467343af
commit 4e067c3d50
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 377 additions and 268 deletions

View file

@ -1,8 +0,0 @@
import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase'
new class extends UnitTestCase {
protected test () {
it('has already been tested in the integration suite', () => expect('😄').toBeTruthy())
}
}

View file

@ -1,252 +0,0 @@
<template>
<nav id="sidebar" v-koel-clickaway="closeIfMobile" :class="{ showing: mobileShowing }" class="side side-nav">
<SearchForm />
<section class="music">
<h1>Your Music</h1>
<ul class="menu">
<li>
<a :class="['home', activeScreen === 'Home' ? 'active' : '']" href="#/home">
<icon :icon="faHome" fixed-width />
Home
</a>
</li>
<li
:class="droppableToQueue && 'droppable'"
@dragleave="onQueueDragLeave"
@dragover="onQueueDragOver"
@drop="onQueueDrop"
>
<a :class="['queue', activeScreen === 'Queue' ? 'active' : '']" href="#/queue">
<icon :icon="faListOl" fixed-width />
Current Queue
</a>
</li>
<li>
<a :class="['songs', activeScreen === 'Songs' ? 'active' : '']" href="#/songs">
<icon :icon="faMusic" fixed-width />
All Songs
</a>
</li>
<li>
<a :class="['albums', activeScreen === 'Albums' ? 'active' : '']" href="#/albums">
<icon :icon="faCompactDisc" fixed-width />
Albums
</a>
</li>
<li>
<a :class="['artists', activeScreen === 'Artists' ? 'active' : '']" href="#/artists">
<icon :icon="faMicrophone" fixed-width />
Artists
</a>
</li>
<li>
<a :class="['genres', activeScreen === 'Genres' ? 'active' : '']" href="#/genres">
<icon :icon="faTags" fixed-width />
Genres
</a>
</li>
<li v-if="useYouTube">
<a :class="['youtube', activeScreen === 'YouTube' ? 'active' : '']" href="#/youtube">
<icon :icon="faYoutube" fixed-width />
YouTube Video
</a>
</li>
</ul>
</section>
<PlaylistList />
<section v-if="isAdmin" class="manage">
<h1>Manage</h1>
<ul class="menu">
<li>
<a :class="['settings', activeScreen === 'Settings' ? 'active' : '']" href="#/settings">
<icon :icon="faTools" fixed-width />
Settings
</a>
</li>
<li>
<a :class="['upload', activeScreen === 'Upload' ? 'active' : '']" href="#/upload">
<icon :icon="faUpload" fixed-width />
Upload
</a>
</li>
<li>
<a :class="['users', activeScreen === 'Users' ? 'active' : '']" href="#/users">
<icon :icon="faUsers" fixed-width />
Users
</a>
</li>
</ul>
</section>
</nav>
</template>
<script lang="ts" setup>
import {
faCompactDisc,
faHome,
faListOl,
faMicrophone,
faMusic,
faTags,
faTools,
faUpload,
faUsers
} from '@fortawesome/free-solid-svg-icons'
import { faYoutube } from '@fortawesome/free-brands-svg-icons'
import { ref } from 'vue'
import { eventBus } from '@/utils'
import { queueStore } from '@/stores'
import { useAuthorization, useDroppable, useRouter, useThirdPartyServices } from '@/composables'
import PlaylistList from '@/components/playlist/PlaylistSidebarList.vue'
import SearchForm from '@/components/ui/SearchForm.vue'
const mobileShowing = ref(false)
const activeScreen = ref<ScreenName>()
const droppableToQueue = ref(false)
const { onRouteChanged } = useRouter()
const { acceptsDrop, resolveDroppedSongs } = useDroppable(['songs', 'album', 'artist', 'playlist'])
const { useYouTube } = useThirdPartyServices()
const { isAdmin } = useAuthorization()
const onQueueDragOver = (event: DragEvent) => {
if (!acceptsDrop(event)) return false
event.preventDefault()
event.dataTransfer!.dropEffect = 'move'
droppableToQueue.value = true
}
const onQueueDragLeave = () => (droppableToQueue.value = false)
const onQueueDrop = async (event: DragEvent) => {
droppableToQueue.value = false
if (!acceptsDrop(event)) return false
event.preventDefault()
const songs = await resolveDroppedSongs(event) || []
songs.length && queueStore.queue(songs)
return false
}
const closeIfMobile = () => (mobileShowing.value = false)
onRouteChanged(route => {
mobileShowing.value = false
activeScreen.value = route.screen
})
/**
* Listen to toggle sidebar event to show or hide the sidebar.
* This should only be triggered on a mobile device.
*/
eventBus.on('TOGGLE_SIDEBAR', () => (mobileShowing.value = !mobileShowing.value))
</script>
<style lang="scss" scoped>
nav {
width: var(--sidebar-width);
background-color: var(--color-bg-secondary);
padding: 2.05rem 1.5rem;
overflow: auto;
overflow-x: hidden;
-ms-overflow-style: -ms-autohiding-scrollbar;
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.1);
> * + * {
margin-top: 2.25rem;
}
@media (hover: none) {
// Enable scroll with momentum on touch devices
overflow-y: scroll;
-webkit-overflow-scrolling: touch;
}
.droppable {
box-shadow: inset 0 0 0 1px var(--color-accent);
border-radius: 4px;
cursor: copy;
}
.queue > span {
display: flex;
align-items: baseline;
justify-content: space-between;
flex: 1;
}
:deep(h1) {
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 12px;
}
:deep(a svg) {
opacity: .7;
}
:deep(a) {
display: flex;
align-items: center;
gap: .7rem;
height: 36px;
line-height: 36px;
white-space: nowrap;
text-overflow: ellipsis;
position: relative;
&:active {
padding: 2px 0 0 2px;
}
&.active, &:hover {
color: var(--color-text-primary);
}
&.active {
&::before {
content: '';
position: absolute;
top: 25%;
right: -1.5rem;
width: 4px;
height: 50%;
background-color: var(--color-highlight);
box-shadow: 0 0 40px 10px var(--color-highlight);
border-radius: 9999rem;
}
}
}
:deep(li li a) { // submenu items
padding-left: 11px;
&:active {
padding: 2px 0 0 13px;
}
}
@media screen and (max-width: 768px) {
@include themed-background();
transform: translateX(-100vw);
transition: transform .2s ease-in-out;
position: fixed;
width: 100%;
z-index: 99;
height: calc(100vh - var(--header-height));
&.showing {
transform: translateX(0);
}
}
}
</style>

View file

@ -10,7 +10,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { defineAsyncComponent } from 'vue' import { defineAsyncComponent } from 'vue'
import SideBar from '@/components/layout/main-wrapper/Sidebar.vue' import SideBar from '@/components/layout/main-wrapper/sidebar/Sidebar.vue'
import MainContent from '@/components/layout/main-wrapper/MainContent.vue' import MainContent from '@/components/layout/main-wrapper/MainContent.vue'
import ExtraPanel from '@/components/layout/main-wrapper/ExtraPanel.vue' import ExtraPanel from '@/components/layout/main-wrapper/ExtraPanel.vue'

View file

@ -9,7 +9,7 @@
> >
<a @click.prevent="toggle" @contextmenu.prevent="onContextMenu"> <a @click.prevent="toggle" @contextmenu.prevent="onContextMenu">
<icon :icon="opened ? faFolderOpen : faFolder" fixed-width /> <icon :icon="opened ? faFolderOpen : faFolder" fixed-width />
{{ folder.name }} <span>{{ folder.name }}</span>
</a> </a>
<ul v-if="playlistsInFolder.length" v-show="opened"> <ul v-if="playlistsInFolder.length" v-show="opened">
@ -34,7 +34,7 @@ import { playlistFolderStore, playlistStore } from '@/stores'
import { eventBus } from '@/utils' import { eventBus } from '@/utils'
import { useDroppable } from '@/composables' import { useDroppable } from '@/composables'
const PlaylistSidebarItem = defineAsyncComponent(() => import('@/components/playlist/PlaylistSidebarItem.vue')) const PlaylistSidebarItem = defineAsyncComponent(() => import('./PlaylistSidebarItem.vue'))
const props = defineProps<{ folder: PlaylistFolder }>() const props = defineProps<{ folder: PlaylistFolder }>()
const { folder } = toRefs(props) const { folder } = toRefs(props)

View file

@ -15,7 +15,7 @@
<icon v-else-if="isFavoriteList(list)" :icon="faHeart" class="text-maroon" fixed-width /> <icon v-else-if="isFavoriteList(list)" :icon="faHeart" class="text-maroon" fixed-width />
<icon v-else-if="list.is_smart" :icon="faWandMagicSparkles" fixed-width /> <icon v-else-if="list.is_smart" :icon="faWandMagicSparkles" fixed-width />
<icon v-else :icon="faFileLines" fixed-width /> <icon v-else :icon="faFileLines" fixed-width />
{{ list.name }} <span>{{ list.name }}</span>
</a> </a>
</li> </li>
</template> </template>

View file

@ -5,7 +5,6 @@
<icon <icon
:icon="faCirclePlus" :icon="faCirclePlus"
class="control create" class="control create"
data-testid="sidebar-create-playlist-btn"
role="button" role="button"
title="Create a new playlist or folder" title="Create a new playlist or folder"
@click.stop.prevent="requestContextMenu" @click.stop.prevent="requestContextMenu"
@ -27,8 +26,8 @@ import { computed, toRef } from 'vue'
import { favoriteStore, playlistFolderStore, playlistStore } from '@/stores' import { favoriteStore, playlistFolderStore, playlistStore } from '@/stores'
import { eventBus } from '@/utils' import { eventBus } from '@/utils'
import PlaylistSidebarItem from '@/components/playlist/PlaylistSidebarItem.vue' import PlaylistSidebarItem from './PlaylistSidebarItem.vue'
import PlaylistFolderSidebarItem from '@/components/playlist/PlaylistFolderSidebarItem.vue' import PlaylistFolderSidebarItem from './PlaylistFolderSidebarItem.vue'
const folders = toRef(playlistFolderStore.state, 'folders') const folders = toRef(playlistFolderStore.state, 'folders')
const playlists = toRef(playlistStore.state, 'playlists') const playlists = toRef(playlistStore.state, 'playlists')

View file

@ -0,0 +1,39 @@
<template>
<SidebarItem
screen="Queue"
href="#/queue"
:icon="faListOl"
:class="droppable && 'droppable'"
@dragleave="onQueueDragLeave"
@dragover.prevent="onQueueDragOver"
@drop="onQueueDrop"
>
Current Queue
</SidebarItem>
</template>
<script lang="ts" setup>
import { faListOl } from '@fortawesome/free-solid-svg-icons'
import { ref } from 'vue'
import { queueStore } from '@/stores'
import { useDroppable } from '@/composables'
import SidebarItem from './SidebarItem.vue'
const { acceptsDrop, resolveDroppedSongs } = useDroppable(['songs', 'album', 'artist', 'playlist'])
const droppable = ref(false)
const onQueueDragOver = (event: DragEvent) => (droppable.value = acceptsDrop(event))
const onQueueDragLeave = () => (droppable.value = false)
const onQueueDrop = async (event: DragEvent) => {
droppable.value = false
event.preventDefault()
const songs = await resolveDroppedSongs(event) || []
songs.length && queueStore.queue(songs)
return false
}
</script>

View file

@ -0,0 +1,55 @@
import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { screen } from '@testing-library/vue'
import { commonStore } from '@/stores'
import { eventBus } from '@/utils'
import Sidebar from './Sidebar.vue'
const standardItems = [
'Home',
'Current Queue',
'All Songs',
'Albums',
'Artists',
'Genres',
'Favorites',
'Recently Played'
]
const adminItems = [...standardItems, 'Users', 'Upload', 'Settings']
new class extends UnitTestCase {
private renderComponent() {
this.render(Sidebar, {
global: {
stubs: {
YouTubeSidebarItem: this.stub('youtube-sidebar-item')
}
}
})
}
protected test() {
it('shows the standard items', () => {
this.actingAs().renderComponent()
standardItems.forEach(label => screen.getByText(label))
})
it('shows administrative items', () => {
this.actingAsAdmin().renderComponent()
adminItems.forEach(label => screen.getByText(label))
})
it('shows the YouTube sidebar item on demand', async () => {
commonStore.state.use_you_tube = true
this.renderComponent()
expect(screen.queryByTestId('youtube-sidebar-item')).toBeNull()
eventBus.emit('PLAY_YOUTUBE_VIDEO', { id: '123', title: 'A Random Video' })
await this.tick()
screen.getByTestId('youtube-sidebar-item')
})
}
}

View file

@ -0,0 +1,180 @@
<template>
<nav id="sidebar" v-koel-clickaway="closeIfMobile" :class="{ showing: mobileShowing }" class="side side-nav">
<SearchForm />
<section class="music">
<h1>Your Music</h1>
<ul class="menu">
<SidebarItem screen="Home" href="#/home" :icon="faHome">Home</SidebarItem>
<QueueSidebarItem />
<SidebarItem screen="Songs" href="#/songs" :icon="faMusic">All Songs</SidebarItem>
<SidebarItem screen="Albums" href="#/albums" :icon="faCompactDisc">Albums</SidebarItem>
<SidebarItem screen="Artists" href="#/artists" :icon="faMicrophone">Artists</SidebarItem>
<SidebarItem screen="Genres" href="#/genres" :icon="faTags">Genres</SidebarItem>
<YouTubeSidebarItem v-if="showYouTube" />
</ul>
</section>
<PlaylistList />
<section v-if="isAdmin" class="manage">
<h1>Manage</h1>
<ul class="menu">
<SidebarItem screen="Settings" href="#/settings" :icon="faTools">Settings</SidebarItem>
<SidebarItem screen="Upload" href="#/upload" :icon="faUpload">Upload</SidebarItem>
<SidebarItem screen="Users" href="#/users" :icon="faUsers">Users</SidebarItem>
</ul>
</section>
</nav>
</template>
<script lang="ts" setup>
import {
faCompactDisc,
faHome,
faMicrophone,
faMusic,
faTags,
faTools,
faUpload,
faUsers
} from '@fortawesome/free-solid-svg-icons'
import { computed, ref } from 'vue'
import { eventBus } from '@/utils'
import { useAuthorization, useRouter, useThirdPartyServices } from '@/composables'
import SidebarItem from './SidebarItem.vue'
import QueueSidebarItem from './QueueSidebarItem.vue'
import YouTubeSidebarItem from './YouTubeSidebarItem.vue'
import PlaylistList from './PlaylistSidebarList.vue'
import SearchForm from '@/components/ui/SearchForm.vue'
const { onRouteChanged } = useRouter()
const { useYouTube } = useThirdPartyServices()
const { isAdmin } = useAuthorization()
const mobileShowing = ref(false)
const youTubePlaying = ref(false)
const showYouTube = computed(() => useYouTube.value && youTubePlaying.value)
const closeIfMobile = () => (mobileShowing.value = false)
onRouteChanged(_ => (mobileShowing.value = false))
/**
* Listen to toggle sidebar event to show or hide the sidebar.
* This should only be triggered on a mobile device.
*/
eventBus.on('TOGGLE_SIDEBAR', () => (mobileShowing.value = !mobileShowing.value))
.on('PLAY_YOUTUBE_VIDEO', _ => (youTubePlaying.value = true))
</script>
<style lang="scss" scoped>
nav {
width: var(--sidebar-width);
background-color: var(--color-bg-secondary);
padding: 2.05rem 1.5rem;
overflow: auto;
overflow-x: hidden;
-ms-overflow-style: -ms-autohiding-scrollbar;
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.1);
> * + * {
margin-top: 2.25rem;
}
@media (hover: none) {
// Enable scroll with momentum on touch devices
overflow-y: scroll;
-webkit-overflow-scrolling: touch;
}
.droppable {
box-shadow: inset 0 0 0 1px var(--color-accent);
border-radius: 4px;
cursor: copy;
}
.queue > span {
display: flex;
align-items: baseline;
justify-content: space-between;
flex: 1;
}
:deep(h1) {
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 12px;
}
:deep(a svg) {
opacity: .7;
}
:deep(a) {
display: flex;
align-items: center;
gap: .7rem;
height: 36px;
line-height: 36px;
white-space: nowrap;
text-overflow: ellipsis;
position: relative;
&:active {
padding: 2px 0 0 2px;
}
&.active, &:hover {
color: var(--color-text-primary);
}
&.active {
&::before {
content: '';
position: absolute;
top: 25%;
right: -1.5rem;
width: 4px;
height: 50%;
background-color: var(--color-highlight);
box-shadow: 0 0 40px 10px var(--color-highlight);
border-radius: 9999rem;
}
}
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
:deep(li li a) { // submenu items
padding-left: 11px;
&:active {
padding: 2px 0 0 13px;
}
}
@media screen and (max-width: 768px) {
@include themed-background();
transform: translateX(-100vw);
transition: transform .2s ease-in-out;
position: fixed;
width: 100%;
z-index: 99;
height: calc(100vh - var(--header-height));
&.showing {
transform: translateX(0);
}
}
}
</style>

View file

@ -0,0 +1,35 @@
import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { screen } from '@testing-library/vue'
import { faHome } from '@fortawesome/free-solid-svg-icons'
import SidebarItem from './SidebarItem.vue'
new class extends UnitTestCase {
private renderComponent () {
return this.render(SidebarItem, {
props: {
icon: faHome,
href: '#',
screen: 'Home'
},
slots: {
default: 'Home'
}
})
}
protected test() {
it('renders', () => expect(this.renderComponent().html()).toMatchSnapshot())
it('activates when the screen matches', async () => {
this.renderComponent()
await this.router.activateRoute({
screen: 'Home',
path: '_',
})
expect(screen.getByRole('link').classList.contains('active')).toBe(true)
})
}
}

View file

@ -0,0 +1,23 @@
<template>
<li>
<a :class="active && 'active'" :href="props.href">
<icon :icon="props.icon" fixed-width />
<span>
<slot />
</span>
</a>
</li>
</template>
<script lang="ts" setup>
import { Component, ref } from 'vue'
import { useRouter } from '@/composables'
const props = defineProps<{ href: string; icon: Component, screen: ScreenName }>()
const active = ref(false)
const { onRouteChanged } = useRouter()
onRouteChanged(route => active.value = route.screen === props.screen)
</script>

View file

@ -0,0 +1,17 @@
import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { eventBus } from '@/utils'
import YouTubeSidebarItem from './YouTubeSidebarItem.vue'
new class extends UnitTestCase {
protected test() {
it('renders', async () => {
const { html } = this.render(YouTubeSidebarItem)
eventBus.emit('PLAY_YOUTUBE_VIDEO', { id: '123', title: 'A Random Video' })
await this.tick()
expect(html()).toMatchSnapshot()
})
}
}

View file

@ -0,0 +1,15 @@
<template>
<SidebarItem screen="YouTube" href="#/youtube" :icon="faYoutube">{{ title }}</SidebarItem>
</template>
<script lang="ts" setup>
import { faYoutube } from '@fortawesome/free-brands-svg-icons'
import { ref } from 'vue'
import { eventBus } from '@/utils'
import SidebarItem from './SidebarItem.vue'
const title = ref('')
eventBus.on('PLAY_YOUTUBE_VIDEO', payload => (title.value = payload.title))
</script>

View file

@ -0,0 +1,3 @@
// Vitest Snapshot v1
exports[`renders 1`] = `<li><a class="" href="#"><br data-testid="icon" icon="[object Object]" fixed-width=""><span>Home</span></a></li>`;

View file

@ -0,0 +1,3 @@
// Vitest Snapshot v1
exports[`renders 1`] = `<li><a class="" href="#/youtube"><br data-testid="icon" icon="[object Object]" fixed-width=""><span>A Random Video</span></a></li>`;

View file

@ -79,7 +79,7 @@ export const useDraggable = (type: DraggableType) => {
export const useDroppable = (acceptedTypes: DraggableType[]) => { export const useDroppable = (acceptedTypes: DraggableType[]) => {
const acceptsDrop = (event: DragEvent) => { const acceptsDrop = (event: DragEvent) => {
const type = getDragType(event) const type = getDragType(event)
return type && acceptedTypes.includes(type) return Boolean(type && acceptedTypes.includes(type))
} }
const getDroppedData = (event: DragEvent) => { const getDroppedData = (event: DragEvent) => {