feat: revamp the router and routing logic (#1519)

This commit is contained in:
Phan An 2022-10-08 12:54:25 +02:00 committed by GitHub
parent 279f23d4e1
commit d038b001d4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
81 changed files with 776 additions and 632 deletions

View file

@ -19,7 +19,6 @@
"@typescript-eslint"
],
"globals": {
"KOEL_ENV": "readonly",
"FileReader": "readonly",
"defineProps": "readonly",
"defineEmits": "readonly",

View file

@ -4,7 +4,7 @@
<MessageToaster ref="toaster"/>
<GlobalEventListeners/>
<div id="main" v-if="authenticated" @dragover="onDragOver" @drop="onDrop" @dragend="onDragEnd">
<div v-if="authenticated" id="main" @dragend="onDragEnd" @dragover="onDragOver" @drop="onDrop">
<Hotkeys/>
<AppHeader/>
<MainWrapper/>
@ -19,17 +19,17 @@
<DropZone v-show="showDropZone"/>
</div>
<div class="login-wrapper" v-else>
<div v-else class="login-wrapper">
<LoginForm @loggedin="onUserLoggedIn"/>
</div>
</template>
<script lang="ts" setup>
import { defineAsyncComponent, nextTick, onMounted, provide, ref } from 'vue'
import { eventBus, hideOverlay, showOverlay } from '@/utils'
import { eventBus, hideOverlay, requireInjection, showOverlay } from '@/utils'
import { commonStore, preferenceStore as preferences } from '@/stores'
import { authService, playbackService, socketListener, socketService, uploadService } from '@/services'
import { ActiveScreenKey, DialogBoxKey, MessageToasterKey } from '@/symbols'
import { DialogBoxKey, MessageToasterKey, RouterKey } from '@/symbols'
import DialogBox from '@/components/ui/DialogBox.vue'
import MessageToaster from '@/components/ui/MessageToaster.vue'
@ -59,7 +59,6 @@ const dialog = ref<InstanceType<typeof DialogBox>>()
const toaster = ref<InstanceType<typeof MessageToaster>>()
const authenticated = ref(false)
const showDropZone = ref(false)
const activeScreen = ref<ScreenName>()
/**
* Request for notification permission if it's not provided and the user is OK with notifications.
@ -116,18 +115,15 @@ const init = async () => {
}
}
const router = requireInjection(RouterKey)
const onDragOver = (e: DragEvent) => {
showDropZone.value = Boolean(e.dataTransfer?.types.includes('Files')) && activeScreen.value !== 'Upload'
showDropZone.value = Boolean(e.dataTransfer?.types.includes('Files')) && router.$currentRoute.value.screen !== 'Upload'
}
const onDragEnd = () => (showDropZone.value = false)
const onDrop = () => (showDropZone.value = false)
onMounted(() => {
eventBus.on('ACTIVATE_SCREEN', (screen: ScreenName) => (activeScreen.value = screen))
})
provide(ActiveScreenKey, activeScreen)
provide(DialogBoxKey, dialog)
provide(MessageToasterKey, toaster)
</script>

View file

@ -6,8 +6,10 @@ import { clickaway, focus } from '@/directives'
import { defineComponent, nextTick } from 'vue'
import { commonStore, userStore } from '@/stores'
import factory from '@/__tests__/factory'
import { DialogBoxKey, MessageToasterKey } from '@/symbols'
import { DialogBoxKey, MessageToasterKey, RouterKey } from '@/symbols'
import { DialogBoxStub, MessageToasterStub } from '@/__tests__/stubs'
import { routes } from '@/config'
import Router from '@/router'
// A deep-merge function that
// - supports symbols as keys (_.merge doesn't)
@ -24,8 +26,10 @@ const deepMerge = (first: object, second: object) => {
export default abstract class UnitTestCase {
private backupMethods = new Map()
protected router: Router
public constructor () {
this.router = new Router(routes)
this.beforeEach()
this.afterEach()
this.test()
@ -107,6 +111,12 @@ export default abstract class UnitTestCase {
options.global.provide[MessageToasterKey] = MessageToasterStub
}
// @ts-ignore
if (!options.global.provide.hasOwnProperty(RouterKey)) {
// @ts-ignore
options.global.provide[RouterKey] = this.router
}
return options
}

View file

@ -1,5 +1,6 @@
import { Ref, ref } from 'vue'
import { noop } from '@/utils'
import MessageToaster from '@/components/ui/MessageToaster.vue'
import DialogBox from '@/components/ui/DialogBox.vue'

View file

@ -1,10 +1,13 @@
import 'plyr/dist/plyr.js'
import { createApp } from 'vue'
import { clickaway, focus } from '@/directives'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { RouterKey } from '@/symbols'
import { routes } from '@/config'
import Router from '@/router'
import App from './App.vue'
createApp(App)
.provide(RouterKey, new Router(routes))
.component('icon', FontAwesomeIcon)
.directive('koel-focus', focus)
.directive('koel-clickaway', clickaway)

View file

@ -56,13 +56,16 @@
<script lang="ts" setup>
import { faDownload, faRandom } from '@fortawesome/free-solid-svg-icons'
import { computed, toRef, toRefs } from 'vue'
import { eventBus, pluralize, secondsToHis } from '@/utils'
import { eventBus, pluralize, requireInjection, secondsToHis } from '@/utils'
import { albumStore, artistStore, commonStore, songStore } from '@/stores'
import { downloadService, playbackService } from '@/services'
import { useDraggable } from '@/composables'
import { RouterKey } from '@/symbols'
import AlbumThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
const router = requireInjection(RouterKey)
const { startDragging } = useDraggable('album')
const props = withDefaults(defineProps<{ album: Album, layout?: ArtistAlbumCardLayout }>(), { layout: 'full' })
@ -76,6 +79,7 @@ const showing = computed(() => !albumStore.isUnknown(album.value))
const shuffle = async () => {
await playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value), true /* shuffled */)
router.go('queue')
}
const download = () => downloadService.fromAlbum(album.value)

View file

@ -4,7 +4,6 @@ import factory from '@/__tests__/factory'
import { eventBus } from '@/utils'
import { downloadService, playbackService } from '@/services'
import { commonStore, songStore } from '@/stores'
import router from '@/router'
import AlbumContextMenu from './AlbumContextMenu.vue'
let album: Album
@ -58,12 +57,12 @@ new class extends UnitTestCase {
})
it('downloads', async () => {
const mock = this.mock(downloadService, 'fromAlbum')
const downloadMock = this.mock(downloadService, 'fromAlbum')
const { getByText } = await this.renderComponent()
await getByText('Download').click()
expect(mock).toHaveBeenCalledWith(album)
expect(downloadMock).toHaveBeenCalledWith(album)
})
it('does not have an option to download if downloading is disabled', async () => {
@ -74,7 +73,7 @@ new class extends UnitTestCase {
})
it('goes to album', async () => {
const mock = this.mock(router, 'go')
const mock = this.mock(this.router, 'go')
const { getByText } = await this.renderComponent()
await getByText('Go to Album').click()
@ -91,7 +90,7 @@ new class extends UnitTestCase {
})
it('goes to artist', async () => {
const mock = this.mock(router, 'go')
const mock = this.mock(this.router, 'go')
const { getByText } = await this.renderComponent()
await getByText('Go to Artist').click()

View file

@ -4,8 +4,8 @@
<li data-testid="play" @click="play">Play All</li>
<li data-testid="shuffle" @click="shuffle">Shuffle All</li>
<li class="separator"></li>
<li data-testid="view-album" @click="viewAlbumDetails" v-if="isStandardAlbum">Go to Album</li>
<li data-testid="view-artist" @click="viewArtistDetails" v-if="isStandardArtist">Go to Artist</li>
<li v-if="isStandardAlbum" data-testid="view-album" @click="viewAlbumDetails">Go to Album</li>
<li v-if="isStandardArtist" data-testid="view-artist" @click="viewArtistDetails">Go to Artist</li>
<template v-if="isStandardAlbum && allowDownload">
<li class="separator"></li>
<li data-testid="download" @click="download">Download</li>
@ -19,29 +19,34 @@ import { computed, ref, toRef } from 'vue'
import { albumStore, artistStore, commonStore, songStore } from '@/stores'
import { downloadService, playbackService } from '@/services'
import { useContextMenu } from '@/composables'
import router from '@/router'
import { eventBus } from '@/utils'
import { eventBus, requireInjection } from '@/utils'
import { RouterKey } from '@/symbols'
const { context, base, ContextMenuBase, open, trigger } = useContextMenu()
const router = requireInjection(RouterKey)
const album = ref<Album>()
const allowDownload = toRef(commonStore.state, 'allow_download')
const isStandardAlbum = computed(() => !albumStore.isUnknown(album.value))
const isStandardAlbum = computed(() => !albumStore.isUnknown(album.value!))
const isStandardArtist = computed(() => {
return !artistStore.isUnknown(album.value.artist_id) && !artistStore.isVarious(album.value.artist_id)
return !artistStore.isUnknown(album.value!.artist_id) && !artistStore.isVarious(album.value!.artist_id)
})
const play = () => trigger(async () => playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value)))
const play = () => trigger(async () => {
await playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value!))
router.go('queue')
})
const shuffle = () => {
trigger(async () => playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value), true))
}
const shuffle = () => trigger(async () => {
await playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value!), true)
router.go('queue')
})
const viewAlbumDetails = () => trigger(() => router.go(`album/${album.value.id}`))
const viewArtistDetails = () => trigger(() => router.go(`artist/${album.value.artist_id}`))
const download = () => trigger(() => downloadService.fromAlbum(album.value))
const viewAlbumDetails = () => trigger(() => router.go(`album/${album.value!.id}`))
const viewArtistDetails = () => trigger(() => router.go(`artist/${album.value!.artist_id}`))
const download = () => trigger(() => downloadService.fromAlbum(album.value!))
eventBus.on('ALBUM_CONTEXT_MENU_REQUESTED', async (e: MouseEvent, _album: Album) => {
album.value = _album

View file

@ -1,10 +1,9 @@
import { expect, it } from 'vitest'
import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { mediaInfoService } from '@/services/mediaInfoService'
import { commonStore, songStore } from '@/stores'
import { fireEvent } from '@testing-library/vue'
import { playbackService } from '@/services'
import { playbackService, mediaInfoService } from '@/services'
import AlbumInfoComponent from './AlbumInfo.vue'
let album: Album

View file

@ -36,10 +36,13 @@ import { faCirclePlay } from '@fortawesome/free-solid-svg-icons'
import { computed, defineAsyncComponent, ref, toRefs, watch } from 'vue'
import { useThirdPartyServices } from '@/composables'
import { songStore } from '@/stores'
import { playbackService } from '@/services'
import { mediaInfoService } from '@/services/mediaInfoService'
import { playbackService, mediaInfoService } from '@/services'
import { RouterKey } from '@/symbols'
import AlbumThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
import { requireInjection } from '@/utils'
const router = requireInjection(RouterKey)
const TrackList = defineAsyncComponent(() => import('@/components/album/AlbumTrackList.vue'))
@ -60,7 +63,10 @@ watch(album, async () => {
const showSummary = computed(() => mode.value !== 'full' && !showingFullWiki.value)
const showFull = computed(() => !showSummary.value)
const play = async () => playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value))
const play = async () => {
await playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value))
router.go('queue')
}
</script>
<style lang="scss" scoped>

View file

@ -15,7 +15,7 @@
<footer>
<div class="info">
<a class="name" :href="`#!/artist/${artist.id}`" data-testid="name">{{ artist.name }}</a>
<a :href="`#!/artist/${artist.id}`" class="name" data-testid="name">{{ artist.name }}</a>
</div>
<p class="meta">
<span class="left">
@ -29,9 +29,9 @@
<a
:title="`Shuffle all songs by ${artist.name}`"
class="shuffle-artist"
data-testid="shuffle-artist"
href
role="button"
data-testid="shuffle-artist"
@click.prevent="shuffle"
>
<icon :icon="faRandom"/>
@ -40,9 +40,9 @@
v-if="allowDownload"
:title="`Download all songs by ${artist.name}`"
class="download-artist"
data-testid="download-artist"
href
role="button"
data-testid="download-artist"
@click.prevent="download"
>
<icon :icon="faDownload"/>
@ -56,13 +56,16 @@
<script lang="ts" setup>
import { faDownload, faRandom } from '@fortawesome/free-solid-svg-icons'
import { computed, toRef, toRefs } from 'vue'
import { eventBus, pluralize } from '@/utils'
import { eventBus, pluralize, requireInjection } from '@/utils'
import { artistStore, commonStore, songStore } from '@/stores'
import { downloadService, playbackService } from '@/services'
import { useDraggable } from '@/composables'
import { RouterKey } from '@/symbols'
import ArtistThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
const router = requireInjection(RouterKey)
const { startDragging } = useDraggable('artist')
const props = withDefaults(defineProps<{ artist: Artist, layout?: ArtistAlbumCardLayout }>(), { layout: 'full' })
@ -74,6 +77,7 @@ const showing = computed(() => artistStore.isStandard(artist.value))
const shuffle = async () => {
await playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value), true /* shuffled */)
router.go('queue')
}
const download = () => downloadService.fromArtist(artist.value)

View file

