mirror of
https://github.com/koel/koel
synced 2024-11-28 06:50:27 +00:00
feat: only show YouTube sidebar item on demand (#1621)
This commit is contained in:
parent
af467343af
commit
4e067c3d50
18 changed files with 377 additions and 268 deletions
|
@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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>
|
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
@ -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)
|
|
@ -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>
|
|
@ -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')
|
|
@ -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>
|
|
@ -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')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
|
@ -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()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
|
@ -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>`;
|
|
@ -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>`;
|
|
@ -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) => {
|
||||||
|
|
Loading…
Reference in a new issue