@ -4,7 +4,6 @@ import factory from '@/__tests__/factory'
import { eventBus } from '@/utils'
import { downloadService, playbackService } from '@/services'
import { commonStore, songStore } from '@/stores'
import router from '@/router'
import ArtistContextMenu from './ArtistContextMenu.vue'
let artist: Artist
@ -74,7 +73,7 @@ new class extends UnitTestCase {
})
it('goes to artist', async () => {
const mock = this.mock(router, 'go')
const mock = this.mock(this.router, 'go')
const { getByText } = await this.renderComponent()
await getByText('Go to Artist').click()

View file

@ -1,5 +1,5 @@
<template>
<ContextMenuBase extra-class="artist-menu" ref="base" data-testid="artist-context-menu">
<ContextMenuBase ref="base" data-testid="artist-context-menu" extra-class="artist-menu">
<template v-if="artist">
<li data-testid="play" @click="play">Play All</li>
<li data-testid="shuffle" @click="shuffle">Shuffle All</li>
@ -20,27 +20,32 @@ import { computed, ref, toRef } from 'vue'
import { artistStore, commonStore, songStore } from '@/stores'
import { downloadService, playbackService } from '@/services'
import { useContextMenu } from '@/composables'
import router from '@/router'
import { eventBus } from '@/utils'
import { RouterKey } from '@/symbols'
import { eventBus, requireInjection } from '@/utils'
const { context, base, ContextMenuBase, open, trigger } = useContextMenu()
const router = requireInjection(RouterKey)
const artist = ref<Artist>()
const allowDownload = toRef(commonStore.state, 'allow_download')
const isStandardArtist = computed(() =>
!artistStore.isUnknown(artist.value)
&& !artistStore.isVarious(artist.value)
!artistStore.isUnknown(artist.value!)
&& !artistStore.isVarious(artist.value!)
)
const play = () => trigger(async () => playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value)))
const play = () => trigger(async () => {
await playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value!))
router.go('queue')
})
const shuffle = () => {
trigger(async () => playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value), true))
}
const shuffle = () => trigger(async () => {
await playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value!), true)
router.go('queue')
})
const viewArtistDetails = () => trigger(() => router.go(`artist/${artist.value.id}`))
const download = () => trigger(() => downloadService.fromArtist(artist.value))
const viewArtistDetails = () => trigger(() => router.go(`artist/${artist.value!.id}`))
const download = () => trigger(() => downloadService.fromArtist(artist.value!))
eventBus.on('ARTIST_CONTEXT_MENU_REQUESTED', async (e: MouseEvent, _artist: Artist) => {
artist.value = _artist

View file

@ -1,10 +1,9 @@
import { expect, it } from 'vitest'
import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { mediaInfoService } from '@/services/mediaInfoService'
import { commonStore, songStore } from '@/stores'
import { fireEvent } from '@testing-library/vue'
import { playbackService } from '@/services'
import { mediaInfoService, playbackService } from '@/services'
import ArtistInfoComponent from './ArtistInfo.vue'
let artist: Artist

View file

@ -32,13 +32,16 @@
<script lang="ts" setup>
import { faCirclePlay } from '@fortawesome/free-solid-svg-icons'
import { computed, ref, toRefs, watch } from 'vue'
import { playbackService } from '@/services'
import { playbackService, mediaInfoService } from '@/services'
import { useThirdPartyServices } from '@/composables'
import { songStore } from '@/stores'
import { mediaInfoService } from '@/services/mediaInfoService'
import { RouterKey } from '@/symbols'
import { requireInjection } from '@/utils'
import ArtistThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
const router = requireInjection(RouterKey)
const props = withDefaults(defineProps<{ artist: Artist, mode?: MediaInfoDisplayMode }>(), { mode: 'aside' })
const { artist, mode } = toRefs(props)
@ -56,7 +59,10 @@ watch(artist, async () => {
const showSummary = computed(() => mode.value !== 'full' && !showingFullBio.value)
const showFull = computed(() => !showSummary.value)
const play = async () => playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value))
const play = async () => {
await playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value))
router.go('queue')
}
</script>
<style lang="scss" scoped>

View file

@ -53,8 +53,9 @@
import isMobile from 'ismobilejs'
import { faBolt, faListOl, faSliders } from '@fortawesome/free-solid-svg-icons'
import { ref, toRef, toRefs } from 'vue'
import { eventBus, isAudioContextSupported as useEqualizer } from '@/utils'
import { eventBus, isAudioContextSupported as useEqualizer, requireInjection } from '@/utils'
import { preferenceStore } from '@/stores'
import { RouterKey } from '@/symbols'
import Equalizer from '@/components/ui/Equalizer.vue'
import Volume from '@/components/ui/Volume.vue'
@ -73,7 +74,9 @@ const toggleEqualizer = () => (showEqualizer.value = !showEqualizer.value)
const closeEqualizer = () => (showEqualizer.value = false)
const toggleVisualizer = () => isMobile.any || eventBus.emit('TOGGLE_VISUALIZER')
eventBus.on('ACTIVATE_SCREEN', (screen: ScreenName) => (viewingQueue.value = screen === 'Queue'))
const router = requireInjection(RouterKey)
router.onRouteChanged(route => (viewingQueue.value = route.screen === 'Queue'))
</script>
<style lang="scss" scoped>

View file

@ -17,17 +17,13 @@ import ExtraControls from '@/components/layout/app-footer/FooterExtraControls.vu
import MiddlePane from '@/components/layout/app-footer/FooterMiddlePane.vue'
import PlayerControls from '@/components/layout/app-footer/FooterPlayerControls.vue'
const song = ref<Song | null>(null)
const viewingQueue = ref(false)
const song = ref<Song>()
const requestContextMenu = (event: MouseEvent) => {
song.value?.id && eventBus.emit('SONG_CONTEXT_MENU_REQUESTED', event, song.value)
}
eventBus.on({
SONG_STARTED: (newSong: Song) => (song.value = newSong),
ACTIVATE_SCREEN: (screen: ScreenName) => (viewingQueue.value = screen === 'Queue')
})
eventBus.on('SONG_STARTED', (newSong: Song) => (song.value = newSong))
</script>
<style lang="scss" scoped>

View file

@ -19,9 +19,9 @@
<UploadScreen v-show="screen === 'Upload'"/>
<SearchExcerptsScreen v-show="screen === 'Search.Excerpt'"/>
<SearchSongResultsScreen v-if="screen === 'Search.Songs'" :q="screenProps"/>
<AlbumScreen v-if="screen === 'Album'" :album="screenProps"/>
<ArtistScreen v-if="screen === 'Artist'" :artist="screenProps"/>
<SearchSongResultsScreen v-if="screen === 'Search.Songs'"/>
<AlbumScreen v-if="screen === 'Album'"/>
<ArtistScreen v-if="screen === 'Artist'"/>
<SettingsScreen v-if="screen === 'Settings'"/>
<ProfileScreen v-if="screen === 'Profile'"/>
<UserListScreen v-if="screen === 'Users'"/>
@ -32,9 +32,10 @@
<script lang="ts" setup>
import { defineAsyncComponent, onMounted, ref, toRef } from 'vue'
import { eventBus } from '@/utils'
import { eventBus, requireInjection } from '@/utils'
import { preferenceStore } from '@/stores'
import { useThirdPartyServices } from '@/composables'
import { RouterKey } from '@/symbols'
import HomeScreen from '@/components/screens/HomeScreen.vue'
import QueueScreen from '@/components/screens/QueueScreen.vue'
@ -46,7 +47,6 @@ import FavoritesScreen from '@/components/screens/FavoritesScreen.vue'
import RecentlyPlayedScreen from '@/components/screens/RecentlyPlayedScreen.vue'
import UploadScreen from '@/components/screens/UploadScreen.vue'
import SearchExcerptsScreen from '@/components/screens/search/SearchExcerptsScreen.vue'
import router from '@/router'
const UserListScreen = defineAsyncComponent(() => import('@/components/screens/UserListScreen.vue'))
const AlbumArtOverlay = defineAsyncComponent(() => import('@/components/ui/AlbumArtOverlay.vue'))
@ -60,24 +60,21 @@ const NotFoundScreen = defineAsyncComponent(() => import('@/components/screens/N
const Visualizer = defineAsyncComponent(() => import('@/components/ui/Visualizer.vue'))
const { useYouTube } = useThirdPartyServices()
const router = requireInjection(RouterKey)
const showAlbumArtOverlay = toRef(preferenceStore.state, 'showAlbumArtOverlay')
const showingVisualizer = ref(false)
const screenProps = ref<any>(null)
const screen = ref<ScreenName>('Home')
const currentSong = ref<Song | null>(null)
eventBus.on({
ACTIVATE_SCREEN (screenName: ScreenName, data: any) {
screenProps.value = data
screen.value = screenName
},
router.onRouteChanged(route => (screen.value = route.screen))
eventBus.on({
TOGGLE_VISUALIZER: () => (showingVisualizer.value = !showingVisualizer.value),
SONG_STARTED: (song: Song) => (currentSong.value = song)
})
onMounted(() => router.resolveRoute())
onMounted(() => router.resolve())
</script>
<style lang="scss">
@ -93,6 +90,7 @@ onMounted(() => router.resolveRoute())
display: flex;
flex-direction: column;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
.main-scroll-wrap {
&:not(.song-list-wrap) {

View file

@ -94,12 +94,12 @@ import { ref } from 'vue'
import { eventBus, requireInjection } from '@/utils'
import { queueStore } from '@/stores'
import { useAuthorization, useDroppable, useThirdPartyServices } from '@/composables'
import { ActiveScreenKey } from '@/symbols'
import { RouterKey } from '@/symbols'
import PlaylistList from '@/components/playlist/PlaylistSidebarList.vue'
const showing = ref(!isMobile.phone)
const activeScreen = requireInjection(ActiveScreenKey, ref('Home'))
const activeScreen = ref<ScreenName>()
const droppableToQueue = ref(false)
const { acceptsDrop, resolveDroppedSongs } = useDroppable(['songs', 'album', 'artist', 'playlist'])
@ -128,12 +128,18 @@ const onQueueDrop = async (event: DragEvent) => {
return false
}
eventBus.on({
/**
* On mobile, hide the sidebar whenever a screen is activated.
*/
ACTIVATE_SCREEN: () => isMobile.phone && (showing.value = false),
const router = requireInjection(RouterKey)
router.onRouteChanged(route => {
// On mobile, hide the sidebar whenever a screen is activated.
if (isMobile.phone) {
showing.value = false
}
activeScreen.value = route.screen
})
eventBus.on({
/**
* Listen to toggle sidebar event to show or hide the sidebar.
* This should only be triggered on a mobile device.

View file

@ -31,11 +31,12 @@
import { ref } from 'vue'
import { playlistStore } from '@/stores'
import { logger, requireInjection } from '@/utils'
import { DialogBoxKey, MessageToasterKey } from '@/symbols'
import { DialogBoxKey, MessageToasterKey, RouterKey } from '@/symbols'
import SoundBars from '@/components/ui/SoundBars.vue'
import Btn from '@/components/ui/Btn.vue'
const router = requireInjection(RouterKey)
const toaster = requireInjection(MessageToasterKey)
const dialog = requireInjection(DialogBoxKey)
@ -50,9 +51,10 @@ const submit = async () => {
loading.value = true
try {
const folder = await playlistStore.store(name.value)
const playlist = await playlistStore.store(name.value)
close()
toaster.value.success(`Playlist "${folder.name}" created.`)
toaster.value.success(`Playlist "${playlist.name}" created.`)
router.go(`playlist/${playlist.id}`)
} catch (error) {
dialog.value.error('Something went wrong. Please try again.')
logger.error(error)

View file

@ -5,7 +5,6 @@ import factory from '@/__tests__/factory'
import { fireEvent, waitFor } from '@testing-library/vue'
import { playlistStore, songStore } from '@/stores'
import { playbackService } from '@/services'
import router from '@/router'
import PlaylistFolderContextMenu from './PlaylistFolderContextMenu.vue'
new class extends UnitTestCase {
@ -42,7 +41,7 @@ new class extends UnitTestCase {
const songs = factory<Song>('song', 3)
const fetchMock = this.mock(songStore, 'fetchForPlaylistFolder').mockResolvedValue(songs)
const queueMock = this.mock(playbackService, 'queueAndPlay')
const goMock = this.mock(router, 'go')
const goMock = this.mock(this.router, 'go')
const { getByText } = await this.renderComponent(folder)
await fireEvent.click(getByText('Play All'))
@ -59,7 +58,7 @@ new class extends UnitTestCase {
const songs = factory<Song>('song', 3)
const fetchMock = this.mock(songStore, 'fetchForPlaylistFolder').mockResolvedValue(songs)
const queueMock = this.mock(playbackService, 'queueAndPlay')
const goMock = this.mock(router, 'go')
const goMock = this.mock(this.router, 'go')
const { getByText } = await this.renderComponent(folder)
await fireEvent.click(getByText('Shuffle All'))

View file

@ -18,12 +18,12 @@ import { useContextMenu } from '@/composables'
import { eventBus, requireInjection } from '@/utils'
import { playlistStore, songStore } from '@/stores'
import { playbackService } from '@/services'
import { DialogBoxKey } from '@/symbols'
import router from '@/router'
import { DialogBoxKey, RouterKey } from '@/symbols'
const { context, base, ContextMenuBase, open, trigger } = useContextMenu()
const dialog = requireInjection(DialogBoxKey)
const router = requireInjection(RouterKey)
const folder = ref<PlaylistFolder>()
const playlistsInFolder = computed(() => folder.value ? playlistStore.byFolder(folder.value) : [])

View file

@ -1,8 +1,8 @@
<template>
<li
ref="el"
class="playlist"
:class="{ droppable }"
class="playlist"
data-testid="playlist-sidebar-item"
draggable="true"
@contextmenu="onContextMenu"
@ -32,13 +32,14 @@ import { faBoltLightning, faClockRotateLeft, faFile, faHeart, faMusic } from '@f
import { computed, ref, toRefs } from 'vue'
import { eventBus, pluralize, requireInjection } from '@/utils'
import { favoriteStore, playlistStore } from '@/stores'
import { MessageToasterKey } from '@/symbols'
import { MessageToasterKey, RouterKey } from '@/symbols'
import { useDraggable, useDroppable } from '@/composables'
const { startDragging } = useDraggable('playlist')
const { acceptsDrop, resolveDroppedSongs } = useDroppable(['songs', 'album', 'artist'])
const toaster = requireInjection(MessageToasterKey)
const router = requireInjection(RouterKey)
const droppable = ref(false)
const props = defineProps<{ list: PlaylistLike }>()
@ -107,8 +108,8 @@ const onDrop = async (event: DragEvent) => {
return false
}
eventBus.on('ACTIVATE_SCREEN', (screen: ScreenName, _list: PlaylistLike): void => {
switch (screen) {
router.onRouteChanged(route => {
switch (route.screen) {
case 'Favorites':
active.value = isFavoriteList(list.value)
break
@ -118,7 +119,7 @@ eventBus.on('ACTIVATE_SCREEN', (screen: ScreenName, _list: PlaylistLike): void =
break
case 'Playlist':
active.value = list.value === _list
active.value = (list.value as Playlist).id === parseInt(route.params!.id)
break
default:

View file

@ -38,11 +38,10 @@
<script lang="ts" setup>
import { faPlus } from '@fortawesome/free-solid-svg-icons'
import { nextTick, ref } from 'vue'
import { ref } from 'vue'
import { playlistStore } from '@/stores'
import { requireInjection } from '@/utils'
import { DialogBoxKey, MessageToasterKey } from '@/symbols'
import router from '@/router'
import { logger, requireInjection } from '@/utils'
import { DialogBoxKey, MessageToasterKey, RouterKey } from '@/symbols'
import { useSmartPlaylistForm } from '@/components/playlist/smart-playlist/useSmartPlaylistForm'
const {
@ -58,6 +57,7 @@ const {
const toaster = requireInjection(MessageToasterKey)
const dialog = requireInjection(DialogBoxKey)
const router = requireInjection(RouterKey)
const name = ref('')
const emit = defineEmits(['close'])
@ -74,13 +74,17 @@ const maybeClose = async () => {
const submit = async () => {
loading.value = true
const playlist = await playlistStore.store(name.value, [], collectedRuleGroups.value)
loading.value = false
close()
toaster.value.success(`Playlist "${playlist.name}" created.`)
await nextTick()
router.go(`playlist/${playlist.id}`)
try {
const playlist = await playlistStore.store(name.value, [], collectedRuleGroups.value)
close()
toaster.value.success(`Playlist "${playlist.name}" created.`)
router.go(`playlist/${playlist.id}`)
} catch (error) {
dialog.value.error('Something went wrong. Please try again.')
logger.error(error)
} finally {
loading.value = false
}
}
</script>

View file

@ -1,10 +1,8 @@
import { ref } from 'vue'
import { expect, it } from 'vitest'
import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { albumStore, preferenceStore } from '@/stores'
import { fireEvent, waitFor } from '@testing-library/vue'
import { ActiveScreenKey } from '@/symbols'
import AlbumListScreen from './AlbumListScreen.vue'
new class extends UnitTestCase {
@ -12,32 +10,29 @@ new class extends UnitTestCase {
super.beforeEach(() => this.mock(albumStore, 'paginate'))
}
private renderComponent () {
private async renderComponent () {
albumStore.state.albums = factory<Album>('album', 9)
return this.render(AlbumListScreen, {
global: {
provide: {
[<symbol>ActiveScreenKey]: ref('Albums')
}
}
})
const rendered = this.render(AlbumListScreen)
await this.router.activateRoute({ path: 'albums', screen: 'Albums' })
return rendered
}
protected test () {
it('renders', () => {
expect(this.renderComponent().getAllByTestId('album-card')).toHaveLength(9)
it('renders', async () => {
const { getAllByTestId } = await this.renderComponent()
expect(getAllByTestId('album-card')).toHaveLength(9)
})
it.each<[ArtistAlbumViewMode]>([['list'], ['thumbnails']])('sets layout from preferences', async (mode) => {
preferenceStore.albumsViewMode = mode
const { getByTestId } = this.renderComponent()
const { getByTestId } = await this.renderComponent()
await waitFor(() => expect(getByTestId('album-list').classList.contains(`as-${mode}`)).toBe(true))
})
it('switches layout', async () => {
const { getByTestId, getByTitle } = this.renderComponent()
const { getByTestId, getByTitle } = await this.renderComponent()
await fireEvent.click(getByTitle('View as list'))
await waitFor(() => expect(getByTestId('album-list').classList.contains(`as-list`)).toBe(true))

View file

@ -4,7 +4,6 @@ import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { albumStore, commonStore, songStore } from '@/stores'
import { downloadService } from '@/services'
import router from '@/router'
import { eventBus } from '@/utils'
import CloseModalBtn from '@/components/ui/BtnCloseModal.vue'
import AlbumScreen from './AlbumScreen.vue'
@ -29,10 +28,12 @@ new class extends UnitTestCase {
const songs = factory<Song>('song', 13)
const fetchSongsMock = this.mock(songStore, 'fetchForAlbum').mockResolvedValue(songs)
await this.router.activateRoute({
path: 'albums/42',
screen: 'Album'
}, { id: '42' })
const rendered = this.render(AlbumScreen, {
props: {
album: 42
},
global: {
stubs: {
CloseModalBtn,
@ -74,7 +75,7 @@ new class extends UnitTestCase {
})
it('goes back to list if album is deleted', async () => {
const goMock = this.mock(router, 'go')
const goMock = this.mock(this.router, 'go')
const byIdMock = this.mock(albumStore, 'byId', null)
await this.renderComponent()

View file

@ -12,7 +12,7 @@
<template v-slot:meta>
<a v-if="isNormalArtist" :href="`#!/artist/${album.artist_id}`" class="artist">{{ album.artist_name }}</a>
<span class="nope" v-else>{{ album.artist_name }}</span>
<span v-else class="nope">{{ album.artist_name }}</span>
<span>{{ pluralize(songs, 'song') }}</span>
<span>{{ duration }}</span>
<a v-if="useLastfm" class="info" href title="View album information" @click.prevent="showInfo">Info</a>
@ -50,13 +50,12 @@
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, onMounted, ref, toRef, toRefs } from 'vue'
import { computed, defineAsyncComponent, onMounted, ref, toRef } from 'vue'
import { eventBus, logger, pluralize, requireInjection } from '@/utils'
import { albumStore, artistStore, commonStore, songStore } from '@/stores'
import { downloadService } from '@/services'
import { useSongList } from '@/composables'
import router from '@/router'
import { DialogBoxKey } from '@/symbols'
import { DialogBoxKey, RouterKey } from '@/symbols'
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import AlbumThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
@ -67,9 +66,7 @@ const AlbumInfo = defineAsyncComponent(() => import('@/components/album/AlbumInf
const CloseModalBtn = defineAsyncComponent(() => import('@/components/ui/BtnCloseModal.vue'))
const dialog = requireInjection(DialogBoxKey)
const props = defineProps<{ album: number }>()
const { album: id } = toRefs(props)
const router = requireInjection(RouterKey)
const album = ref<Album>()
const songs = ref<Song[]>([])
@ -100,16 +97,17 @@ const isNormalArtist = computed(() => {
return !artistStore.isVarious(album.value.artist_id) && !artistStore.isUnknown(album.value.artist_id)
})
const download = () => downloadService.fromAlbum(album.value)
const download = () => downloadService.fromAlbum(album.value!)
const showInfo = () => (showingInfo.value = true)
onMounted(async () => {
const id = parseInt(router.$currentRoute.value?.params!.id)
loading.value = true
try {
[album.value, songs.value] = await Promise.all([
albumStore.resolve(id.value),
songStore.fetchForAlbum(id.value)
albumStore.resolve(id),
songStore.fetchForAlbum(id)
])
sort('track')
@ -121,10 +119,8 @@ onMounted(async () => {
}
})
eventBus.on('SONGS_UPDATED', () => {
// if the current album has been deleted, go back to the list
albumStore.byId(id.value) || router.go('albums')
})
// if the current album has been deleted, go back to the list
eventBus.on('SONGS_UPDATED', () => albumStore.byId(album.value!.id) || router.go('albums'))
</script>
<style lang="scss" scoped>

View file

@ -1,12 +1,9 @@
import { ref } from 'vue'
import { expect, it } from 'vitest'
import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { commonStore, queueStore, songStore } from '@/stores'
import { fireEvent, waitFor } from '@testing-library/vue'
import { playbackService } from '@/services'
import router from '@/router'
import { ActiveScreenKey } from '@/symbols'
import AllSongsScreen from './AllSongsScreen.vue'
new class extends UnitTestCase {
@ -16,13 +13,15 @@ new class extends UnitTestCase {
songStore.state.songs = factory<Song>('song', 20)
const fetchMock = this.mock(songStore, 'paginate').mockResolvedValue(2)
this.router.$currentRoute.value = {
screen: 'Songs',
path: '/songs'
}
const rendered = this.render(AllSongsScreen, {
global: {
stubs: {
SongList: this.stub('song-list')
},
provide: {
[<symbol>ActiveScreenKey]: ref('Songs')
}
}
})
@ -40,7 +39,7 @@ new class extends UnitTestCase {
it('shuffles', async () => {
const queueMock = this.mock(queueStore, 'fetchRandom')
const playMock = this.mock(playbackService, 'playFirstInQueue')
const goMock = this.mock(router, 'go')
const goMock = this.mock(this.router, 'go')
const { getByTitle } = await this.renderComponent()
await fireEvent.click(getByTitle('Shuffle all songs'))

View file

@ -8,7 +8,7 @@
<ThumbnailStack :thumbnails="thumbnails"/>
</template>
<template v-slot:meta v-if="totalSongCount">
<template v-if="totalSongCount" v-slot:meta>
<span>{{ pluralize(totalSongCount, 'song') }}</span>
<span>{{ totalDuration }}</span>
</template>
@ -36,11 +36,11 @@
<script lang="ts" setup>
import { computed, ref, toRef } from 'vue'
import { pluralize, secondsToHis } from '@/utils'
import { pluralize, requireInjection, secondsToHis } from '@/utils'
import { commonStore, queueStore, songStore } from '@/stores'
import { playbackService } from '@/services'
import { useScreen, useSongList } from '@/composables'
import router from '@/router'
import { RouterKey } from '@/symbols'
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
@ -65,6 +65,8 @@ const {
onScrollBreakpoint
} = useSongList(toRef(songStore.state, 'songs'), 'all-songs')
const router = requireInjection(RouterKey)
let initialized = false
const loading = ref(false)
let sortField: SongListSortField = 'title' // @todo get from query string
@ -99,7 +101,7 @@ const playAll = async (shuffle: boolean) => {
}
await playbackService.playFirstInQueue()
await router.go('queue')
router.go('queue')
}
useScreen('Songs').onScreenActivated(async () => {

View file

@ -1,10 +1,8 @@
import { ref } from 'vue'
import { expect, it } from 'vitest'
import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { artistStore, preferenceStore } from '@/stores'
import { fireEvent, waitFor } from '@testing-library/vue'
import { ActiveScreenKey } from '@/symbols'
import ArtistListScreen from './ArtistListScreen.vue'
new class extends UnitTestCase {
@ -12,32 +10,29 @@ new class extends UnitTestCase {
super.beforeEach(() => this.mock(artistStore, 'paginate'))
}
private renderComponent () {
private async renderComponent () {
artistStore.state.artists = factory<Artist>('artist', 9)
return this.render(ArtistListScreen, {
global: {
provide: {
[<symbol>ActiveScreenKey]: ref('Artists')
}
}
})
const rendered = this.render(ArtistListScreen)
await this.router.activateRoute({ path: 'artists', screen: 'Artists' })
return rendered
}
protected test () {
it('renders', () => {
expect(this.renderComponent().getAllByTestId('artist-card')).toHaveLength(9)
it('renders', async () => {
const { getAllByTestId } = await this.renderComponent()
expect(getAllByTestId('artist-card')).toHaveLength(9)
})
it.each<[ArtistAlbumViewMode]>([['list'], ['thumbnails']])('sets layout:%s from preferences', async (mode) => {
preferenceStore.artistsViewMode = mode
const { getByTestId } = this.renderComponent()
const { getByTestId } = await this.renderComponent()
await waitFor(() => expect(getByTestId('artist-list').classList.contains(`as-${mode}`)).toBe(true))
})
it('switches layout', async () => {
const { getByTestId, getByTitle } = this.renderComponent()
const { getByTestId, getByTitle } = await this.renderComponent()
await fireEvent.click(getByTitle('View as list'))
await waitFor(() => expect(getByTestId('artist-list').classList.contains(`as-list`)).toBe(true))

View file

@ -4,7 +4,6 @@ import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { artistStore, commonStore, songStore } from '@/stores'
import { downloadService } from '@/services'
import router from '@/router'
import { eventBus } from '@/utils'
import CloseModalBtn from '@/components/ui/BtnCloseModal.vue'
import ArtistScreen from './ArtistScreen.vue'
@ -28,10 +27,12 @@ new class extends UnitTestCase {
const songs = factory<Song>('song', 13)
const fetchSongsMock = this.mock(songStore, 'fetchForArtist').mockResolvedValue(songs)
await this.router.activateRoute({
path: 'artists/42',
screen: 'Artist'
}, { id: '42' })
const rendered = this.render(ArtistScreen, {
props: {
artist: 42
},
global: {
stubs: {
CloseModalBtn,
@ -73,7 +74,7 @@ new class extends UnitTestCase {
})
it('goes back to list if artist is deleted', async () => {
const goMock = this.mock(router, 'go')
const goMock = this.mock(this.router, 'go')
const byIdMock = this.mock(artistStore, 'byId', null)
await this.renderComponent()

View file

@ -50,23 +50,23 @@
</template>
<script lang="ts" setup>
import { defineAsyncComponent, onMounted, ref, toRef, toRefs } from 'vue'
import { defineAsyncComponent, onMounted, ref, toRef } from 'vue'
import { eventBus, logger, pluralize, requireInjection } from '@/utils'
import { artistStore, commonStore, songStore } from '@/stores'
import { downloadService } from '@/services'
import { useSongList, useThirdPartyServices } from '@/composables'
import router from '@/router'
import { DialogBoxKey } from '@/symbols'
import { DialogBoxKey, RouterKey } from '@/symbols'
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import ArtistThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
import ScreenHeaderSkeleton from '@/components/ui/skeletons/ScreenHeaderSkeleton.vue'
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
const dialog = requireInjection(DialogBoxKey)
const ArtistInfo = defineAsyncComponent(() => import('@/components/artist/ArtistInfo.vue'))
const CloseModalBtn = defineAsyncComponent(() => import('@/components/ui/BtnCloseModal.vue'))
const props = defineProps<{ artist: number }>()
const { artist: id } = toRefs(props)
const dialog = requireInjection(DialogBoxKey)
const router = requireInjection(RouterKey)
const artist = ref<Artist>()
const songs = ref<Song[]>([])
@ -89,22 +89,20 @@ const {
onScrollBreakpoint
} = useSongList(songs, 'artist', { columns: ['track', 'title', 'album', 'length'] })
const ArtistInfo = defineAsyncComponent(() => import('@/components/artist/ArtistInfo.vue'))
const CloseModalBtn = defineAsyncComponent(() => import('@/components/ui/BtnCloseModal.vue'))
const { useLastfm } = useThirdPartyServices()
const allowDownload = toRef(commonStore.state, 'allow_download')
const download = () => downloadService.fromArtist(artist.value)
const download = () => downloadService.fromArtist(artist.value!)
const showInfo = () => (showingInfo.value = true)
onMounted(async () => {
const id = parseInt(router.$currentRoute.value!.params!.id)
loading.value = true
try {
[artist.value, songs.value] = await Promise.all([
artistStore.resolve(id.value),
songStore.fetchForArtist(id.value)
artistStore.resolve(id),
songStore.fetchForArtist(id)
])
} catch (e) {
logger.error(e)
@ -114,10 +112,8 @@ onMounted(async () => {
}
})
eventBus.on('SONGS_UPDATED', () => {
// if the current artist has been deleted, go back to the list
artistStore.byId(id.value) || router.go('artists')
})
// if the current artist has been deleted, go back to the list
eventBus.on('SONGS_UPDATED', () => artistStore.byId(artist.value!.id) || router.go('artists'))
</script>
<style lang="scss" scoped>

View file

@ -1,22 +1,16 @@
import { ref } from 'vue'
import { waitFor } from '@testing-library/vue'
import { expect, it } from 'vitest'
import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { favoriteStore } from '@/stores'
import { ActiveScreenKey } from '@/symbols'
import FavoritesScreen from './FavoritesScreen.vue'
new class extends UnitTestCase {
private async renderComponent () {
const fetchMock = this.mock(favoriteStore, 'fetch')
const rendered = this.render(FavoritesScreen, {
global: {
provide: {
[<symbol>ActiveScreenKey]: ref('Favorites')
}
}
})
const rendered = this.render(FavoritesScreen)
await this.router.activateRoute({ path: 'favorites', screen: 'Favorites' })
await waitFor(() => expect(fetchMock).toHaveBeenCalled())

View file

@ -1,33 +1,28 @@
import { ref } from 'vue'
import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { commonStore, overviewStore } from '@/stores'
import { ActiveScreenKey } from '@/symbols'
import { EventName } from '@/config'
import { eventBus } from '@/utils'
import HomeScreen from './HomeScreen.vue'
new class extends UnitTestCase {
private renderComponent () {
return this.render(HomeScreen, {
global: {
provide: {
[<symbol>ActiveScreenKey]: ref('Home')
}
}
})
private async renderComponent () {
const rendered = this.render(HomeScreen)
await this.router.activateRoute({ path: 'home', screen: 'Home' })
return rendered
}
protected test () {
it('renders an empty state if no songs found', () => {
it('renders an empty state if no songs found', async () => {
commonStore.state.song_length = 0
this.renderComponent().getByTestId('screen-empty-state')
const { getByTestId } = await this.render(HomeScreen)
getByTestId('screen-empty-state')
})
it('renders overview components if applicable', () => {
it('renders overview components if applicable', async () => {
commonStore.state.song_length = 100
const { getByTestId, queryByTestId } = this.renderComponent()
const { getByTestId, queryByTestId } = await this.renderComponent()
;[
'most-played-songs',
@ -42,9 +37,9 @@ new class extends UnitTestCase {
})
it.each<[EventName]>([['SONGS_UPDATED'], ['SONGS_DELETED']])
('refreshes the overviews on %s event', (eventName) => {
('refreshes the overviews on %s event', async (eventName) => {
const refreshMock = this.mock(overviewStore, 'refresh')
this.renderComponent()
await this.renderComponent()
eventBus.emit(eventName)

View file

@ -3,7 +3,7 @@ import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { eventBus } from '@/utils'
import { fireEvent, getByTestId, waitFor } from '@testing-library/vue'
import { songStore } from '@/stores'
import { playlistStore, songStore } from '@/stores'
import { downloadService } from '@/services'
import PlaylistScreen from './PlaylistScreen.vue'
@ -12,10 +12,16 @@ let playlist: Playlist
new class extends UnitTestCase {
private async renderComponent (songs: Song[]) {
playlist = playlist || factory<Playlist>('playlist')
playlistStore.init([playlist])
const fetchMock = this.mock(songStore, 'fetchForPlaylist').mockResolvedValue(songs)
const rendered = this.render(PlaylistScreen)
eventBus.emit('ACTIVATE_SCREEN', 'Playlist', playlist)
await this.router.activateRoute({
path: `playlists/${playlist.id}`,
screen: 'Playlist'
}, { id: playlist.id.toString() })
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(playlist))

View file

@ -1,5 +1,5 @@
<template>
<section id="playlistWrapper" v-if="playlist">
<section v-if="playlist" id="playlistWrapper">
<ScreenHeader :layout="songs.length === 0 ? 'collapsed' : headerLayout">
{{ playlist.name }}
<ControlsToggle v-if="songs.length" v-model="showingControls"/>
@ -8,7 +8,7 @@
<ThumbnailStack :thumbnails="thumbnails"/>
</template>
<template v-slot:meta v-if="songs.length">
<template v-if="songs.length" v-slot:meta>
<span>{{ pluralize(songs, 'song') }}</span>
<span>{{ duration }}</span>
<a
@ -37,10 +37,10 @@
<SongList
v-if="!loading && songs.length"
ref="songList"
@sort="sort"
@press:delete="removeSelected"
@press:enter="onPressEnter"
@scroll-breakpoint="onScrollBreakpoint"
@sort="sort"
/>
<ScreenEmptyState v-if="!songs.length && !loading">
@ -70,13 +70,14 @@ import { eventBus, pluralize, requireInjection } from '@/utils'
import { commonStore, playlistStore, songStore } from '@/stores'
import { downloadService } from '@/services'
import { useSongList } from '@/composables'
import { MessageToasterKey } from '@/symbols'
import { MessageToasterKey, RouterKey } from '@/symbols'
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
const toaster = requireInjection(MessageToasterKey)
const router = requireInjection(RouterKey)
const playlist = ref<Playlist>()
const loading = ref(false)
@ -123,15 +124,24 @@ const fetchSongs = async () => {
sort()
}
eventBus.on({
ACTIVATE_SCREEN: async (screen: ScreenName, p: any) => {
if (screen === 'Playlist') {
playlist.value = p as Playlist
await fetchSongs()
}
},
router.onRouteChanged(async (route) => {
if (route.screen !== 'Playlist') return
const id = parseInt(route.params!.id)
SMART_PLAYLIST_UPDATED: async (updated: Playlist) => updated === playlist.value && await fetchSongs()
if (id === playlist.value?.id) return
const _playlist = playlistStore.byId(id)
if (!_playlist) {
await router.triggerNotFound()
return
}
)
playlist.value = _playlist
await fetchSongs()
})
eventBus.on({
SMART_PLAYLIST_UPDATED: async (updated: Playlist) => updated === playlist.value && await fetchSongs()
})
</script>

View file

@ -8,7 +8,7 @@
<ThumbnailStack :thumbnails="thumbnails"/>
</template>
<template v-slot:meta v-if="songs.length">
<template v-if="songs.length" v-slot:meta>
<span>{{ pluralize(songs, 'song') }}</span>
<span>{{ duration }}</span>
</template>
@ -16,10 +16,10 @@
<template v-slot:controls>
<SongListControls
v-if="songs.length && (!isPhone || showingControls)"
:config="controlConfig"
@clearQueue="clearQueue"
@playAll="playAll"
@playSelected="playSelected"
@clearQueue="clearQueue"
:config="controlConfig"
/>
</template>
</ScreenHeader>
@ -28,9 +28,9 @@
<SongList
v-if="songs.length"
ref="songList"
@reorder="onReorder"
@press:delete="removeSelected"
@press:enter="onPressEnter"
@reorder="onReorder"
@scroll-breakpoint="onScrollBreakpoint"
/>
@ -40,9 +40,9 @@
</template>
No songs queued.
<span class="d-block secondary" v-if="libraryNotEmpty">
<span v-if="libraryNotEmpty" class="d-block secondary">
How about
<a data-testid="shuffle-library" class="start" @click.prevent="shuffleSome">playing some random songs</a>?
<a class="start" data-testid="shuffle-library" @click.prevent="shuffleSome">playing some random songs</a>?
</span>
</ScreenEmptyState>
</section>
@ -55,13 +55,15 @@ import { eventBus, logger, pluralize, requireInjection } from '@/utils'
import { commonStore, queueStore, songStore } from '@/stores'
import { playbackService } from '@/services'
import { useSongList } from '@/composables'
import { DialogBoxKey } from '@/symbols'
import { DialogBoxKey, RouterKey } from '@/symbols'
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
const dialog = requireInjection(DialogBoxKey)
const router = requireInjection(RouterKey)
const controlConfig: Partial<SongListControlsConfig> = { clearQueue: true }
const {
@ -84,7 +86,10 @@ const {
const loading = ref(false)
const libraryNotEmpty = computed(() => commonStore.state.song_count > 0)
const playAll = (shuffle = true) => playbackService.queueAndPlay(songs.value, shuffle)
const playAll = async (shuffle = true) => {
await playbackService.queueAndPlay(songs.value, shuffle)
router.go('queue')
}
const shuffleSome = async () => {
try {

View file

@ -1,10 +1,8 @@
import { ref } from 'vue'
import { expect, it } from 'vitest'
import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { recentlyPlayedStore } from '@/stores'
import { waitFor } from '@testing-library/vue'
import { ActiveScreenKey } from '@/symbols'
import RecentlyPlayedScreen from './RecentlyPlayedScreen.vue'
new class extends UnitTestCase {
@ -16,13 +14,12 @@ new class extends UnitTestCase {
global: {
stubs: {
SongList: this.stub('song-list')
},
provide: {
[<symbol>ActiveScreenKey]: ref('RecentlyPlayed')
}
}
})
await this.router.activateRoute({ path: 'recently-played', screen: 'RecentlyPlayed' })
await waitFor(() => expect(fetchMock).toHaveBeenCalled())
return rendered

View file

@ -3,7 +3,6 @@ import UnitTestCase from '@/__tests__/UnitTestCase'
import SettingsScreen from './SettingsScreen.vue'
import { settingStore } from '@/stores'
import { fireEvent, waitFor } from '@testing-library/vue'
import router from '@/router'
import { DialogBoxStub } from '@/__tests__/stubs'
new class extends UnitTestCase {
@ -12,7 +11,7 @@ new class extends UnitTestCase {
it('submits the settings form', async () => {
const updateMock = this.mock(settingStore, 'update')
const goMock = this.mock(router, 'go')
const goMock = this.mock(this.router, 'go')
settingStore.state.media_path = ''
const { getByLabelText, getByText } = this.render(SettingsScreen)
@ -28,7 +27,7 @@ new class extends UnitTestCase {
it('confirms upon media path change', async () => {
const updateMock = this.mock(settingStore, 'update')
const goMock = this.mock(router, 'go')
const goMock = this.mock(this.router, 'go')
const confirmMock = this.mock(DialogBoxStub.value, 'confirm')
settingStore.state.media_path = '/old'

View file

@ -32,14 +32,15 @@
import { computed, ref } from 'vue'
import { settingStore } from '@/stores'
import { forceReloadWindow, hideOverlay, parseValidationError, requireInjection, showOverlay } from '@/utils'
import router from '@/router'
import { DialogBoxKey, MessageToasterKey } from '@/symbols'
import { DialogBoxKey, MessageToasterKey, RouterKey } from '@/symbols'
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import Btn from '@/components/ui/Btn.vue'
const router = requireInjection(RouterKey)
const toaster = requireInjection(MessageToasterKey)
const dialog = requireInjection(DialogBoxKey)
const mediaPath = ref(settingStore.state.media_path)
const originalMediaPath = mediaPath.value

View file

@ -2,7 +2,6 @@ import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase'
import factory from '@/__tests__/factory'
import { fireEvent } from '@testing-library/vue'
import router from '@/router'
import { overviewStore } from '@/stores'
import RecentlyPlayedSongs from './RecentlyPlayedSongs.vue'
@ -14,7 +13,7 @@ new class extends UnitTestCase {
})
it('goes to dedicated screen', async () => {
const mock = this.mock(router, 'go')
const mock = this.mock(this.router, 'go')
const { getByTestId } = this.render(RecentlyPlayedSongs)
await fireEvent.click(getByTestId('home-view-all-recently-played-btn'))

View file

@ -32,13 +32,16 @@
<script lang="ts" setup>
import { toRef, toRefs } from 'vue'
import router from '@/router'
import { overviewStore } from '@/stores'
import { RouterKey } from '@/symbols'
import { requireInjection } from '@/utils'
import Btn from '@/components/ui/Btn.vue'
import SongCard from '@/components/song/SongCard.vue'
import SongCardSkeleton from '@/components/ui/skeletons/SongCardSkeleton.vue'
const router = requireInjection(RouterKey)
const props = withDefaults(defineProps<{ loading?: boolean }>(), { loading: false })
const { loading } = toRefs(props)

View file

@ -6,7 +6,7 @@
</ScreenHeader>
<div ref="wrapper" class="main-scroll-wrap">
<div class="results" v-if="q">
<div v-if="q" class="results">
<section class="songs" data-testid="song-excerpts">
<h1>
Songs
@ -86,9 +86,9 @@
import { faSearch } from '@fortawesome/free-solid-svg-icons'
import { intersectionBy } from 'lodash'
import { ref, toRef } from 'vue'
import { eventBus } from '@/utils'
import { eventBus, requireInjection } from '@/utils'
import { searchStore } from '@/stores'
import router from '@/router'
import { RouterKey } from '@/symbols'
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
@ -99,11 +99,13 @@ import SongCard from '@/components/song/SongCard.vue'
import SongCardSkeleton from '@/components/ui/skeletons/SongCardSkeleton.vue'
import ArtistAlbumCardSkeleton from '@/components/ui/skeletons/ArtistAlbumCardSkeleton.vue'
const router = requireInjection(RouterKey)
const excerpt = toRef(searchStore.state, 'excerpt')
const q = ref('')
const searching = ref(false)
const goToSongResults = () => router.go(`search/songs/${q.value}`)
const goToSongResults = () => router.go(`search/songs/?q=${q.value}`)
const doSearch = async () => {
searching.value = true

View file

@ -8,11 +8,9 @@ new class extends UnitTestCase {
it('searches for prop query on created', () => {
const resetResultMock = this.mock(searchStore, 'resetSongResultState')
const searchMock = this.mock(searchStore, 'songSearch')
this.render(SearchSongResultsScreen, {
props: {
q: 'search me'
}
})
this.router.activateRoute({ path: 'search-songs', screen: 'Search.Songs' }, { q: 'search me' })
this.render(SearchSongResultsScreen)
expect(resetResultMock).toHaveBeenCalled()
expect(searchMock).toHaveBeenCalledWith('search me')

View file

@ -8,7 +8,7 @@
<ThumbnailStack :thumbnails="thumbnails"/>
</template>
<template v-slot:meta v-if="songs.length">
<template v-if="songs.length" v-slot:meta>
<span>{{ pluralize(songs, 'song') }}</span>
<span>{{ duration }}</span>
</template>
@ -28,16 +28,17 @@
</template>
<script lang="ts" setup>
import { computed, onMounted, ref, toRef, toRefs } from 'vue'
import { computed, onMounted, ref, toRef } from 'vue'
import { searchStore } from '@/stores'
import { useSongList } from '@/composables'
import { pluralize } from '@/utils'
import { pluralize, requireInjection } from '@/utils'
import { RouterKey } from '@/symbols'
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
const props = defineProps<{ q: string }>()
const { q } = toRefs(props)
const router = requireInjection(RouterKey)
const q = ref('')
const {
SongList,
@ -64,6 +65,9 @@ const loading = ref(false)
searchStore.resetSongResultState()
onMounted(async () => {
q.value = router.$currentRoute.value?.params?.q || ''
if (!q.value) return
loading.value = true
await searchStore.songSearch(q.value)
loading.value = false

View file

@ -79,12 +79,13 @@ import { computed, nextTick, ref, toRef, toRefs, watch } from 'vue'
import { pluralize, requireInjection } from '@/utils'
import { playlistStore, queueStore } from '@/stores'
import { useSongMenuMethods } from '@/composables'
import router from '@/router'
import { MessageToasterKey } from '@/symbols'
import { MessageToasterKey, RouterKey } from '@/symbols'
import Btn from '@/components/ui/Btn.vue'
const toaster = requireInjection(MessageToasterKey)
const router = requireInjection(RouterKey)
const props = defineProps<{ songs: Song[], showing: Boolean, config: AddToMenuConfig }>()
const { songs, showing, config } = toRefs(props)

View file

@ -3,7 +3,6 @@ import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { arrayify, eventBus } from '@/utils'
import { fireEvent, waitFor } from '@testing-library/vue'
import router from '@/router'
import { downloadService, playbackService } from '@/services'
import { favoriteStore, playlistStore, queueStore, songStore } from '@/stores'
import { DialogBoxStub, MessageToasterStub } from '@/__tests__/stubs'
@ -63,7 +62,7 @@ new class extends UnitTestCase {
})
it('goes to album details screen', async () => {
const goMock = this.mock(router, 'go')
const goMock = this.mock(this.router, 'go')
const { getByText } = await this.renderComponent(factory<Song>('song'))
await fireEvent.click(getByText('Go to Album'))
@ -72,7 +71,7 @@ new class extends UnitTestCase {
})
it('goes to artist details screen', async () => {
const goMock = this.mock(router, 'go')
const goMock = this.mock(this.router, 'go')
const { getByText } = await this.renderComponent(factory<Song>('song'))
await fireEvent.click(getByText('Go to Artist'))

View file

@ -19,7 +19,7 @@
<li v-else @click="queueSongsToBottom">Queue</li>
<li class="separator"/>
<li @click="addSongsToFavorite">Favorites</li>
<li class="separator" v-if="normalPlaylists.length"/>
<li v-if="normalPlaylists.length" class="separator"/>
<li v-for="p in normalPlaylists" :key="p.id" @click="addSongsToExistingPlaylist(p)">{{ p.name }}</li>
</ul>
</li>
@ -38,14 +38,15 @@ import { computed, ref, toRef } from 'vue'
import { arrayify, copyText, eventBus, pluralize, requireInjection } from '@/utils'
import { commonStore, playlistStore, queueStore, songStore, userStore } from '@/stores'
import { downloadService, playbackService } from '@/services'
import router from '@/router'
import { useAuthorization, useContextMenu, useSongMenuMethods } from '@/composables'
import { DialogBoxKey, MessageToasterKey } from '@/symbols'
import { DialogBoxKey, MessageToasterKey, RouterKey } from '@/symbols'
const { context, base, ContextMenuBase, open, close, trigger } = useContextMenu()
const dialogBox = requireInjection(DialogBoxKey)
const toaster = requireInjection(MessageToasterKey)
const router = requireInjection(RouterKey)
const songs = ref<Song[]>([])
const {

View file

@ -28,11 +28,12 @@ import { albumStore, artistStore, queueStore, songStore, userStore } from '@/sto
import { playbackService } from '@/services'
import { defaultCover, fileReader, logger, requireInjection } from '@/utils'
import { useAuthorization } from '@/composables'
import { MessageToasterKey } from '@/symbols'
import { MessageToasterKey, RouterKey } from '@/symbols'
const VALID_IMAGE_TYPES = ['image/jpeg', 'image/gif', 'image/png', 'image/webp']
const toaster = requireInjection(MessageToasterKey)
const router = requireInjection(RouterKey)
const props = defineProps<{ entity: Album | Artist }>()
const { entity } = toRefs(props)
@ -72,6 +73,7 @@ const playOrQueue = async (event: KeyboardEvent) => {
}
await playbackService.queueAndPlay(songs)
router.go('queue')
}
const onDragEnter = () => (droppable.value = allowsUpload.value)

View file

@ -2,7 +2,7 @@
<div id="equalizer" data-testid="equalizer" ref="root">
<div class="presets">
<label class="select-wrapper">
<select v-model="selectedPresetId">
<select v-model="selectedPresetId" title="Select equalizer">
<option disabled value="-1">Preset</option>
<option v-for="preset in presets" :value="preset.id" :key="preset.id" v-once>{{ preset.name }}</option>
</select>

View file

@ -1,5 +1,4 @@
import { expect, it } from 'vitest'
import router from '@/router'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { fireEvent } from '@testing-library/vue'
import { eventBus } from '@/utils'
@ -16,7 +15,7 @@ new class extends UnitTestCase {
})
it('goes to search screen when search box is focused', async () => {
const mock = this.mock(router, 'go')
const mock = this.mock(this.router, 'go')
const { getByRole } = this.render(SearchForm)
await fireEvent.focus(getByRole('searchbox'))

View file

@ -18,8 +18,10 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { debounce } from 'lodash'
import { eventBus } from '@/utils'
import router from '@/router'
import { eventBus, requireInjection } from '@/utils'
import { RouterKey } from '@/symbols'
const router = requireInjection(RouterKey)
const input = ref<HTMLInputElement>()
const q = ref('')

View file

@ -11,13 +11,20 @@
<script lang="ts" setup>
import { computed, toRefs } from 'vue'
import { youTubeService } from '@/services'
import { RouterKey } from '@/symbols'
import { requireInjection } from '@/utils'
const router = requireInjection(RouterKey)
const props = defineProps<{ video: YouTubeVideo }>()
const { video } = toRefs(props)
const url = computed(() => `https://youtu.be/${video.value.id.videoId}`)
const play = () => youTubeService.play(video.value)
const play = () => {
youTubeService.play(video.value)
router.go('youtube')
}
</script>
<style lang="scss" scoped>

View file

@ -1,8 +1,7 @@
import { expect, it } from 'vitest'
import { UploadFile, UploadStatus } from '@/config'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { fireEvent } from '@testing-library/vue'
import { uploadService } from '@/services'
import { UploadFile, uploadService, UploadStatus } from '@/services'
import Btn from '@/components/ui/Btn.vue'
import UploadItem from './UploadItem.vue'

View file

@ -19,8 +19,7 @@
import slugify from 'slugify'
import { faRotateBack, faTimes } from '@fortawesome/free-solid-svg-icons'
import { computed, defineAsyncComponent, toRefs } from 'vue'
import { UploadFile } from '@/config'
import { uploadService } from '@/services'
import { UploadFile, uploadService } from '@/services'
const Btn = defineAsyncComponent(() => import('@/components/ui/Btn.vue'))

View file

@ -2,7 +2,6 @@ import { expect, it } from 'vitest'
import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { fireEvent } from '@testing-library/vue'
import router from '@/router'
import { eventBus } from '@/utils'
import UserCard from './UserCard.vue'
@ -35,7 +34,7 @@ new class extends UnitTestCase {
})
it('redirects to Profile screen if edit current user', async () => {
const mock = this.mock(router, 'go')
const mock = this.mock(this.router, 'go')
const user = factory<User>('user')
const { getByText } = this.actingAs(user).renderComponent(user)

View file

@ -17,10 +17,10 @@
<p class="email text-secondary">{{ user.email }}</p>
<footer>
<Btn class="btn-edit" data-testid="edit-user-btn" small orange @click="edit">
<Btn class="btn-edit" data-testid="edit-user-btn" orange small @click="edit">
{{ isCurrentUser ? 'Your Profile' : 'Edit' }}
</Btn>
<Btn v-if="!isCurrentUser" class="btn-delete" data-testid="delete-user-btn" small red @click="confirmDelete">
<Btn v-if="!isCurrentUser" class="btn-delete" data-testid="delete-user-btn" red small @click="confirmDelete">
Delete
</Btn>
</footer>
@ -34,13 +34,13 @@ import { computed, toRefs } from 'vue'
import { userStore } from '@/stores'
import { eventBus, requireInjection } from '@/utils'
import { useAuthorization } from '@/composables'
import { DialogBoxKey, MessageToasterKey } from '@/symbols'
import router from '@/router'
import { DialogBoxKey, MessageToasterKey, RouterKey } from '@/symbols'
import Btn from '@/components/ui/Btn.vue'
const toaster = requireInjection(MessageToasterKey)
const dialog = requireInjection(DialogBoxKey)
const router = requireInjection(RouterKey)
const props = defineProps<{ user: User }>()
const { user } = toRefs(props)

View file

@ -7,14 +7,14 @@
* Global event listeners (basically, those without a Vue instance access) go here.
*/
import isMobile from 'ismobilejs'
import router from '@/router'
import { authService } from '@/services'
import { playlistFolderStore, playlistStore, preferenceStore, userStore } from '@/stores'
import { eventBus, forceReloadWindow, requireInjection } from '@/utils'
import { DialogBoxKey, MessageToasterKey } from '@/symbols'
import { DialogBoxKey, MessageToasterKey, RouterKey } from '@/symbols'
const toaster = requireInjection(MessageToasterKey)
const dialog = requireInjection(DialogBoxKey)
const router = requireInjection(RouterKey)
eventBus.on({
PLAYLIST_DELETE: async (playlist: Playlist) => {
@ -42,11 +42,13 @@ eventBus.on({
forceReloadWindow()
},
KOEL_READY: () => router.resolveRoute(),
KOEL_READY: () => router.resolve()
})
/**
* Hide the panel away if a main view is triggered on mobile.
*/
ACTIVATE_SCREEN: () => isMobile.phone && (preferenceStore.showExtraPanel = false)
router.onRouteChanged(() => {
// Hide the extra panel away if a main view is triggered on mobile.
if (isMobile.phone) {
preferenceStore.showExtraPanel = false
}
})
</script>

View file

@ -1,16 +1,11 @@
import { ref, watch } from 'vue'
import { RouterKey } from '@/symbols'
import { requireInjection } from '@/utils'
import { ActiveScreenKey } from '@/symbols'
export const useScreen = (currentScreen: ScreenName) => {
const activeScreen = requireInjection(ActiveScreenKey, ref('Home'))
const onScreenActivated = (cb: Closure) => watch(activeScreen, screen => screen === currentScreen && cb(), {
immediate: true
})
export const useScreen = (screen: ScreenName) => {
const router = requireInjection(RouterKey)
const onScreenActivated = (cb: Closure) => router.onRouteChanged(route => route.screen === screen && cb())
return {
activeScreen,
onScreenActivated
}
}

View file

@ -3,7 +3,7 @@ import isMobile from 'ismobilejs'
import { computed, reactive, Ref, ref } from 'vue'
import { playbackService } from '@/services'
import { queueStore, songStore } from '@/stores'
import router from '@/router'
import { eventBus, provideReadonly, requireInjection } from '@/utils'
import {
SelectedSongsKey,
@ -11,16 +11,18 @@ import {
SongListSortFieldKey,
SongListSortOrderKey,
SongListTypeKey,
SongsKey
SongsKey,
RouterKey
} from '@/symbols'
import ControlsToggle from '@/components/ui/ScreenControlsToggle.vue'
import SongList from '@/components/song/SongList.vue'
import SongListControls from '@/components/song/SongListControls.vue'
import ThumbnailStack from '@/components/ui/ThumbnailStack.vue'
import { eventBus, provideReadonly } from '@/utils'
export const useSongList = (songs: Ref<Song[]>, type: SongListType, config: Partial<SongListConfig> = {}) => {
const router = requireInjection(RouterKey)
const songList = ref<InstanceType<typeof SongList>>()
const isPhone = isMobile.phone
@ -61,7 +63,7 @@ export const useSongList = (songs: Ref<Song[]>, type: SongListType, config: Part
await playbackService.play(selectedSongs.value[0])
}
router.go('/queue')
router.go('queue')
}
const sortField = ref<SongListSortField | null>(((): SongListSortField | null => {

View file

@ -1,18 +1,18 @@
import isMobile from 'ismobilejs'
import { computed, ref, toRef } from 'vue'
import { useAuthorization } from '@/composables/useAuthorization'
import { computed, toRef } from 'vue'
import { useAuthorization } from '@/composables'
import { settingStore } from '@/stores'
import { acceptedMediaTypes, UploadFile } from '@/config'
import { uploadService } from '@/services'
import { acceptedMediaTypes } from '@/config'
import { UploadFile, uploadService } from '@/services'
import { getAllFileEntries, pluralize, requireInjection } from '@/utils'
import { ActiveScreenKey, MessageToasterKey } from '@/symbols'
import router from '@/router'
import { MessageToasterKey, RouterKey } from '@/symbols'
export const useUpload = () => {
const { isAdmin } = useAuthorization()
const activeScreen = requireInjection(ActiveScreenKey, ref('Home'))
const toaster = requireInjection(MessageToasterKey)
const router = requireInjection(RouterKey)
const mediaPath = toRef(settingStore.state, 'media_path')
const mediaPathSetUp = computed(() => Boolean(mediaPath.value))
@ -47,7 +47,7 @@ export const useUpload = () => {
if (queuedFiles.length) {
toaster.value.success(`Queued ${pluralize(queuedFiles, 'file')} for upload`)
activeScreen.value === 'Upload' || router.go('upload')
router.$currentRoute.value.screen === 'Upload' || router.go('upload')
} else {
toaster.value.warning('No files applicable for upload')
}

View file

@ -1,6 +1,5 @@
export type EventName =
'KOEL_READY'
| 'ACTIVATE_SCREEN'
| 'LOG_OUT'
| 'TOGGLE_SIDEBAR'
| 'SHOW_OVERLAY'

View file

@ -1,4 +1,4 @@
export * from './events'
export * from './upload.types'
export * from './acceptedMediaTypes'
export * from './genres'
export * from './routes'

View file

@ -0,0 +1,93 @@
import { eventBus } from '@/utils'
import { Route } from '@/router'
import { userStore } from '@/stores'
const queueRoute: Route = {
path: '/queue',
screen: 'Queue'
}
export const routes: Route[] = [
queueRoute,
{
path: '/home',
screen: 'Home'
},
{
path: '/404',
screen: '404'
},
{
path: '/songs',
screen: 'Songs'
},
{
path: '/albums',
screen: 'Albums'
},
{
path: '/artists',
screen: 'Artists'
},
{
path: '/favorites',
screen: 'Favorites'
},
{
path: '/recently-played',
screen: 'RecentlyPlayed'
},
{
path: '/search',
screen: 'Search.Excerpt'
},
{
path: '/search/songs',
screen: 'Search.Songs'
},
{
path: '/upload',
screen: 'Upload',
onBeforeEnter: () => userStore.current.is_admin
},
{
path: '/settings',
screen: 'Settings',
onBeforeEnter: () => userStore.current.is_admin
},
{
path: '/users',
screen: 'Users',
onBeforeEnter: () => userStore.current.is_admin
},
{
path: '/youtube',
screen: 'YouTube'
},
{
path: '/profile',
screen: 'Profile'
},
{
path: 'visualizer',
screen: 'Visualizer'
},
{
path: '/album/(?<id>\\d+)',
screen: 'Album'
},
{
path: '/artist/(?<id>\\d+)',
screen: 'Artist'
},
{
path: '/playlist/(?<id>\\d+)',
screen: 'Playlist'
},
{
path: '/song/(?<id>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})',
screen: 'Queue',
redirect: () => queueRoute,
onBeforeEnter: params => eventBus.emit('SONG_QUEUED_FROM_ROUTE', params.id)
}
]

View file

@ -1,15 +0,0 @@
export type UploadStatus =
| 'Ready'
| 'Uploading'
| 'Uploaded'
| 'Canceled'
| 'Errored'
export interface UploadFile {
id: string
file: File
status: UploadStatus
name: string
progress: number
message?: string
}

View file

@ -1,68 +1,94 @@
import { eventBus, loadMainView, use } from '@/utils'
import { playlistStore, userStore } from '@/stores'
import { ref, Ref, watch } from 'vue'
class Router {
routes: Record<string, Closure>
paths: string[]
type RouteParams = Record<string, string>
type BeforeEnterHook = (params: RouteParams) => boolean | void
type EnterHook = (params: RouteParams) => any
type RedirectHook = (params: RouteParams) => Route
constructor () {
this.routes = {
'/home': () => loadMainView('Home'),
'/queue': () => loadMainView('Queue'),
'/songs': () => loadMainView('Songs'),
'/albums': () => loadMainView('Albums'),
'/artists': () => loadMainView('Artists'),
'/favorites': () => loadMainView('Favorites'),
'/recently-played': () => loadMainView('RecentlyPlayed'),
'/search': () => loadMainView('Search.Excerpt'),
'/search/songs/(.+)': (q: string) => loadMainView('Search.Songs', q),
'/upload': () => userStore.current.is_admin && loadMainView('Upload'),
'/settings': () => userStore.current.is_admin && loadMainView('Settings'),
'/users': () => userStore.current.is_admin && loadMainView('Users'),
'/youtube': () => loadMainView('YouTube'),
'/visualizer': () => loadMainView('Visualizer'),
'/profile': () => loadMainView('Profile'),
'/album/(\\d+)': (id: string) => loadMainView('Album', parseInt(id)),
'/artist/(\\d+)': (id: string) => loadMainView('Artist', parseInt(id)),
'/playlist/(\\d+)': (id: string) => use(playlistStore.byId(~~id), playlist => loadMainView('Playlist', playlist)),
'/song/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})': (id: string) => {
eventBus.emit('SONG_QUEUED_FROM_ROUTE', id)
loadMainView('Queue')
export type Route = {
path: string
screen: ScreenName
params?: RouteParams
redirect?: RedirectHook
onBeforeEnter?: BeforeEnterHook
onEnter?: EnterHook
}
type RouteChangedHandler = (newRoute: Route, oldRoute: Route | undefined) => any
export default class Router {
public $currentRoute: Ref<Route>
private readonly routes: Route[]
private readonly homeRoute: Route
private readonly notFoundRoute: Route
private routeChangedHandlers: RouteChangedHandler[] = []
constructor (routes: Route[]) {
this.routes = routes
this.homeRoute = routes.find(route => route.screen === 'Home')!
this.notFoundRoute = routes.find(route => route.screen === '404')!
this.$currentRoute = ref<Route>(this.homeRoute)
watch(
this.$currentRoute,
(newValue, oldValue) => this.routeChangedHandlers.forEach(async handler => await handler(newValue, oldValue)),
{
deep: true,
immediate: true
}
}
)
this.paths = Object.keys(this.routes)
addEventListener('popstate', () => this.resolveRoute(), true)
addEventListener('popstate', () => this.resolve(), true)
}
public resolveRoute () {
if (!location.hash) {
return this.go('home')
public async resolve () {
if (!location.hash || location.hash === '#!/') {
return this.activateRoute(this.homeRoute)
}
for (let i = 0; i < this.paths.length; i++) {
const matches = location.hash.match(new RegExp(`^#!${this.paths[i]}$`))
for (let i = 0; i < this.routes.length; i++) {
const route = this.routes[i]
const matches = location.hash.match(new RegExp(`^#!${route.path}/?(?:\\?(.*))?$`))
if (matches) {
const [, ...params] = matches
this.routes[this.paths[i]](...params)
return
const searchParams = new URLSearchParams(new URL(location.href.replace('#!/', '')).search)
const routeParams = Object.assign(Object.fromEntries(searchParams.entries()), matches.groups || {})
if (route.onBeforeEnter && route.onBeforeEnter(routeParams) === false) {
return this.triggerNotFound()
}
return this.activateRoute(route, routeParams)
}
}
loadMainView('404')
await this.triggerNotFound()
}
/**
* Navigate to a (relative, hash-bang'ed) path.
*/
public go (path: string | number) {
if (typeof path === 'number') {
history.go(path)
return
public async triggerNotFound () {
await this.activateRoute(this.notFoundRoute)
}
public onRouteChanged (handler: RouteChangedHandler) {
this.routeChangedHandlers.push(handler)
}
public async activateRoute (route: Route, params: RouteParams = {}) {
this.$currentRoute.value = route
this.$currentRoute.value.params = params
if (this.$currentRoute.value.redirect) {
const to = this.$currentRoute.value.redirect(params)
return await this.activateRoute(to, to.params)
}
if (this.$currentRoute.value.onEnter) {
await this.$currentRoute.value.onEnter(params)
}
}
public go (path: string) {
if (!path.startsWith('/')) {
path = `/${path}`
}
@ -75,5 +101,3 @@ class Router {
location.assign(`${location.origin}${location.pathname}${path}`)
}
}
export default new Router()

View file

@ -7,5 +7,6 @@ export * from './socketService'
export * from './audioService'
export * from './uploadService'
export * from './authService'
export * from './mediaInfoService'
export * from './cache'
export * from './socketListener'

View file

@ -3,7 +3,6 @@ import plyr from 'plyr'
import lodash from 'lodash'
import { expect, it, vi } from 'vitest'
import { eventBus, noop } from '@/utils'
import router from '@/router'
import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { socketService } from '@/services'
@ -332,7 +331,6 @@ new class extends UnitTestCase {
it('queues and plays songs without shuffling', async () => {
const songs = factory<Song>('song', 5)
const replaceQueueMock = this.mock(queueStore, 'replaceQueueWith')
const goMock = this.mock(router, 'go')
const playMock = this.mock(playbackService, 'play')
const firstSongInQueue = songs[0]
const shuffleMock = this.mock(lodash, 'shuffle')
@ -343,7 +341,6 @@ new class extends UnitTestCase {
expect(shuffleMock).not.toHaveBeenCalled()
expect(replaceQueueMock).toHaveBeenCalledWith(songs)
expect(goMock).toHaveBeenCalledWith('queue')
expect(playMock).toHaveBeenCalledWith(firstSongInQueue)
})
@ -351,7 +348,6 @@ new class extends UnitTestCase {
const songs = factory<Song>('song', 5)
const shuffledSongs = factory<Song>('song', 5)
const replaceQueueMock = this.mock(queueStore, 'replaceQueueWith')
const goMock = this.mock(router, 'go')
const playMock = this.mock(playbackService, 'play')
const firstSongInQueue = songs[0]
this.setReadOnlyProperty(queueStore, 'first', firstSongInQueue)
@ -362,7 +358,6 @@ new class extends UnitTestCase {
expect(shuffleMock).toHaveBeenCalledWith(songs)
expect(replaceQueueMock).toHaveBeenCalledWith(shuffledSongs)
expect(goMock).toHaveBeenCalledWith('queue')
expect(playMock).toHaveBeenCalledWith(firstSongInQueue)
})

View file

@ -1,7 +1,6 @@
import isMobile from 'ismobilejs'
import plyr from 'plyr'
import { shuffle, throttle } from 'lodash'
import { nextTick } from 'vue'
import {
commonStore,
@ -14,7 +13,6 @@ import {
import { arrayify, eventBus, isAudioContextSupported, logger } from '@/utils'
import { audioService, socketService } from '@/services'
import router from '@/router'
/**
* The number of seconds before the current song ends to start preload the next one.
@ -58,59 +56,6 @@ class PlaybackService {
this.setMediaSessionActionHandlers()
}
private setMediaSessionActionHandlers () {
if (!navigator.mediaSession) {
return
}
navigator.mediaSession.setActionHandler('play', () => this.resume())
navigator.mediaSession.setActionHandler('pause', () => this.pause())
navigator.mediaSession.setActionHandler('previoustrack', () => this.playPrev())
navigator.mediaSession.setActionHandler('nexttrack', () => this.playNext())
}
private listenToMediaEvents (mediaElement: HTMLMediaElement) {
mediaElement.addEventListener('error', () => this.playNext(), true)
mediaElement.addEventListener('ended', () => {
if (commonStore.state.use_last_fm && userStore.current.preferences!.lastfm_session_key) {
songStore.scrobble(queueStore.current!)
}
preferences.repeatMode === 'REPEAT_ONE' ? this.restart() : this.playNext()
})
let timeUpdateHandler = () => {
const currentSong = queueStore.current
if (!currentSong) return
if (!currentSong.play_count_registered && !this.isTranscoding) {
// if we've passed 25% of the song, it's safe to say the song has been "played".
// Refer to https://github.com/koel/koel/issues/1087
if (!mediaElement.duration || mediaElement.currentTime * 4 >= mediaElement.duration) {
this.registerPlay(currentSong)
}
}
const nextSong = queueStore.next
if (!nextSong || nextSong.preloaded || this.isTranscoding) {
return
}
if (mediaElement.duration && mediaElement.currentTime + PRELOAD_BUFFER > mediaElement.duration) {
this.preload(nextSong)
}
}
if (process.env.NODE_ENV !== 'test') {
timeUpdateHandler = throttle(timeUpdateHandler, 1000)
}
mediaElement.addEventListener('timeupdate', timeUpdateHandler)
}
public registerPlay (song: Song) {
recentlyPlayedStore.add(song)
songStore.registerPlay(song)
@ -383,16 +328,65 @@ class PlaybackService {
await this.stop()
queueStore.replaceQueueWith(songs)
// Wait for the DOM to complete updating and play the first song in the queue.
await nextTick()
router.go('queue')
await this.play(queueStore.first)
}
public async playFirstInQueue () {
queueStore.all.length && await this.play(queueStore.first)
}
private setMediaSessionActionHandlers () {
if (!navigator.mediaSession) {
return
}
navigator.mediaSession.setActionHandler('play', () => this.resume())
navigator.mediaSession.setActionHandler('pause', () => this.pause())
navigator.mediaSession.setActionHandler('previoustrack', () => this.playPrev())
navigator.mediaSession.setActionHandler('nexttrack', () => this.playNext())
}
private listenToMediaEvents (mediaElement: HTMLMediaElement) {
mediaElement.addEventListener('error', () => this.playNext(), true)
mediaElement.addEventListener('ended', () => {
if (commonStore.state.use_last_fm && userStore.current.preferences!.lastfm_session_key) {
songStore.scrobble(queueStore.current!)
}
preferences.repeatMode === 'REPEAT_ONE' ? this.restart() : this.playNext()
})
let timeUpdateHandler = () => {
const currentSong = queueStore.current
if (!currentSong) return
if (!currentSong.play_count_registered && !this.isTranscoding) {
// if we've passed 25% of the song, it's safe to say the song has been "played".
// Refer to https://github.com/koel/koel/issues/1087
if (!mediaElement.duration || mediaElement.currentTime * 4 >= mediaElement.duration) {
this.registerPlay(currentSong)
}
}
const nextSong = queueStore.next
if (!nextSong || nextSong.preloaded || this.isTranscoding) {
return
}
if (mediaElement.duration && mediaElement.currentTime + PRELOAD_BUFFER > mediaElement.duration) {
this.preload(nextSong)
}
}
if (process.env.NODE_ENV !== 'test') {
timeUpdateHandler = throttle(timeUpdateHandler, 1000)
}
mediaElement.addEventListener('timeupdate', timeUpdateHandler)
}
}
export const playbackService = new PlaybackService()

View file

@ -1,6 +1,5 @@
import { without } from 'lodash'
import { reactive } from 'vue'
import { UploadFile } from '@/config'
import { http } from '@/services'
import { albumStore, overviewStore, songStore } from '@/stores'
import { logger } from '@/utils'
@ -10,6 +9,22 @@ interface UploadResult {
album: Album
}
export type UploadStatus =
| 'Ready'
| 'Uploading'
| 'Uploaded'
| 'Canceled'
| 'Errored'
export interface UploadFile {
id: string
file: File
status: UploadStatus
name: string
progress: number
message?: string
}
export const uploadService = {
state: reactive({
files: [] as UploadFile[]

View file

@ -1,5 +1,4 @@
import { eventBus } from '@/utils'
import router from '@/router'
import factory from '@/__tests__/factory'
import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase'
@ -18,12 +17,10 @@ new class extends UnitTestCase {
})
const emitMock = this.mock(eventBus, 'emit')
const goMock = this.mock(router, 'go')
youTubeService.play(video)
expect(emitMock).toHaveBeenCalledWith('PLAY_YOUTUBE_VIDEO', { id: 'foo', title: 'Bar' })
expect(goMock).toHaveBeenCalledWith('youtube')
})
}
}

View file

@ -1,6 +1,5 @@
import { cache, http } from '@/services'
import { eventBus } from '@/utils'
import router from '@/router'
interface YouTubeSearchResult {
nextPageToken: string
@ -22,7 +21,5 @@ export const youTubeService = {
id: video.id.videoId,
title: video.snippet.title
})
router.go('youtube')
}
}

View file

@ -168,8 +168,9 @@ export const songStore = {
return await this.cacheable(['artist.songs', id], http.get<Song[]>(`artists/${id}/songs`))
},
async fetchForPlaylist (playlist: Playlist) {
return await this.cacheable(['playlist.songs', playlist.id], http.get<Song[]>(`playlists/${playlist.id}/songs`))
async fetchForPlaylist (playlist: Playlist | number) {
const id = typeof playlist === 'number' ? playlist : playlist.id
return await this.cacheable(['playlist.songs', id], http.get<Song[]>(`playlists/${id}/songs`))
},
async fetchForPlaylistFolder (folder: PlaylistFolder) {

View file

@ -1,15 +1,18 @@
import { DeepReadonly, InjectionKey, Ref } from 'vue'
import DialogBox from '@/components/ui/DialogBox.vue'
import MessageToaster from '@/components/ui/MessageToaster.vue'
import Router from '@/router'
export interface ReadonlyInjectionKey<T> extends InjectionKey<[Readonly<T> | DeepReadonly<T>, Closure]> {
}
export const RouterKey: InjectionKey<Router> = Symbol('Router')
export const DialogBoxKey: InjectionKey<Ref<InstanceType<typeof DialogBox>>> = Symbol('DialogBox')
export const MessageToasterKey: InjectionKey<Ref<InstanceType<typeof MessageToaster>>> = Symbol('MessageToaster')
export const SongListTypeKey: ReadonlyInjectionKey<SongListType> = Symbol('SongListType')
export const SongsKey: ReadonlyInjectionKey<Ref<Song[]>> = Symbol('Songs')
export const SongsKey: ReadonlyInjectionKey<Ref<Song[]>> | InjectionKey<Ref<Song[]>> = Symbol('Songs')
export const SelectedSongsKey: ReadonlyInjectionKey<Ref<Song[]>> = Symbol('SelectedSongs')
export const SongListConfigKey: ReadonlyInjectionKey<Partial<SongListConfig>> = Symbol('SongListConfig')
export const SongListSortFieldKey: ReadonlyInjectionKey<Ref<SongListSortField>> = Symbol('SongListSortField')
@ -20,5 +23,3 @@ export const EditSongFormInitialTabKey: ReadonlyInjectionKey<Ref<EditSongFormTab
export const PlaylistKey: ReadonlyInjectionKey<Ref<Playlist>> = Symbol('Playlist')
export const PlaylistFolderKey: ReadonlyInjectionKey<Ref<PlaylistFolder>> = Symbol('PlaylistFolder')
export const UserKey: ReadonlyInjectionKey<Ref<User>> = Symbol('User')
export const ActiveScreenKey: InjectionKey<Ref<ScreenName>> = Symbol('ActiveScreen')

View file

@ -31,10 +31,6 @@ interface Plyr {
setVolume (volume: number): void
}
declare module 'plyr' {
function setup (el: string | HTMLMediaElement | HTMLMediaElement[], options: Record<string, any>): Plyr[]
}
declare module 'ismobilejs' {
let apple: { device: boolean }
let any: boolean

View file

@ -4,14 +4,6 @@ import defaultCover from '@/../img/covers/default.svg'
export { defaultCover }
/**
* Load (display) a main panel (view).
*
* @param view
* @param {...*} args Extra data to attach to the view.
*/
export const loadMainView = (view: ScreenName, ...args: any[]) => eventBus.emit('ACTIVATE_SCREEN', view, ...args)
/**
* Force reloading window regardless of "Confirm before reload" setting.
* This is handy for certain cases, for example Last.fm connect/disconnect.

View file

@ -20,7 +20,7 @@ export const eventBus = {
this.all.has(name) ? this.all.get(name).push(callback) : this.all.set(name, [callback])
},
emit (name: EventName, ...args: any) {
emit (name: EventName, ...args: any[]) {
if (this.all.has(name)) {
this.all.get(name).forEach((cb: Closure) => cb(...args))
} else {

View file

@ -161,7 +161,7 @@ export default (container: HTMLElement) => {
Sketch.create({
container,
particles: [],
setup () {
init () {
// generate some particles
for (let i = 0; i < NUM_PARTICLES; i++) {
const particle = new Particle(random(this.width), random(this.height))

View file

@ -5,7 +5,7 @@
<meta name="description" content="{{ config('app.tagline') }}">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#282828">

View file

@ -29,9 +29,6 @@ export default defineConfig({
}
}
},
define: {
KOEL_ENV: '""'
},
test: {
environment: 'jsdom',
setupFiles: path.resolve(__dirname, './resources/assets/js/__tests__/setup.ts')

314
yarn.lock
View file

@ -953,6 +953,11 @@
debug "^3.1.0"
lodash.once "^4.1.1"
"@esbuild/linux-loong64@0.14.54":
version "0.14.54"
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz#de2a4be678bd4d0d1ffbb86e6de779cde5999028"
integrity sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw==
"@eslint/eslintrc@^1.2.2":
version "1.2.2"
resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.2.2.tgz#4989b9e8c0216747ee7cca314ae73791bb281aae"
@ -1154,9 +1159,9 @@
"@types/chai" "*"
"@types/chai@*", "@types/chai@^4.3.1":
version "4.3.1"
resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.1.tgz#e2c6e73e0bdeb2521d00756d099218e9f5d90a04"
integrity sha512-/zPMqDkzSZ8t3VtxOa4KPq7uzzW978M9Tvh+j7GHKuo6k6GTLxPJ4J5gE5cjfJ26pnXst0N5Hax8Sr0T2Mi9zQ==
version "4.3.3"
resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.3.tgz#3c90752792660c4b562ad73b3fbd68bf3bc7ae07"
integrity sha512-hC7OMnszpxhZPduX+m+nrx+uFoLkWOMiR4oa/AZF3MuSETYTZmFfJAHqZEM8MVlvfG7BEUcgvtwoCTxBp6hm3g==
"@types/color-name@^1.1.1":
version "1.1.1"
@ -1367,9 +1372,9 @@
eslint-visitor-keys "^3.0.0"
"@vitejs/plugin-vue@^2.3.1":
version "2.3.1"
resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-2.3.1.tgz#5f286b8d3515381c6d5c8fa8eee5e6335f727e14"
integrity sha512-YNzBt8+jt6bSwpt7LP890U1UcTOIZZxfpE5WOJ638PNxSEKOqAi0+FSKS0nVeukfdZ0Ai/H7AFd6k3hayfGZqQ==
version "2.3.4"
resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-2.3.4.tgz#966a6279060eb2d9d1a02ea1a331af071afdcf9e"
integrity sha512-IfFNbtkbIm36O9KB8QodlwwYvTEsJb4Lll4c2IwB3VHc2gie2mSPtSzL0eYay7X2jd/2WX02FjSGTWR6OPr/zg==
"@vue/compiler-core@3.2.32":
version "3.2.32"
@ -1461,11 +1466,16 @@
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.2.32.tgz#1ca0c3b8c03a5e24129156e171df736b2c1d645f"
integrity sha512-bjcixPErUsAnTQRQX4Z5IQnICYjIfNCyCl8p29v1M6kfVzvwOICPw+dz48nNuWlTOOx2RHhzHdazJibE8GSnsw==
"@vue/test-utils@^2.0.0-rc.18", "@vue/test-utils@^2.0.0-rc.21":
"@vue/test-utils@^2.0.0-rc.18":
version "2.0.0-rc.21"
resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-2.0.0-rc.21.tgz#9ebb0029bafa94ee55e90a6b4eab6d1e7b7a3152"
integrity sha512-wIJR4e/jISBKVKfiod3DV32BlDsoD744WVCuCaGtaSKvhvTL9gI5vl2AYSy00V51YaM8dCOFi3zcpCON8G1WqA==
"@vue/test-utils@^2.0.0-rc.21":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-2.1.0.tgz#c2f646aa2d6ac779f79a83f18c5b82fc40952bfd"
integrity sha512-U4AxAD/tKJ3ajxYew1gkfEotpr96DE/gLXpbl+nPbsNRqGBfQZZA7YhwGoQNDPgon56v+IGZDrYq7pe3GDl9aw==
"@webassemblyjs/ast@1.11.1":
version "1.11.1"
resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.1.tgz#2bfd767eae1a6996f432ff7e8d7fc75679c0b6a7"
@ -2130,7 +2140,7 @@ chalk@^4.0.0, chalk@^4.1.0:
check-error@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82"
integrity sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=
integrity sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==
check-more-types@2.24.0, check-more-types@^2.24.0:
version "2.24.0"
@ -2870,131 +2880,132 @@ es6-symbol@^3.1.1, es6-symbol@~3.1.1:
d "1"
es5-ext "~0.10.14"
esbuild-android-64@0.14.38:
version "0.14.38"
resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.14.38.tgz#5b94a1306df31d55055f64a62ff6b763a47b7f64"
integrity sha512-aRFxR3scRKkbmNuGAK+Gee3+yFxkTJO/cx83Dkyzo4CnQl/2zVSurtG6+G86EQIZ+w+VYngVyK7P3HyTBKu3nw==
esbuild-android-64@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.14.54.tgz#505f41832884313bbaffb27704b8bcaa2d8616be"
integrity sha512-Tz2++Aqqz0rJ7kYBfz+iqyE3QMycD4vk7LBRyWaAVFgFtQ/O8EJOnVmTOiDWYZ/uYzB4kvP+bqejYdVKzE5lAQ==
esbuild-android-arm64@0.14.38:
version "0.14.38"
resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.14.38.tgz#78acc80773d16007de5219ccce544c036abd50b8"
integrity sha512-L2NgQRWuHFI89IIZIlpAcINy9FvBk6xFVZ7xGdOwIm8VyhX1vNCEqUJO3DPSSy945Gzdg98cxtNt8Grv1CsyhA==
esbuild-android-arm64@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.14.54.tgz#8ce69d7caba49646e009968fe5754a21a9871771"
integrity sha512-F9E+/QDi9sSkLaClO8SOV6etqPd+5DgJje1F9lOWoNncDdOBL2YF59IhsWATSt0TLZbYCf3pNlTHvVV5VfHdvg==
esbuild-darwin-64@0.14.38:
version "0.14.38"
resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.14.38.tgz#e02b1291f629ebdc2aa46fabfacc9aa28ff6aa46"
integrity sha512-5JJvgXkX87Pd1Og0u/NJuO7TSqAikAcQQ74gyJ87bqWRVeouky84ICoV4sN6VV53aTW+NE87qLdGY4QA2S7KNA==
esbuild-darwin-64@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.14.54.tgz#24ba67b9a8cb890a3c08d9018f887cc221cdda25"
integrity sha512-jtdKWV3nBviOd5v4hOpkVmpxsBy90CGzebpbO9beiqUYVMBtSc0AL9zGftFuBon7PNDcdvNCEuQqw2x0wP9yug==
esbuild-darwin-arm64@0.14.38:
version "0.14.38"
resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.38.tgz#01eb6650ec010b18c990e443a6abcca1d71290a9"
integrity sha512-eqF+OejMI3mC5Dlo9Kdq/Ilbki9sQBw3QlHW3wjLmsLh+quNfHmGMp3Ly1eWm981iGBMdbtSS9+LRvR2T8B3eQ==
esbuild-darwin-arm64@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.54.tgz#3f7cdb78888ee05e488d250a2bdaab1fa671bf73"
integrity sha512-OPafJHD2oUPyvJMrsCvDGkRrVCar5aVyHfWGQzY1dWnzErjrDuSETxwA2HSsyg2jORLY8yBfzc1MIpUkXlctmw==
esbuild-freebsd-64@0.14.38:
version "0.14.38"
resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.38.tgz#790b8786729d4aac7be17648f9ea8e0e16475b5e"
integrity sha512-epnPbhZUt93xV5cgeY36ZxPXDsQeO55DppzsIgWM8vgiG/Rz+qYDLmh5ts3e+Ln1wA9dQ+nZmVHw+RjaW3I5Ig==
esbuild-freebsd-64@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.54.tgz#09250f997a56ed4650f3e1979c905ffc40bbe94d"
integrity sha512-OKwd4gmwHqOTp4mOGZKe/XUlbDJ4Q9TjX0hMPIDBUWWu/kwhBAudJdBoxnjNf9ocIB6GN6CPowYpR/hRCbSYAg==
esbuild-freebsd-arm64@0.14.38:
version "0.14.38"
resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.38.tgz#b66340ab28c09c1098e6d9d8ff656db47d7211e6"
integrity sha512-/9icXUYJWherhk+y5fjPI5yNUdFPtXHQlwP7/K/zg8t8lQdHVj20SqU9/udQmeUo5pDFHMYzcEFfJqgOVeKNNQ==
esbuild-freebsd-arm64@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.54.tgz#bafb46ed04fc5f97cbdb016d86947a79579f8e48"
integrity sha512-sFwueGr7OvIFiQT6WeG0jRLjkjdqWWSrfbVwZp8iMP+8UHEHRBvlaxL6IuKNDwAozNUmbb8nIMXa7oAOARGs1Q==
esbuild-linux-32@0.14.38:
version "0.14.38"
resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.14.38.tgz#7927f950986fd39f0ff319e92839455912b67f70"
integrity sha512-QfgfeNHRFvr2XeHFzP8kOZVnal3QvST3A0cgq32ZrHjSMFTdgXhMhmWdKzRXP/PKcfv3e2OW9tT9PpcjNvaq6g==
esbuild-linux-32@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.14.54.tgz#e2a8c4a8efdc355405325033fcebeb941f781fe5"
integrity sha512-1ZuY+JDI//WmklKlBgJnglpUL1owm2OX+8E1syCD6UAxcMM/XoWd76OHSjl/0MR0LisSAXDqgjT3uJqT67O3qw==
esbuild-linux-64@0.14.38:
version "0.14.38"
resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.14.38.tgz#4893d07b229d9cfe34a2b3ce586399e73c3ac519"
integrity sha512-uuZHNmqcs+Bj1qiW9k/HZU3FtIHmYiuxZ/6Aa+/KHb/pFKr7R3aVqvxlAudYI9Fw3St0VCPfv7QBpUITSmBR1Q==
esbuild-linux-64@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.14.54.tgz#de5fdba1c95666cf72369f52b40b03be71226652"
integrity sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg==
esbuild-linux-arm64@0.14.38:
version "0.14.38"
resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.38.tgz#8442402e37d0b8ae946ac616784d9c1a2041056a"
integrity sha512-HlMGZTEsBrXrivr64eZ/EO0NQM8H8DuSENRok9d+Jtvq8hOLzrxfsAT9U94K3KOGk2XgCmkaI2KD8hX7F97lvA==
esbuild-linux-arm64@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.54.tgz#dae4cd42ae9787468b6a5c158da4c84e83b0ce8b"
integrity sha512-WL71L+0Rwv+Gv/HTmxTEmpv0UgmxYa5ftZILVi2QmZBgX3q7+tDeOQNqGtdXSdsL8TQi1vIaVFHUPDe0O0kdig==
esbuild-linux-arm@0.14.38:
version "0.14.38"
resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.14.38.tgz#d5dbf32d38b7f79be0ec6b5fb2f9251fd9066986"
integrity sha512-FiFvQe8J3VKTDXG01JbvoVRXQ0x6UZwyrU4IaLBZeq39Bsbatd94Fuc3F1RGqPF5RbIWW7RvkVQjn79ejzysnA==
esbuild-linux-arm@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.14.54.tgz#a2c1dff6d0f21dbe8fc6998a122675533ddfcd59"
integrity sha512-qqz/SjemQhVMTnvcLGoLOdFpCYbz4v4fUo+TfsWG+1aOu70/80RV6bgNpR2JCrppV2moUQkww+6bWxXRL9YMGw==
esbuild-linux-mips64le@0.14.38:
version "0.14.38"
resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.38.tgz#95081e42f698bbe35d8ccee0e3a237594b337eb5"
integrity sha512-qd1dLf2v7QBiI5wwfil9j0HG/5YMFBAmMVmdeokbNAMbcg49p25t6IlJFXAeLzogv1AvgaXRXvgFNhScYEUXGQ==
esbuild-linux-mips64le@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.54.tgz#d9918e9e4cb972f8d6dae8e8655bf9ee131eda34"
integrity sha512-qTHGQB8D1etd0u1+sB6p0ikLKRVuCWhYQhAHRPkO+OF3I/iSlTKNNS0Lh2Oc0g0UFGguaFZZiPJdJey3AGpAlw==
esbuild-linux-ppc64le@0.14.38:
version "0.14.38"
resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.38.tgz#dceb0a1b186f5df679618882a7990bd422089b47"
integrity sha512-mnbEm7o69gTl60jSuK+nn+pRsRHGtDPfzhrqEUXyCl7CTOCLtWN2bhK8bgsdp6J/2NyS/wHBjs1x8aBWwP2X9Q==
esbuild-linux-ppc64le@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.54.tgz#3f9a0f6d41073fb1a640680845c7de52995f137e"
integrity sha512-j3OMlzHiqwZBDPRCDFKcx595XVfOfOnv68Ax3U4UKZ3MTYQB5Yz3X1mn5GnodEVYzhtZgxEBidLWeIs8FDSfrQ==
esbuild-linux-riscv64@0.14.38:
version "0.14.38"
resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.38.tgz#61fb8edb75f475f9208c4a93ab2bfab63821afd2"
integrity sha512-+p6YKYbuV72uikChRk14FSyNJZ4WfYkffj6Af0/Tw63/6TJX6TnIKE+6D3xtEc7DeDth1fjUOEqm+ApKFXbbVQ==
esbuild-linux-riscv64@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.54.tgz#618853c028178a61837bc799d2013d4695e451c8"
integrity sha512-y7Vt7Wl9dkOGZjxQZnDAqqn+XOqFD7IMWiewY5SPlNlzMX39ocPQlOaoxvT4FllA5viyV26/QzHtvTjVNOxHZg==
esbuild-linux-s390x@0.14.38:
version "0.14.38"
resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.38.tgz#34c7126a4937406bf6a5e69100185fd702d12fe0"
integrity sha512-0zUsiDkGJiMHxBQ7JDU8jbaanUY975CdOW1YDrurjrM0vWHfjv9tLQsW9GSyEb/heSK1L5gaweRjzfUVBFoybQ==
esbuild-linux-s390x@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.54.tgz#d1885c4c5a76bbb5a0fe182e2c8c60eb9e29f2a6"
integrity sha512-zaHpW9dziAsi7lRcyV4r8dhfG1qBidQWUXweUjnw+lliChJqQr+6XD71K41oEIC3Mx1KStovEmlzm+MkGZHnHA==
esbuild-netbsd-64@0.14.38:
version "0.14.38"
resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.38.tgz#322ea9937d9e529183ee281c7996b93eb38a5d95"
integrity sha512-cljBAApVwkpnJZfnRVThpRBGzCi+a+V9Ofb1fVkKhtrPLDYlHLrSYGtmnoTVWDQdU516qYI8+wOgcGZ4XIZh0Q==
esbuild-netbsd-64@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.54.tgz#69ae917a2ff241b7df1dbf22baf04bd330349e81"
integrity sha512-PR01lmIMnfJTgeU9VJTDY9ZerDWVFIUzAtJuDHwwceppW7cQWjBBqP48NdeRtoP04/AtO9a7w3viI+PIDr6d+w==
esbuild-openbsd-64@0.14.38:
version "0.14.38"
resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.38.tgz#1ca29bb7a2bf09592dcc26afdb45108f08a2cdbd"
integrity sha512-CDswYr2PWPGEPpLDUO50mL3WO/07EMjnZDNKpmaxUPsrW+kVM3LoAqr/CE8UbzugpEiflYqJsGPLirThRB18IQ==
esbuild-openbsd-64@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.54.tgz#db4c8495287a350a6790de22edea247a57c5d47b"
integrity sha512-Qyk7ikT2o7Wu76UsvvDS5q0amJvmRzDyVlL0qf5VLsLchjCa1+IAvd8kTBgUxD7VBUUVgItLkk609ZHUc1oCaw==
esbuild-sunos-64@0.14.38:
version "0.14.38"
resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.14.38.tgz#c9446f7d8ebf45093e7bb0e7045506a88540019b"
integrity sha512-2mfIoYW58gKcC3bck0j7lD3RZkqYA7MmujFYmSn9l6TiIcAMpuEvqksO+ntBgbLep/eyjpgdplF7b+4T9VJGOA==
esbuild-sunos-64@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.14.54.tgz#54287ee3da73d3844b721c21bc80c1dc7e1bf7da"
integrity sha512-28GZ24KmMSeKi5ueWzMcco6EBHStL3B6ubM7M51RmPwXQGLe0teBGJocmWhgwccA1GeFXqxzILIxXpHbl9Q/Kw==
esbuild-windows-32@0.14.38:
version "0.14.38"
resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.14.38.tgz#f8e9b4602fd0ccbd48e5c8d117ec0ba4040f2ad1"
integrity sha512-L2BmEeFZATAvU+FJzJiRLFUP+d9RHN+QXpgaOrs2klshoAm1AE6Us4X6fS9k33Uy5SzScn2TpcgecbqJza1Hjw==
esbuild-windows-32@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.14.54.tgz#f8aaf9a5667630b40f0fb3aa37bf01bbd340ce31"
integrity sha512-T+rdZW19ql9MjS7pixmZYVObd9G7kcaZo+sETqNH4RCkuuYSuv9AGHUVnPoP9hhuE1WM1ZimHz1CIBHBboLU7w==
esbuild-windows-64@0.14.38:
version "0.14.38"
resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.14.38.tgz#280f58e69f78535f470905ce3e43db1746518107"
integrity sha512-Khy4wVmebnzue8aeSXLC+6clo/hRYeNIm0DyikoEqX+3w3rcvrhzpoix0S+MF9vzh6JFskkIGD7Zx47ODJNyCw==
esbuild-windows-64@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.14.54.tgz#bf54b51bd3e9b0f1886ffdb224a4176031ea0af4"
integrity sha512-AoHTRBUuYwXtZhjXZbA1pGfTo8cJo3vZIcWGLiUcTNgHpJJMC1rVA44ZereBHMJtotyN71S8Qw0npiCIkW96cQ==
esbuild-windows-arm64@0.14.38:
version "0.14.38"
resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.38.tgz#d97e9ac0f95a4c236d9173fa9f86c983d6a53f54"
integrity sha512-k3FGCNmHBkqdJXuJszdWciAH77PukEyDsdIryEHn9cKLQFxzhT39dSumeTuggaQcXY57UlmLGIkklWZo2qzHpw==
esbuild-windows-arm64@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.54.tgz#937d15675a15e4b0e4fafdbaa3a01a776a2be982"
integrity sha512-M0kuUvXhot1zOISQGXwWn6YtS+Y/1RT9WrVIOywZnJHo3jCDyewAc79aKNQWFCQm+xNHVTq9h8dZKvygoXQQRg==
esbuild@^0.14.27:
version "0.14.38"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.14.38.tgz#99526b778cd9f35532955e26e1709a16cca2fb30"
integrity sha512-12fzJ0fsm7gVZX1YQ1InkOE5f9Tl7cgf6JPYXRJtPIoE0zkWAbHdPHVPPaLi9tYAcEBqheGzqLn/3RdTOyBfcA==
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.14.54.tgz#8b44dcf2b0f1a66fc22459943dccf477535e9aa2"
integrity sha512-Cy9llcy8DvET5uznocPyqL3BFRrFXSVqbgpMJ9Wz8oVjZlh/zUSNbPRbov0VX7VxN2JH1Oa0uNxZ7eLRb62pJA==
optionalDependencies:
esbuild-android-64 "0.14.38"
esbuild-android-arm64 "0.14.38"
esbuild-darwin-64 "0.14.38"
esbuild-darwin-arm64 "0.14.38"
esbuild-freebsd-64 "0.14.38"
esbuild-freebsd-arm64 "0.14.38"
esbuild-linux-32 "0.14.38"
esbuild-linux-64 "0.14.38"
esbuild-linux-arm "0.14.38"
esbuild-linux-arm64 "0.14.38"
esbuild-linux-mips64le "0.14.38"
esbuild-linux-ppc64le "0.14.38"
esbuild-linux-riscv64 "0.14.38"
esbuild-linux-s390x "0.14.38"
esbuild-netbsd-64 "0.14.38"
esbuild-openbsd-64 "0.14.38"
esbuild-sunos-64 "0.14.38"
esbuild-windows-32 "0.14.38"
esbuild-windows-64 "0.14.38"
esbuild-windows-arm64 "0.14.38"
"@esbuild/linux-loong64" "0.14.54"
esbuild-android-64 "0.14.54"
esbuild-android-arm64 "0.14.54"
esbuild-darwin-64 "0.14.54"
esbuild-darwin-arm64 "0.14.54"
esbuild-freebsd-64 "0.14.54"
esbuild-freebsd-arm64 "0.14.54"
esbuild-linux-32 "0.14.54"
esbuild-linux-64 "0.14.54"
esbuild-linux-arm "0.14.54"
esbuild-linux-arm64 "0.14.54"
esbuild-linux-mips64le "0.14.54"
esbuild-linux-ppc64le "0.14.54"
esbuild-linux-riscv64 "0.14.54"
esbuild-linux-s390x "0.14.54"
esbuild-netbsd-64 "0.14.54"
esbuild-openbsd-64 "0.14.54"
esbuild-sunos-64 "0.14.54"
esbuild-windows-32 "0.14.54"
esbuild-windows-64 "0.14.54"
esbuild-windows-arm64 "0.14.54"
escalade@^3.1.1:
version "3.1.1"
@ -3566,7 +3577,7 @@ gensync@^1.0.0-beta.2:
get-func-name@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41"
integrity sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=
integrity sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==
get-intrinsic@^1.0.2, get-intrinsic@^1.1.0, get-intrinsic@^1.1.1:
version "1.1.1"
@ -3983,10 +3994,10 @@ is-ci@^3.0.0:
dependencies:
ci-info "^3.2.0"
is-core-module@^2.8.1:
version "2.8.1"
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.8.1.tgz#f59fdfca701d5879d0a6b100a40aa1560ce27211"
integrity sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA==
is-core-module@^2.8.1, is-core-module@^2.9.0:
version "2.10.0"
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.10.0.tgz#9012ede0a91c69587e647514e1d5277019e728ed"
integrity sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg==
dependencies:
has "^1.0.3"
@ -4463,9 +4474,9 @@ loader-utils@^2.0.0:
json5 "^2.1.2"
local-pkg@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-0.4.1.tgz#e7b0d7aa0b9c498a1110a5ac5b00ba66ef38cfff"
integrity sha512-lL87ytIGP2FU5PWwNDo0w3WhIo2gopIAxPg9RxDYF7m4rr5ahuZxP22xnJHIvaLTe4Z9P6uKKY2UHiwyB4pcrw==
version "0.4.2"
resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-0.4.2.tgz#13107310b77e74a0e513147a131a2ba288176c2f"
integrity sha512-mlERgSPrbxU3BP4qBqAvvwlgW4MTg78iwJdGGnv7kibKjWcJksrG3t6LB5lXI93wXRDvG4NpUgJFmTG4T6rdrg==
local-storage@^2.0.0:
version "2.0.0"
@ -5319,9 +5330,9 @@ postcss@^8.1.10, postcss@^8.4.12:
source-map-js "^1.0.2"
postcss@^8.4.13:
version "8.4.14"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.14.tgz#ee9274d5622b4858c1007a74d76e42e56fd21caf"
integrity sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig==
version "8.4.17"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.17.tgz#f87863ec7cd353f81f7ab2dec5d67d861bbb1be5"
integrity sha512-UNxNOLQydcOFi41yHNMcKRZ39NeXlr8AxGuZJsdub8vIb12fHzcq37DTU/QtbI6WLxNg2gF9Z+8qtRwTj1UI1Q==
dependencies:
nanoid "^3.3.4"
picocolors "^1.0.0"
@ -5564,7 +5575,7 @@ resolve-url@^0.2.1:
resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a"
integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=
resolve@^1.10.1, resolve@^1.14.2, resolve@^1.20.0, resolve@^1.22.0:
resolve@^1.10.1, resolve@^1.14.2, resolve@^1.20.0:
version "1.22.0"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.0.tgz#5e0b8c67c15df57a89bdbabe603a002f21731198"
integrity sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==
@ -5573,6 +5584,15 @@ resolve@^1.10.1, resolve@^1.14.2, resolve@^1.20.0, resolve@^1.22.0:
path-parse "^1.0.7"
supports-preserve-symlinks-flag "^1.0.0"
resolve@^1.22.0:
version "1.22.1"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177"
integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==
dependencies:
is-core-module "^2.9.0"
path-parse "^1.0.7"
supports-preserve-symlinks-flag "^1.0.0"
restore-cursor@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e"
@ -5611,10 +5631,10 @@ rimraf@^3.0.0, rimraf@^3.0.2:
dependencies:
glob "^7.1.3"
rollup@^2.59.0:
version "2.71.1"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.71.1.tgz#82b259af7733dfd1224a8171013aaaad02971a22"
integrity sha512-lMZk3XfUBGjrrZQpvPSoXcZSfKcJ2Bgn+Z0L1MoW2V8Wh7BVM+LOBJTPo16yul2MwL59cXedzW1ruq3rCjSRgw==
"rollup@>=2.59.0 <2.78.0":
version "2.77.3"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.77.3.tgz#8f00418d3a2740036e15deb653bed1a90ee0cc12"
integrity sha512-/qxNTG7FbmefJWoeeYJFbHehJ2HNWnjkAFRKzWN/45eNBBF/r8lo992CwcJXEzyVxs5FmfId+vTSTQDb+bxA+g==
optionalDependencies:
fsevents "~2.3.2"
@ -6120,15 +6140,15 @@ through@2, through@^2.3.8, through@~2.3, through@~2.3.1:
resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=
tinypool@^0.1.2:
tinypool@^0.1.3:
version "0.1.3"
resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-0.1.3.tgz#b5570b364a1775fd403de5e7660b325308fee26b"
integrity sha512-2IfcQh7CP46XGWGGbdyO4pjcKqsmVqFAPcXfPxcPXmOWt9cYkTP9HcDmGgsfijYoAEc4z9qcpM/BaBz46Y9/CQ==
tinyspy@^0.3.2:
version "0.3.2"
resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-0.3.2.tgz#2f95cb14c38089ca690385f339781cd35faae566"
integrity sha512-2+40EP4D3sFYy42UkgkFFB+kiX2Tg3URG/lVvAZFfLxgGpnWl5qQJuBw1gaLttq8UOS+2p3C0WrhJnQigLTT2Q==
version "0.3.3"
resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-0.3.3.tgz#8b57f8aec7fe1bf583a3a49cb9ab30c742f69237"
integrity sha512-gRiUR8fuhUf0W9lzojPf1N1euJYA30ISebSfgca8z76FOvXtVXqd5ojEIaKLWbDQhAaC3ibxZIjqbyi4ybjcTw==
tmp@~0.2.1:
version "0.2.1"
@ -6389,42 +6409,30 @@ verror@1.10.0:
core-util-is "1.0.2"
extsprintf "^1.2.0"
vite@^2.9.13:
version "2.9.13"
resolved "https://registry.yarnpkg.com/vite/-/vite-2.9.13.tgz#859cb5d4c316c0d8c6ec9866045c0f7858ca6abc"
integrity sha512-AsOBAaT0AD7Mhe8DuK+/kE4aWYFMx/i0ZNi98hJclxb4e0OhQcZYUrvLjIaQ8e59Ui7txcvKMiJC1yftqpQoDw==
vite@^2.9.13, vite@^2.9.7:
version "2.9.15"
resolved "https://registry.yarnpkg.com/vite/-/vite-2.9.15.tgz#2858dd5b2be26aa394a283e62324281892546f0b"
integrity sha512-fzMt2jK4vQ3yK56te3Kqpkaeq9DkcZfBbzHwYpobasvgYmP2SoAr6Aic05CsB4CzCZbsDv4sujX3pkEGhLabVQ==
dependencies:
esbuild "^0.14.27"
postcss "^8.4.13"
resolve "^1.22.0"
rollup "^2.59.0"
optionalDependencies:
fsevents "~2.3.2"
vite@^2.9.5:
version "2.9.6"
resolved "https://registry.yarnpkg.com/vite/-/vite-2.9.6.tgz#29f1b33193b0de9e155d67ba0dd097501c3c3281"
integrity sha512-3IffdrByHW95Yjv0a13TQOQfJs7L5dVlSPuTt432XLbRMriWbThqJN2k/IS6kXn5WY4xBLhK9XoaWay1B8VzUw==
dependencies:
esbuild "^0.14.27"
postcss "^8.4.12"
resolve "^1.22.0"
rollup "^2.59.0"
rollup ">=2.59.0 <2.78.0"
optionalDependencies:
fsevents "~2.3.2"
vitest@^0.10.0:
version "0.10.0"
resolved "https://registry.yarnpkg.com/vitest/-/vitest-0.10.0.tgz#ab8930194f2a8943c533cca735cba2bca705d5bc"
integrity sha512-8UXemUg9CA4QYppDTsDV76nH0e1p6C8lV9q+o9i0qMSK9AQ7vA2sjoxtkDP0M+pwNmc3ZGYetBXgSJx0M1D/gg==
version "0.10.5"
resolved "https://registry.yarnpkg.com/vitest/-/vitest-0.10.5.tgz#f2cd782a8f72889d4324a809101ada9e9f424a67"
integrity sha512-4qXdNbHwAd9YcsztJoVMWUQGcMATVlY9Xd95I3KQ2JJwDLTL97f/jgfGRotqptvNxdlmme5TBY0Gv+l6+JSYvA==
dependencies:
"@types/chai" "^4.3.1"
"@types/chai-subset" "^1.3.3"
chai "^4.3.6"
local-pkg "^0.4.1"
tinypool "^0.1.2"
tinypool "^0.1.3"
tinyspy "^0.3.2"
vite "^2.9.5"
vite "^2.9.7"
vue-eslint-parser@^8.0.1:
version "8.3.0"