mirror of
https://github.com/koel/koel
synced 2024-11-24 05:03:05 +00:00
feat: add and use a url helper for router (#1863)
This commit is contained in:
parent
b5a42d485b
commit
86e4b65ec7
64 changed files with 253 additions and 157 deletions
|
@ -13,7 +13,6 @@ import { commonStore } from '@/stores/commonStore'
|
||||||
import { userStore } from '@/stores/userStore'
|
import { userStore } from '@/stores/userStore'
|
||||||
import { http } from '@/services/http'
|
import { http } from '@/services/http'
|
||||||
import { DialogBoxKey, MessageToasterKey, OverlayKey, RouterKey } from '@/symbols'
|
import { DialogBoxKey, MessageToasterKey, OverlayKey, RouterKey } from '@/symbols'
|
||||||
import { routes } from '@/config/routes'
|
|
||||||
import Router from '@/router'
|
import Router from '@/router'
|
||||||
|
|
||||||
// A deep-merge function that
|
// A deep-merge function that
|
||||||
|
@ -47,7 +46,7 @@ export default abstract class UnitTestCase {
|
||||||
private backupMethods = new Map()
|
private backupMethods = new Map()
|
||||||
|
|
||||||
public constructor () {
|
public constructor () {
|
||||||
this.router = new Router(routes)
|
this.router = new Router()
|
||||||
this.mock(http, 'request') // prevent actual HTTP requests from being made
|
this.mock(http, 'request') // prevent actual HTTP requests from being made
|
||||||
this.user = userEvent.setup({ delay: null }) // @see https://github.com/testing-library/user-event/issues/833
|
this.user = userEvent.setup({ delay: null }) // @see https://github.com/testing-library/user-event/issues/833
|
||||||
|
|
||||||
|
@ -82,11 +81,11 @@ export default abstract class UnitTestCase {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
protected auth (user?: User = null) {
|
protected auth (user?: User) {
|
||||||
return this.be(user)
|
return this.be(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected be (user?: User = null) {
|
protected be (user?: User) {
|
||||||
userStore.state.current = user || factory('user')
|
userStore.state.current = user || factory('user')
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
@ -181,7 +180,7 @@ export default abstract class UnitTestCase {
|
||||||
await this.user.type(element, value)
|
await this.user.type(element, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async trigger (element: HTMLElement, key: EventType | string, options?: object = {}) {
|
protected async trigger (element: HTMLElement, key: EventType | string, options: object = {}) {
|
||||||
await fireEvent(element, createEvent[key](element, options))
|
await fireEvent(element, createEvent[key](element, options))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,13 +6,12 @@ import { hideBrokenIcon } from '@/directives/hideBrokenIcon'
|
||||||
import { overflowFade } from '@/directives/overflowFade'
|
import { overflowFade } from '@/directives/overflowFade'
|
||||||
import { newTab } from '@/directives/newTab'
|
import { newTab } from '@/directives/newTab'
|
||||||
import { RouterKey } from '@/symbols'
|
import { RouterKey } from '@/symbols'
|
||||||
import { routes } from '@/config/routes'
|
|
||||||
import Router from '@/router'
|
import Router from '@/router'
|
||||||
import '@/../css/app.pcss'
|
import '@/../css/app.pcss'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
|
||||||
createApp(App)
|
createApp(App)
|
||||||
.provide(RouterKey, new Router(routes))
|
.provide(RouterKey, new Router())
|
||||||
.component('Icon', FontAwesomeIcon)
|
.component('Icon', FontAwesomeIcon)
|
||||||
.component('IconLayers', FontAwesomeLayers)
|
.component('IconLayers', FontAwesomeLayers)
|
||||||
.directive('koel-focus', focus)
|
.directive('koel-focus', focus)
|
||||||
|
|
|
@ -9,8 +9,8 @@
|
||||||
@dragstart="onDragStart"
|
@dragstart="onDragStart"
|
||||||
>
|
>
|
||||||
<template #name>
|
<template #name>
|
||||||
<a :href="`#/album/${album.id}`" class="font-medium" data-testid="name">{{ album.name }}</a>
|
<a :href="url('albums.show', { id: album.id })" class="font-medium" data-testid="name">{{ album.name }}</a>
|
||||||
<a v-if="isStandardArtist" :href="`#/artist/${album.artist_id}`">{{ album.artist_name }}</a>
|
<a v-if="isStandardArtist" :href="url('artists.show', { id: album.artist_id })">{{ album.artist_name }}</a>
|
||||||
<span v-else class="text-k-text-secondary">{{ album.artist_name }}</span>
|
<span v-else class="text-k-text-secondary">{{ album.artist_name }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -45,7 +45,7 @@ import { useRouter } from '@/composables/useRouter'
|
||||||
import BaseCard from '@/components/ui/album-artist/AlbumOrArtistCard.vue'
|
import BaseCard from '@/components/ui/album-artist/AlbumOrArtistCard.vue'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{ album: Album, layout?: ArtistAlbumCardLayout }>(), { layout: 'full' })
|
const props = withDefaults(defineProps<{ album: Album, layout?: ArtistAlbumCardLayout }>(), { layout: 'full' })
|
||||||
const { go } = useRouter()
|
const { go, url } = useRouter()
|
||||||
const { startDragging } = useDraggable('album')
|
const { startDragging } = useDraggable('album')
|
||||||
|
|
||||||
const { album, layout } = toRefs(props)
|
const { album, layout } = toRefs(props)
|
||||||
|
@ -58,7 +58,7 @@ const showing = computed(() => !albumStore.isUnknown(album.value))
|
||||||
|
|
||||||
const shuffle = async () => {
|
const shuffle = async () => {
|
||||||
playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value), true /* shuffled */)
|
playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value), true /* shuffled */)
|
||||||
go('queue')
|
go(url('queue'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const download = () => downloadService.fromAlbum(album.value)
|
const download = () => downloadService.fromAlbum(album.value)
|
||||||
|
|
|
@ -64,7 +64,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
await this.user.click(screen.getByText('Go to Album'))
|
await this.user.click(screen.getByText('Go to Album'))
|
||||||
|
|
||||||
expect(mock).toHaveBeenCalledWith(`album/${album.id}`)
|
expect(mock).toHaveBeenCalledWith(`/#/albums/${album.id}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not have an option to download or go to Unknown Album and Artist', async () => {
|
it('does not have an option to download or go to Unknown Album and Artist', async () => {
|
||||||
|
@ -81,7 +81,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
await this.user.click(screen.getByText('Go to Artist'))
|
await this.user.click(screen.getByText('Go to Artist'))
|
||||||
|
|
||||||
expect(mock).toHaveBeenCalledWith(`artist/${album.artist_id}`)
|
expect(mock).toHaveBeenCalledWith(`/#/artists/${album.artist_id}`)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -26,7 +26,7 @@ import { useContextMenu } from '@/composables/useContextMenu'
|
||||||
import { useRouter } from '@/composables/useRouter'
|
import { useRouter } from '@/composables/useRouter'
|
||||||
import { eventBus } from '@/utils/eventBus'
|
import { eventBus } from '@/utils/eventBus'
|
||||||
|
|
||||||
const { go } = useRouter()
|
const { go, url } = useRouter()
|
||||||
const { base, ContextMenu, open, trigger } = useContextMenu()
|
const { base, ContextMenu, open, trigger } = useContextMenu()
|
||||||
|
|
||||||
const album = ref<Album>()
|
const album = ref<Album>()
|
||||||
|
@ -40,16 +40,16 @@ const isStandardArtist = computed(() => {
|
||||||
|
|
||||||
const play = () => trigger(async () => {
|
const play = () => trigger(async () => {
|
||||||
playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value!))
|
playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value!))
|
||||||
go('queue')
|
go(url('queue'))
|
||||||
})
|
})
|
||||||
|
|
||||||
const shuffle = () => trigger(async () => {
|
const shuffle = () => trigger(async () => {
|
||||||
playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value!), true)
|
playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value!), true)
|
||||||
go('queue')
|
go(url('queue'))
|
||||||
})
|
})
|
||||||
|
|
||||||
const viewAlbumDetails = () => trigger(() => go(`album/${album.value!.id}`))
|
const viewAlbumDetails = () => trigger(() => go(url('albums.show', { id: album.value!.id })))
|
||||||
const viewArtistDetails = () => trigger(() => go(`artist/${album.value!.artist_id}`))
|
const viewArtistDetails = () => trigger(() => go(url('artists.show', { id: album.value!.artist_id })))
|
||||||
const download = () => trigger(() => downloadService.fromAlbum(album.value!))
|
const download = () => trigger(() => downloadService.fromAlbum(album.value!))
|
||||||
|
|
||||||
eventBus.on('ALBUM_CONTEXT_MENU_REQUESTED', async ({ pageX, pageY }, _album) => {
|
eventBus.on('ALBUM_CONTEXT_MENU_REQUESTED', async ({ pageX, pageY }, _album) => {
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
exports[`renders 1`] = `
|
exports[`renders 1`] = `
|
||||||
<article data-v-2487c4e3="" class="full relative group flex max-w-full md:max-w-[256px] border p-5 rounded-lg flex-col gap-5 transition border-color duration-200" data-testid="artist-album-card" draggable="true" tabindex="0" title="IV by Led Zeppelin"><button data-v-40f79232="" data-v-2487c4e3="" class="thumbnail relative w-full aspect-square bg-no-repeat bg-cover bg-center overflow-hidden rounded-md active:scale-95" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-testid="album-artist-thumbnail"><img data-v-40f79232="" alt="Thumbnail" src="http://loremflickr.com/640/480" class="w-full aspect-square object-cover" loading="lazy"><span data-v-40f79232="" class="hidden">Play all songs in the album IV</span><span data-v-40f79232="" class="absolute top-0 left-0 w-full h-full group-hover:bg-black/40 no-hover:bg-black/40 z-10"></span><span data-v-40f79232="" class="play-icon absolute flex opacity-0 no-hover:opacity-100 items-center justify-center w-[32px] aspect-square rounded-full top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-k-highlight group-hover:opacity-100 duration-500 transition z-20"><br data-v-40f79232="" data-testid="Icon" icon="[object Object]" class="ml-0.5 text-white" size="lg"></span></button>
|
<article data-v-2487c4e3="" class="full relative group flex max-w-full md:max-w-[256px] border p-5 rounded-lg flex-col gap-5 transition border-color duration-200" data-testid="artist-album-card" draggable="true" tabindex="0" title="IV by Led Zeppelin"><button data-v-40f79232="" data-v-2487c4e3="" class="thumbnail relative w-full aspect-square bg-no-repeat bg-cover bg-center overflow-hidden rounded-md active:scale-95" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-testid="album-artist-thumbnail"><img data-v-40f79232="" alt="Thumbnail" src="http://loremflickr.com/640/480" class="w-full aspect-square object-cover" loading="lazy"><span data-v-40f79232="" class="hidden">Play all songs in the album IV</span><span data-v-40f79232="" class="absolute top-0 left-0 w-full h-full group-hover:bg-black/40 no-hover:bg-black/40 z-10"></span><span data-v-40f79232="" class="play-icon absolute flex opacity-0 no-hover:opacity-100 items-center justify-center w-[32px] aspect-square rounded-full top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-k-highlight group-hover:opacity-100 duration-500 transition z-20"><br data-v-40f79232="" data-testid="Icon" icon="[object Object]" class="ml-0.5 text-white" size="lg"></span></button>
|
||||||
<footer data-v-2487c4e3="" class="flex flex-1 flex-col gap-1.5 overflow-hidden">
|
<footer data-v-2487c4e3="" class="flex flex-1 flex-col gap-1.5 overflow-hidden">
|
||||||
<div data-v-2487c4e3="" class="name flex flex-col gap-2 whitespace-nowrap"><a href="#/album/42" class="font-medium" data-testid="name">IV</a><a href="#/artist/17">Led Zeppelin</a></div>
|
<div data-v-2487c4e3="" class="name flex flex-col gap-2 whitespace-nowrap"><a href="/#/albums/42" class="font-medium" data-testid="name">IV</a><a href="/#/artists/17">Led Zeppelin</a></div>
|
||||||
<p data-v-2487c4e3="" class="meta text-[0.9rem] flex gap-1.5 opacity-70 hover:opacity-100"><a title="Shuffle all songs in the album IV" role="button"> Shuffle </a><a title="Download all songs in the album IV" role="button"> Download </a></p>
|
<p data-v-2487c4e3="" class="meta text-[0.9rem] flex gap-1.5 opacity-70 hover:opacity-100"><a title="Shuffle all songs in the album IV" role="button"> Shuffle </a><a title="Download all songs in the album IV" role="button"> Download </a></p>
|
||||||
</footer>
|
</footer>
|
||||||
</article>
|
</article>
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
@dragstart="onDragStart"
|
@dragstart="onDragStart"
|
||||||
>
|
>
|
||||||
<template #name>
|
<template #name>
|
||||||
<a :href="`#/artist/${artist.id}`" class="font-medium" data-testid="name">{{ artist.name }}</a>
|
<a :href="url('artists.show', { id: artist.id })" class="font-medium" data-testid="name">{{ artist.name }}</a>
|
||||||
</template>
|
</template>
|
||||||
<template #meta>
|
<template #meta>
|
||||||
<a :title="`Shuffle all songs by ${artist.name}`" role="button" @click.prevent="shuffle">
|
<a :title="`Shuffle all songs by ${artist.name}`" role="button" @click.prevent="shuffle">
|
||||||
|
@ -36,7 +36,7 @@ import { useRouter } from '@/composables/useRouter'
|
||||||
import BaseCard from '@/components/ui/album-artist/AlbumOrArtistCard.vue'
|
import BaseCard from '@/components/ui/album-artist/AlbumOrArtistCard.vue'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{ artist: Artist, layout?: ArtistAlbumCardLayout }>(), { layout: 'full' })
|
const props = withDefaults(defineProps<{ artist: Artist, layout?: ArtistAlbumCardLayout }>(), { layout: 'full' })
|
||||||
const { go } = useRouter()
|
const { go, url } = useRouter()
|
||||||
const { startDragging } = useDraggable('artist')
|
const { startDragging } = useDraggable('artist')
|
||||||
|
|
||||||
const { artist, layout } = toRefs(props)
|
const { artist, layout } = toRefs(props)
|
||||||
|
@ -48,7 +48,7 @@ const showing = computed(() => artistStore.isStandard(artist.value))
|
||||||
|
|
||||||
const shuffle = async () => {
|
const shuffle = async () => {
|
||||||
playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value), true /* shuffled */)
|
playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value), true /* shuffled */)
|
||||||
go('queue')
|
go(url('queue'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const download = () => downloadService.fromArtist(artist.value)
|
const download = () => downloadService.fromArtist(artist.value)
|
||||||
|
|
|
@ -64,7 +64,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
await screen.getByText('Go to Artist').click()
|
await screen.getByText('Go to Artist').click()
|
||||||
|
|
||||||
expect(mock).toHaveBeenCalledWith(`artist/${artist.id}`)
|
expect(mock).toHaveBeenCalledWith(`/#/artists/${artist.id}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not have an option to download or go to Unknown Artist', async () => {
|
it('does not have an option to download or go to Unknown Artist', async () => {
|
||||||
|
|
|
@ -26,7 +26,7 @@ import { useContextMenu } from '@/composables/useContextMenu'
|
||||||
import { useRouter } from '@/composables/useRouter'
|
import { useRouter } from '@/composables/useRouter'
|
||||||
import { eventBus } from '@/utils/eventBus'
|
import { eventBus } from '@/utils/eventBus'
|
||||||
|
|
||||||
const { go } = useRouter()
|
const { go, url } = useRouter()
|
||||||
const { base, ContextMenu, open, trigger } = useContextMenu()
|
const { base, ContextMenu, open, trigger } = useContextMenu()
|
||||||
|
|
||||||
const artist = ref<Artist>()
|
const artist = ref<Artist>()
|
||||||
|
@ -39,15 +39,15 @@ const isStandardArtist = computed(() =>
|
||||||
|
|
||||||
const play = () => trigger(async () => {
|
const play = () => trigger(async () => {
|
||||||
playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value!))
|
playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value!))
|
||||||
go('queue')
|
go(url('queue'))
|
||||||
})
|
})
|
||||||
|
|
||||||
const shuffle = () => trigger(async () => {
|
const shuffle = () => trigger(async () => {
|
||||||
playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value!), true)
|
playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value!), true)
|
||||||
go('queue')
|
go(url('queue'))
|
||||||
})
|
})
|
||||||
|
|
||||||
const viewArtistDetails = () => trigger(() => go(`artist/${artist.value!.id}`))
|
const viewArtistDetails = () => trigger(() => go(url('artists.show', { id: artist.value!.id })))
|
||||||
const download = () => trigger(() => downloadService.fromArtist(artist.value!))
|
const download = () => trigger(() => downloadService.fromArtist(artist.value!))
|
||||||
|
|
||||||
eventBus.on('ARTIST_CONTEXT_MENU_REQUESTED', async ({ pageX, pageY }, _artist) => {
|
eventBus.on('ARTIST_CONTEXT_MENU_REQUESTED', async ({ pageX, pageY }, _artist) => {
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
exports[`renders 1`] = `
|
exports[`renders 1`] = `
|
||||||
<article data-v-2487c4e3="" class="full relative group flex max-w-full md:max-w-[256px] border p-5 rounded-lg flex-col gap-5 transition border-color duration-200" data-testid="artist-album-card" draggable="true" tabindex="0" title="Led Zeppelin"><button data-v-40f79232="" data-v-2487c4e3="" class="thumbnail relative w-full aspect-square bg-no-repeat bg-cover bg-center overflow-hidden rounded-md active:scale-95" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-testid="album-artist-thumbnail"><img data-v-40f79232="" alt="Thumbnail" src="foo.jpg" class="w-full aspect-square object-cover" loading="lazy"><span data-v-40f79232="" class="hidden">Play all songs by Led Zeppelin</span><span data-v-40f79232="" class="absolute top-0 left-0 w-full h-full group-hover:bg-black/40 no-hover:bg-black/40 z-10"></span><span data-v-40f79232="" class="play-icon absolute flex opacity-0 no-hover:opacity-100 items-center justify-center w-[32px] aspect-square rounded-full top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-k-highlight group-hover:opacity-100 duration-500 transition z-20"><br data-v-40f79232="" data-testid="Icon" icon="[object Object]" class="ml-0.5 text-white" size="lg"></span></button>
|
<article data-v-2487c4e3="" class="full relative group flex max-w-full md:max-w-[256px] border p-5 rounded-lg flex-col gap-5 transition border-color duration-200" data-testid="artist-album-card" draggable="true" tabindex="0" title="Led Zeppelin"><button data-v-40f79232="" data-v-2487c4e3="" class="thumbnail relative w-full aspect-square bg-no-repeat bg-cover bg-center overflow-hidden rounded-md active:scale-95" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-testid="album-artist-thumbnail"><img data-v-40f79232="" alt="Thumbnail" src="foo.jpg" class="w-full aspect-square object-cover" loading="lazy"><span data-v-40f79232="" class="hidden">Play all songs by Led Zeppelin</span><span data-v-40f79232="" class="absolute top-0 left-0 w-full h-full group-hover:bg-black/40 no-hover:bg-black/40 z-10"></span><span data-v-40f79232="" class="play-icon absolute flex opacity-0 no-hover:opacity-100 items-center justify-center w-[32px] aspect-square rounded-full top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-k-highlight group-hover:opacity-100 duration-500 transition z-20"><br data-v-40f79232="" data-testid="Icon" icon="[object Object]" class="ml-0.5 text-white" size="lg"></span></button>
|
||||||
<footer data-v-2487c4e3="" class="flex flex-1 flex-col gap-1.5 overflow-hidden">
|
<footer data-v-2487c4e3="" class="flex flex-1 flex-col gap-1.5 overflow-hidden">
|
||||||
<div data-v-2487c4e3="" class="name flex flex-col gap-2 whitespace-nowrap"><a href="#/artist/42" class="font-medium" data-testid="name">Led Zeppelin</a></div>
|
<div data-v-2487c4e3="" class="name flex flex-col gap-2 whitespace-nowrap"><a href="/#/artists/42" class="font-medium" data-testid="name">Led Zeppelin</a></div>
|
||||||
<p data-v-2487c4e3="" class="meta text-[0.9rem] flex gap-1.5 opacity-70 hover:opacity-100"><a title="Shuffle all songs by Led Zeppelin" role="button"> Shuffle </a><a title="Download all songs by Led Zeppelin" role="button"> Download </a></p>
|
<p data-v-2487c4e3="" class="meta text-[0.9rem] flex gap-1.5 opacity-70 hover:opacity-100"><a title="Shuffle all songs by Led Zeppelin" role="button"> Shuffle </a><a title="Download all songs by Led Zeppelin" role="button"> Download </a></p>
|
||||||
</footer>
|
</footer>
|
||||||
</article>
|
</article>
|
||||||
|
|
|
@ -46,11 +46,11 @@ import FooterQueueIcon from '@/components/layout/app-footer/FooterQueueButton.vu
|
||||||
const isFullscreen = ref(false)
|
const isFullscreen = ref(false)
|
||||||
const fullscreenButtonTitle = computed(() => (isFullscreen.value ? 'Exit fullscreen mode' : 'Enter fullscreen mode'))
|
const fullscreenButtonTitle = computed(() => (isFullscreen.value ? 'Exit fullscreen mode' : 'Enter fullscreen mode'))
|
||||||
|
|
||||||
const { go, isCurrentScreen } = useRouter()
|
const { go, isCurrentScreen, url } = useRouter()
|
||||||
|
|
||||||
const showEqualizer = () => eventBus.emit('MODAL_SHOW_EQUALIZER')
|
const showEqualizer = () => eventBus.emit('MODAL_SHOW_EQUALIZER')
|
||||||
const toggleFullscreen = () => eventBus.emit('FULLSCREEN_TOGGLE')
|
const toggleFullscreen = () => eventBus.emit('FULLSCREEN_TOGGLE')
|
||||||
const toggleVisualizer = () => go(isCurrentScreen('Visualizer') ? -1 : 'visualizer')
|
const toggleVisualizer = () => go(isCurrentScreen('Visualizer') ? -1 : url('visualizer'))
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('fullscreenchange', () => {
|
document.addEventListener('fullscreenchange', () => {
|
||||||
|
|
|
@ -11,7 +11,7 @@ new class extends UnitTestCase {
|
||||||
this.render(Component)
|
this.render(Component)
|
||||||
|
|
||||||
await this.user.click(screen.getByRole('button'))
|
await this.user.click(screen.getByRole('button'))
|
||||||
expect(goMock).toHaveBeenCalledWith('queue')
|
expect(goMock).toHaveBeenCalledWith('/#/queue')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('goes back if current screen is Queue', async () => {
|
it('goes back if current screen is Queue', async () => {
|
||||||
|
|
|
@ -24,7 +24,7 @@ import { pluralize } from '@/utils/formatters'
|
||||||
|
|
||||||
import FooterButton from '@/components/layout/app-footer/FooterButton.vue'
|
import FooterButton from '@/components/layout/app-footer/FooterButton.vue'
|
||||||
|
|
||||||
const { go, isCurrentScreen } = useRouter()
|
const { go, isCurrentScreen, url } = useRouter()
|
||||||
const { toastWarning, toastSuccess } = useMessageToaster()
|
const { toastWarning, toastSuccess } = useMessageToaster()
|
||||||
|
|
||||||
const { acceptsDrop, resolveDroppedItems } = useDroppable(
|
const { acceptsDrop, resolveDroppedItems } = useDroppable(
|
||||||
|
@ -58,7 +58,7 @@ const onDrop = async (event: DragEvent) => {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const showQueue = () => go(isCurrentScreen('Queue') ? -1 : 'queue')
|
const showQueue = () => go(isCurrentScreen('Queue') ? -1 : url('queue'))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="postcss" scoped>
|
<style lang="postcss" scoped>
|
||||||
|
|
|
@ -25,23 +25,25 @@ import { getPlayableProp, requireInjection } from '@/utils/helpers'
|
||||||
import { isSong } from '@/utils/typeGuards'
|
import { isSong } from '@/utils/typeGuards'
|
||||||
import { CurrentPlayableKey } from '@/symbols'
|
import { CurrentPlayableKey } from '@/symbols'
|
||||||
import { useDraggable } from '@/composables/useDragAndDrop'
|
import { useDraggable } from '@/composables/useDragAndDrop'
|
||||||
|
import { useRouter } from '@/composables/useRouter'
|
||||||
|
|
||||||
const { startDragging } = useDraggable('playables')
|
const { startDragging } = useDraggable('playables')
|
||||||
|
const { url } = useRouter()
|
||||||
|
|
||||||
const song = requireInjection(CurrentPlayableKey, ref())
|
const song = requireInjection(CurrentPlayableKey, ref())
|
||||||
|
|
||||||
const cover = computed(() => {
|
const cover = computed(() => {
|
||||||
if (!song.value) {
|
return song.value ? getPlayableProp(song.value, 'album_cover', 'episode_image') : defaultCover
|
||||||
return defaultCover
|
|
||||||
}
|
|
||||||
return getPlayableProp(song.value, 'album_cover', 'episode_image')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const artistOrPodcastUri = computed(() => {
|
const artistOrPodcastUri = computed(() => {
|
||||||
if (!song.value) {
|
if (!song.value) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
return isSong(song.value) ? `#/artist/${song.value?.artist_id}` : `#/podcasts/${song.value.podcast_id}`
|
|
||||||
|
return isSong(song.value)
|
||||||
|
? url('artists.show', { id: song.value?.artist_id })
|
||||||
|
: url('podcasts.show', { id: song.value?.podcast_id })
|
||||||
})
|
})
|
||||||
|
|
||||||
const artistOrPodcastName = computed(() => {
|
const artistOrPodcastName = computed(() => {
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
exports[`renders with current song 1`] = `
|
exports[`renders with current song 1`] = `
|
||||||
<div data-v-91ed60f7="" class="playing song-info px-6 py-0 flex items-center content-start w-[84px] md:w-[420px] gap-5" draggable="true"><span data-v-91ed60f7="" class="album-thumb block h-[55%] md:h-3/4 aspect-square rounded-full bg-cover"></span>
|
<div data-v-91ed60f7="" class="playing song-info px-6 py-0 flex items-center content-start w-[84px] md:w-[420px] gap-5" draggable="true"><span data-v-91ed60f7="" class="album-thumb block h-[55%] md:h-3/4 aspect-square rounded-full bg-cover"></span>
|
||||||
<div data-v-91ed60f7="" class="meta overflow-hidden hidden md:block">
|
<div data-v-91ed60f7="" class="meta overflow-hidden hidden md:block">
|
||||||
<h3 data-v-91ed60f7="" class="title text-ellipsis overflow-hidden whitespace-nowrap">Fahrstuhl zum Mond</h3><a data-v-91ed60f7="" href="#/artist/10" class="artist text-ellipsis overflow-hidden whitespace-nowrap block text-[0.9rem] !text-k-text-secondary hover:!text-k-accent">Led Zeppelin</a>
|
<h3 data-v-91ed60f7="" class="title text-ellipsis overflow-hidden whitespace-nowrap">Fahrstuhl zum Mond</h3><a data-v-91ed60f7="" href="/#/artists/10" class="artist text-ellipsis overflow-hidden whitespace-nowrap block text-[0.9rem] !text-k-text-secondary hover:!text-k-accent">Led Zeppelin</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<a
|
<a
|
||||||
class="bg-black/20 flex items-center px-3.5 rounded-md !text-k-text-secondary hover:!text-k-text-primary"
|
class="bg-black/20 flex items-center px-3.5 rounded-md !text-k-text-secondary hover:!text-k-text-primary"
|
||||||
href="#/home"
|
:href="url('home')"
|
||||||
@click="onClick"
|
@click="onClick"
|
||||||
>
|
>
|
||||||
<Icon :icon="faHome" fixed-width />
|
<Icon :icon="faHome" fixed-width />
|
||||||
|
@ -11,6 +11,8 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { faHome } from '@fortawesome/free-solid-svg-icons'
|
import { faHome } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { eventBus } from '@/utils/eventBus'
|
import { eventBus } from '@/utils/eventBus'
|
||||||
|
import { useRouter } from '@/composables/useRouter'
|
||||||
|
|
||||||
|
const { url } = useRouter()
|
||||||
const onClick = () => eventBus.emit('TOGGLE_SIDEBAR')
|
const onClick = () => eventBus.emit('TOGGLE_SIDEBAR')
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
:class="{ current, droppable }"
|
:class="{ current, droppable }"
|
||||||
:href="url"
|
:href="href"
|
||||||
class="playlist select-none"
|
class="playlist select-none"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
@contextmenu="onContextMenu"
|
@contextmenu="onContextMenu"
|
||||||
|
@ -34,7 +34,7 @@ import { usePlaylistManagement } from '@/composables/usePlaylistManagement'
|
||||||
import SidebarItem from '@/components/layout/main-wrapper/sidebar/SidebarItem.vue'
|
import SidebarItem from '@/components/layout/main-wrapper/sidebar/SidebarItem.vue'
|
||||||
|
|
||||||
const props = defineProps<{ list: PlaylistLike }>()
|
const props = defineProps<{ list: PlaylistLike }>()
|
||||||
const { onRouteChanged } = useRouter()
|
const { onRouteChanged, url } = useRouter()
|
||||||
const { startDragging } = useDraggable('playlist')
|
const { startDragging } = useDraggable('playlist')
|
||||||
const { acceptsDrop, resolveDroppedItems } = useDroppable(['playables', 'album', 'artist'])
|
const { acceptsDrop, resolveDroppedItems } = useDroppable(['playables', 'album', 'artist'])
|
||||||
|
|
||||||
|
@ -50,15 +50,17 @@ const isRecentlyPlayedList = (list: PlaylistLike): list is RecentlyPlayedList =>
|
||||||
|
|
||||||
const current = ref(false)
|
const current = ref(false)
|
||||||
|
|
||||||
const url = computed(() => {
|
const href = computed(() => {
|
||||||
if (isPlaylist(list.value)) {
|
if (isPlaylist(list.value)) {
|
||||||
return `#/playlist/${list.value.id}`
|
return url('playlists.show', { id: list.value.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isFavoriteList(list.value)) {
|
if (isFavoriteList(list.value)) {
|
||||||
return '#/favorites'
|
return url('favorites')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRecentlyPlayedList(list.value)) {
|
if (isRecentlyPlayedList(list.value)) {
|
||||||
return '#/recently-played'
|
return url('recently-played')
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error('Invalid playlist-like type.')
|
throw new Error('Invalid playlist-like type.')
|
||||||
|
|
|
@ -5,19 +5,19 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<ul class="menu">
|
<ul class="menu">
|
||||||
<SidebarItem v-if="isAdmin" href="#/settings" screen="Settings">
|
<SidebarItem v-if="isAdmin" :href="url('settings')" screen="Settings">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Icon :icon="faTools" fixed-width />
|
<Icon :icon="faTools" fixed-width />
|
||||||
</template>
|
</template>
|
||||||
Settings
|
Settings
|
||||||
</SidebarItem>
|
</SidebarItem>
|
||||||
<SidebarItem v-if="allowsUpload" href="#/upload" screen="Upload">
|
<SidebarItem v-if="allowsUpload" :href="url('upload')" screen="Upload">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Icon :icon="faUpload" fixed-width />
|
<Icon :icon="faUpload" fixed-width />
|
||||||
</template>
|
</template>
|
||||||
Upload
|
Upload
|
||||||
</SidebarItem>
|
</SidebarItem>
|
||||||
<SidebarItem v-if="isAdmin" href="#/users" screen="Users">
|
<SidebarItem v-if="isAdmin" :href="url('users.index')" screen="Users">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Icon :icon="faUsers" fixed-width />
|
<Icon :icon="faUsers" fixed-width />
|
||||||
</template>
|
</template>
|
||||||
|
@ -31,11 +31,13 @@
|
||||||
import { faTools, faUpload, faUsers } from '@fortawesome/free-solid-svg-icons'
|
import { faTools, faUpload, faUsers } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { useAuthorization } from '@/composables/useAuthorization'
|
import { useAuthorization } from '@/composables/useAuthorization'
|
||||||
import { useUpload } from '@/composables/useUpload'
|
import { useUpload } from '@/composables/useUpload'
|
||||||
|
import { useRouter } from '@/composables/useRouter'
|
||||||
|
|
||||||
import SidebarSection from '@/components/layout/main-wrapper/sidebar/SidebarSection.vue'
|
import SidebarSection from '@/components/layout/main-wrapper/sidebar/SidebarSection.vue'
|
||||||
import SidebarSectionHeader from '@/components/layout/main-wrapper/sidebar/SidebarSectionHeader.vue'
|
import SidebarSectionHeader from '@/components/layout/main-wrapper/sidebar/SidebarSectionHeader.vue'
|
||||||
import SidebarItem from '@/components/layout/main-wrapper/sidebar/SidebarItem.vue'
|
import SidebarItem from '@/components/layout/main-wrapper/sidebar/SidebarItem.vue'
|
||||||
|
|
||||||
|
const { url } = useRouter()
|
||||||
const { isAdmin } = useAuthorization()
|
const { isAdmin } = useAuthorization()
|
||||||
const { allowsUpload } = useUpload()
|
const { allowsUpload } = useUpload()
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -5,25 +5,25 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<ul class="menu">
|
<ul class="menu">
|
||||||
<SidebarItem href="#/songs" screen="Songs">
|
<SidebarItem :href="url('songs.index')" screen="Songs">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Icon :icon="faMusic" fixed-width />
|
<Icon :icon="faMusic" fixed-width />
|
||||||
</template>
|
</template>
|
||||||
All Songs
|
All Songs
|
||||||
</SidebarItem>
|
</SidebarItem>
|
||||||
<SidebarItem href="#/albums" screen="Albums">
|
<SidebarItem :href="url('albums.index')" screen="Albums">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Icon :icon="faCompactDisc" fixed-width />
|
<Icon :icon="faCompactDisc" fixed-width />
|
||||||
</template>
|
</template>
|
||||||
Albums
|
Albums
|
||||||
</SidebarItem>
|
</SidebarItem>
|
||||||
<SidebarItem href="#/artists" screen="Artists">
|
<SidebarItem :href="url('artists.index')" screen="Artists">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<MicVocalIcon size="16" />
|
<MicVocalIcon size="16" />
|
||||||
</template>
|
</template>
|
||||||
Artists
|
Artists
|
||||||
</SidebarItem>
|
</SidebarItem>
|
||||||
<SidebarItem href="#/genres" screen="Genres">
|
<SidebarItem :href="url('genres.index')" screen="Genres">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<GuitarIcon size="16" />
|
<GuitarIcon size="16" />
|
||||||
</template>
|
</template>
|
||||||
|
@ -32,7 +32,7 @@
|
||||||
<YouTubeSidebarItem v-if="youtubeVideoTitle" data-testid="youtube">
|
<YouTubeSidebarItem v-if="youtubeVideoTitle" data-testid="youtube">
|
||||||
{{ youtubeVideoTitle }}
|
{{ youtubeVideoTitle }}
|
||||||
</YouTubeSidebarItem>
|
</YouTubeSidebarItem>
|
||||||
<SidebarItem href="#/podcasts" screen="Podcasts">
|
<SidebarItem :href="url('podcasts.index')" screen="Podcasts">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Icon :icon="faPodcast" fixed-width />
|
<Icon :icon="faPodcast" fixed-width />
|
||||||
</template>
|
</template>
|
||||||
|
@ -48,6 +48,7 @@ import { GuitarIcon, MicVocalIcon } from 'lucide-vue-next'
|
||||||
import { unescape } from 'lodash'
|
import { unescape } from 'lodash'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { eventBus } from '@/utils/eventBus'
|
import { eventBus } from '@/utils/eventBus'
|
||||||
|
import { useRouter } from '@/composables/useRouter'
|
||||||
|
|
||||||
import SidebarSection from '@/components/layout/main-wrapper/sidebar/SidebarSection.vue'
|
import SidebarSection from '@/components/layout/main-wrapper/sidebar/SidebarSection.vue'
|
||||||
import SidebarSectionHeader from '@/components/layout/main-wrapper/sidebar/SidebarSectionHeader.vue'
|
import SidebarSectionHeader from '@/components/layout/main-wrapper/sidebar/SidebarSectionHeader.vue'
|
||||||
|
@ -55,6 +56,7 @@ import SidebarItem from '@/components/layout/main-wrapper/sidebar/SidebarItem.vu
|
||||||
import YouTubeSidebarItem from '@/components/layout/main-wrapper/sidebar/YouTubeSidebarItem.vue'
|
import YouTubeSidebarItem from '@/components/layout/main-wrapper/sidebar/YouTubeSidebarItem.vue'
|
||||||
|
|
||||||
const youtubeVideoTitle = ref<string | null>(null)
|
const youtubeVideoTitle = ref<string | null>(null)
|
||||||
|
const { url } = useRouter()
|
||||||
|
|
||||||
eventBus.on('PLAY_YOUTUBE_VIDEO', payload => (youtubeVideoTitle.value = unescape(payload.title)))
|
eventBus.on('PLAY_YOUTUBE_VIDEO', payload => (youtubeVideoTitle.value = unescape(payload.title)))
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<SidebarItem href="#/youtube" screen="YouTube">
|
<SidebarItem :href="url('youtube')" screen="YouTube">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Icon :icon="faYoutube" fixed-width />
|
<Icon :icon="faYoutube" fixed-width />
|
||||||
</template>
|
</template>
|
||||||
|
@ -9,6 +9,9 @@
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { faYoutube } from '@fortawesome/free-brands-svg-icons'
|
import { faYoutube } from '@fortawesome/free-brands-svg-icons'
|
||||||
|
import { useRouter } from '@/composables/useRouter'
|
||||||
|
|
||||||
import SidebarItem from './SidebarItem.vue'
|
import SidebarItem from './SidebarItem.vue'
|
||||||
|
|
||||||
|
const { url } = useRouter()
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
// Vitest Snapshot v1
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
exports[`renders 1`] = `<li data-v-7589e0e3="" class="relative before:right-0 px-6 before:top-1/4 before:w-[4px] before:h-1/2 before:absolute before:rounded-full before:transition-[box-shadow,_background-color] before:ease-in-out before:duration-500" data-testid="sidebar-item"><a data-v-7589e0e3="" href="#/youtube" class="flex items-center overflow-x-hidden gap-3 h-11 relative active:pt-0.5 active:pr-0 active:pb-0 active:pl-0.5 !text-k-text-secondary hover:!text-k-text-primary"><span data-v-7589e0e3="" class="opacity-70"><br data-testid="Icon" icon="[object Object]" fixed-width=""></span><span data-v-7589e0e3="" class="overflow-hidden text-ellipsis whitespace-nowrap">Another One Bites the Dust</span></a></li>`;
|
exports[`renders 1`] = `<li data-v-7589e0e3="" class="relative before:right-0 px-6 before:top-1/4 before:w-[4px] before:h-1/2 before:absolute before:rounded-full before:transition-[box-shadow,_background-color] before:ease-in-out before:duration-500" data-testid="sidebar-item"><a data-v-7589e0e3="" href="/#/youtube" class="flex items-center overflow-x-hidden gap-3 h-11 relative active:pt-0.5 active:pr-0 active:pb-0 active:pl-0.5 !text-k-text-secondary hover:!text-k-text-primary"><span data-v-7589e0e3="" class="opacity-70"><br data-testid="Icon" icon="[object Object]" fixed-width=""></span><span data-v-7589e0e3="" class="overflow-hidden text-ellipsis whitespace-nowrap">Another One Bites the Dust</span></a></li>`;
|
||||||
|
|
|
@ -55,7 +55,7 @@ const emit = defineEmits<{ (e: 'close'): void }>()
|
||||||
const { showOverlay, hideOverlay } = useOverlay()
|
const { showOverlay, hideOverlay } = useOverlay()
|
||||||
const { toastSuccess } = useMessageToaster()
|
const { toastSuccess } = useMessageToaster()
|
||||||
const { showConfirmDialog } = useDialogBox()
|
const { showConfirmDialog } = useDialogBox()
|
||||||
const { go } = useRouter()
|
const { go, url } = useRouter()
|
||||||
const { getFromContext } = useModal()
|
const { getFromContext } = useModal()
|
||||||
|
|
||||||
const targetFolder = getFromContext<PlaylistFolder | null>('folder') ?? null
|
const targetFolder = getFromContext<PlaylistFolder | null>('folder') ?? null
|
||||||
|
@ -88,7 +88,7 @@ const submit = async () => {
|
||||||
|
|
||||||
close()
|
close()
|
||||||
toastSuccess(`Playlist "${playlist.name}" created.`)
|
toastSuccess(`Playlist "${playlist.name}" created.`)
|
||||||
go(`playlist/${playlist.id}`)
|
go(url('playlists.show', { id: playlist.id }))
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
useErrorHandler('dialog').handleHttpError(error)
|
useErrorHandler('dialog').handleHttpError(error)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
@ -56,7 +56,7 @@ new class extends UnitTestCase {
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchMock).toHaveBeenCalledWith(playlist)
|
expect(fetchMock).toHaveBeenCalledWith(playlist)
|
||||||
expect(queueMock).toHaveBeenCalledWith(songs)
|
expect(queueMock).toHaveBeenCalledWith(songs)
|
||||||
expect(goMock).toHaveBeenCalledWith('queue')
|
expect(goMock).toHaveBeenCalledWith('/#/queue')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -92,7 +92,7 @@ new class extends UnitTestCase {
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchMock).toHaveBeenCalledWith(playlist)
|
expect(fetchMock).toHaveBeenCalledWith(playlist)
|
||||||
expect(queueMock).toHaveBeenCalledWith(songs, true)
|
expect(queueMock).toHaveBeenCalledWith(songs, true)
|
||||||
expect(goMock).toHaveBeenCalledWith('queue')
|
expect(goMock).toHaveBeenCalledWith('/#/queue')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -28,7 +28,7 @@ import { queueStore } from '@/stores/queueStore'
|
||||||
import { songStore } from '@/stores/songStore'
|
import { songStore } from '@/stores/songStore'
|
||||||
|
|
||||||
const { base, ContextMenu, open, trigger } = useContextMenu()
|
const { base, ContextMenu, open, trigger } = useContextMenu()
|
||||||
const { go } = useRouter()
|
const { go, url } = useRouter()
|
||||||
const { toastWarning, toastSuccess } = useMessageToaster()
|
const { toastWarning, toastSuccess } = useMessageToaster()
|
||||||
const { isPlus } = useKoelPlus()
|
const { isPlus } = useKoelPlus()
|
||||||
const { currentUserCan } = usePolicies()
|
const { currentUserCan } = usePolicies()
|
||||||
|
@ -46,7 +46,7 @@ const play = () => trigger(async () => {
|
||||||
|
|
||||||
if (songs.length) {
|
if (songs.length) {
|
||||||
playbackService.queueAndPlay(songs)
|
playbackService.queueAndPlay(songs)
|
||||||
go('queue')
|
go(url('queue'))
|
||||||
} else {
|
} else {
|
||||||
toastWarning('The playlist is empty.')
|
toastWarning('The playlist is empty.')
|
||||||
}
|
}
|
||||||
|
@ -57,7 +57,7 @@ const shuffle = () => trigger(async () => {
|
||||||
|
|
||||||
if (songs.length) {
|
if (songs.length) {
|
||||||
playbackService.queueAndPlay(songs, true)
|
playbackService.queueAndPlay(songs, true)
|
||||||
go('queue')
|
go(url('queue'))
|
||||||
} else {
|
} else {
|
||||||
toastWarning('The playlist is empty.')
|
toastWarning('The playlist is empty.')
|
||||||
}
|
}
|
||||||
|
|
|
@ -45,7 +45,7 @@ new class extends UnitTestCase {
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchMock).toHaveBeenCalledWith(folder)
|
expect(fetchMock).toHaveBeenCalledWith(folder)
|
||||||
expect(queueMock).toHaveBeenCalledWith(songs)
|
expect(queueMock).toHaveBeenCalledWith(songs)
|
||||||
expect(goMock).toHaveBeenCalledWith('queue')
|
expect(goMock).toHaveBeenCalledWith('/#/queue')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -82,7 +82,7 @@ new class extends UnitTestCase {
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchMock).toHaveBeenCalledWith(folder)
|
expect(fetchMock).toHaveBeenCalledWith(folder)
|
||||||
expect(queueMock).toHaveBeenCalledWith(songs, true)
|
expect(queueMock).toHaveBeenCalledWith(songs, true)
|
||||||
expect(goMock).toHaveBeenCalledWith('queue')
|
expect(goMock).toHaveBeenCalledWith('/#/queue')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -26,7 +26,7 @@ import { useMessageToaster } from '@/composables/useMessageToaster'
|
||||||
import { songStore } from '@/stores/songStore'
|
import { songStore } from '@/stores/songStore'
|
||||||
|
|
||||||
const { base, ContextMenu, open, trigger } = useContextMenu()
|
const { base, ContextMenu, open, trigger } = useContextMenu()
|
||||||
const { go } = useRouter()
|
const { go, url } = useRouter()
|
||||||
const { toastWarning } = useMessageToaster()
|
const { toastWarning } = useMessageToaster()
|
||||||
|
|
||||||
const folder = ref<PlaylistFolder>()
|
const folder = ref<PlaylistFolder>()
|
||||||
|
@ -39,7 +39,7 @@ const play = () => trigger(async () => {
|
||||||
|
|
||||||
if (songs.length) {
|
if (songs.length) {
|
||||||
playbackService.queueAndPlay(songs)
|
playbackService.queueAndPlay(songs)
|
||||||
go('queue')
|
go(url('queue'))
|
||||||
} else {
|
} else {
|
||||||
toastWarning('No songs available.')
|
toastWarning('No songs available.')
|
||||||
}
|
}
|
||||||
|
@ -50,7 +50,7 @@ const shuffle = () => trigger(async () => {
|
||||||
|
|
||||||
if (songs.length) {
|
if (songs.length) {
|
||||||
playbackService.queueAndPlay(songs, true)
|
playbackService.queueAndPlay(songs, true)
|
||||||
go('queue')
|
go(url('queue'))
|
||||||
} else {
|
} else {
|
||||||
toastWarning('No songs available.')
|
toastWarning('No songs available.')
|
||||||
}
|
}
|
||||||
|
|
|
@ -83,7 +83,7 @@ const {
|
||||||
const { showOverlay, hideOverlay } = useOverlay()
|
const { showOverlay, hideOverlay } = useOverlay()
|
||||||
const { toastSuccess } = useMessageToaster()
|
const { toastSuccess } = useMessageToaster()
|
||||||
const { showConfirmDialog } = useDialogBox()
|
const { showConfirmDialog } = useDialogBox()
|
||||||
const { go } = useRouter()
|
const { go, url } = useRouter()
|
||||||
const { isPlus } = useKoelPlus()
|
const { isPlus } = useKoelPlus()
|
||||||
|
|
||||||
const targetFolder = useModal().getFromContext<PlaylistFolder | null>('folder')
|
const targetFolder = useModal().getFromContext<PlaylistFolder | null>('folder')
|
||||||
|
@ -120,7 +120,7 @@ const submit = async () => {
|
||||||
|
|
||||||
close()
|
close()
|
||||||
toastSuccess(`Playlist "${playlist.name}" created.`)
|
toastSuccess(`Playlist "${playlist.name}" created.`)
|
||||||
go(`playlist/${playlist.id}`)
|
go(url('playlists.show', { id: playlist.id }))
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
useErrorHandler('dialog').handleHttpError(error)
|
useErrorHandler('dialog').handleHttpError(error)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
data-testid="episode-item"
|
data-testid="episode-item"
|
||||||
class="group relative flex flex-col md:flex-row gap-4 px-6 py-5 !text-k-text-primary hover:bg-white/10 duration-200"
|
class="group relative flex flex-col md:flex-row gap-4 px-6 py-5 !text-k-text-primary hover:bg-white/10 duration-200"
|
||||||
:class="isCurrentEpisode && 'current'"
|
:class="isCurrentEpisode && 'current'"
|
||||||
:href="`/#/episodes/${episode.id}`"
|
:href="url('episodes.show', { id: episode.id })"
|
||||||
@contextmenu.prevent="requestContextMenu"
|
@contextmenu.prevent="requestContextMenu"
|
||||||
@dragstart="onDragStart"
|
@dragstart="onDragStart"
|
||||||
>
|
>
|
||||||
|
@ -62,6 +62,7 @@ import { playbackService } from '@/services/playbackService'
|
||||||
import { songStore as episodeStore } from '@/stores/songStore'
|
import { songStore as episodeStore } from '@/stores/songStore'
|
||||||
import { queueStore } from '@/stores/queueStore'
|
import { queueStore } from '@/stores/queueStore'
|
||||||
import { preferenceStore as preferences } from '@/stores/preferenceStore'
|
import { preferenceStore as preferences } from '@/stores/preferenceStore'
|
||||||
|
import { useRouter } from '@/composables/useRouter'
|
||||||
|
|
||||||
const props = defineProps<{ episode: Episode, podcast: Podcast }>()
|
const props = defineProps<{ episode: Episode, podcast: Podcast }>()
|
||||||
|
|
||||||
|
@ -70,6 +71,7 @@ const EpisodeProgress = defineAsyncComponent(() => import('@/components/podcast/
|
||||||
const { episode, podcast } = toRefs(props)
|
const { episode, podcast } = toRefs(props)
|
||||||
|
|
||||||
const { startDragging } = useDraggable('playables')
|
const { startDragging } = useDraggable('playables')
|
||||||
|
const { url } = useRouter()
|
||||||
|
|
||||||
const publicationDateForHumans = computed(() => {
|
const publicationDateForHumans = computed(() => {
|
||||||
const publishedAt = new Date(episode.value.created_at)
|
const publishedAt = new Date(episode.value.created_at)
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
@click="goToPodcast"
|
@click="goToPodcast"
|
||||||
>
|
>
|
||||||
<template #name>
|
<template #name>
|
||||||
<a :href="`#/podcasts/${podcast.id}`" class="font-medium" data-testid="title">{{ podcast.title }}</a>
|
<a :href="href" class="font-medium" data-testid="title">{{ podcast.title }}</a>
|
||||||
<span class="text-k-text-secondary">{{ podcast.author }}</span>
|
<span class="text-k-text-secondary">{{ podcast.author }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -26,7 +26,8 @@ import BaseCard from '@/components/ui/album-artist/AlbumOrArtistCard.vue'
|
||||||
const props = withDefaults(defineProps<{ podcast: Podcast, layout?: ArtistAlbumCardLayout }>(), { layout: 'full' })
|
const props = withDefaults(defineProps<{ podcast: Podcast, layout?: ArtistAlbumCardLayout }>(), { layout: 'full' })
|
||||||
const { podcast, layout } = toRefs(props)
|
const { podcast, layout } = toRefs(props)
|
||||||
|
|
||||||
const { go } = useRouter()
|
const { go, url } = useRouter()
|
||||||
|
|
||||||
const goToPodcast = () => go(`/podcasts/${podcast.value.id}`)
|
const href = url('podcasts.show', { id: podcast.value.id })
|
||||||
|
const goToPodcast = () => go(href)
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
<a
|
<a
|
||||||
data-testid="podcast-item"
|
data-testid="podcast-item"
|
||||||
class="flex gap-5 p-5 rounded-lg border border-white/5 hover:bg-white/10 bg-white/5 !text-k-text-primary !hover:text-k-text-primary"
|
class="flex gap-5 p-5 rounded-lg border border-white/5 hover:bg-white/10 bg-white/5 !text-k-text-primary !hover:text-k-text-primary"
|
||||||
:href="`#/podcasts/${podcast.id}`"
|
:href="url('podcasts.show', { id: podcast.id })"
|
||||||
>
|
>
|
||||||
<aside class="hidden md:block md:flex-[0_0_128px]">
|
<aside class="hidden md:block md:flex-[0_0_128px]">
|
||||||
<img :src="podcast.image" alt="Podcast image" class="w-[128px] aspect-square object-cover rounded-lg">
|
<img :src="podcast.image" alt="Podcast image" class="w-[128px] aspect-square object-cover rounded-lg">
|
||||||
|
@ -29,9 +29,12 @@
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
import { formatTimeAgo } from '@vueuse/core'
|
import { formatTimeAgo } from '@vueuse/core'
|
||||||
|
import { useRouter } from '@/composables/useRouter'
|
||||||
|
|
||||||
const { podcast } = defineProps<{ podcast: Podcast }>()
|
const { podcast } = defineProps<{ podcast: Podcast }>()
|
||||||
|
|
||||||
|
const { url } = useRouter()
|
||||||
|
|
||||||
const description = computed(() => DOMPurify.sanitize(podcast.description))
|
const description = computed(() => DOMPurify.sanitize(podcast.description))
|
||||||
|
|
||||||
const lastPlayedAt = computed(() => podcast.state.current_episode
|
const lastPlayedAt = computed(() => podcast.state.current_episode
|
||||||
|
|
|
@ -28,11 +28,19 @@ new class extends UnitTestCase {
|
||||||
const byIdMock = this.mock(albumStore, 'byId', null)
|
const byIdMock = this.mock(albumStore, 'byId', null)
|
||||||
await this.renderComponent()
|
await this.renderComponent()
|
||||||
|
|
||||||
eventBus.emit('SONGS_UPDATED')
|
eventBus.emit('SONGS_UPDATED', {
|
||||||
|
songs: [],
|
||||||
|
artists: [],
|
||||||
|
albums: [],
|
||||||
|
removed: {
|
||||||
|
albums: [],
|
||||||
|
artists: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(byIdMock).toHaveBeenCalledWith(album.id)
|
expect(byIdMock).toHaveBeenCalledWith(album.id)
|
||||||
expect(goMock).toHaveBeenCalledWith('albums')
|
expect(goMock).toHaveBeenCalledWith('/#/albums')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -12,7 +12,9 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #meta>
|
<template #meta>
|
||||||
<a v-if="isNormalArtist" :href="`#/artist/${album.artist_id}`" class="artist">{{ album.artist_name }}</a>
|
<a v-if="isNormalArtist" :href="url('artists.show', { id: album.artist_id })" class="artist">
|
||||||
|
{{ album.artist_name }}
|
||||||
|
</a>
|
||||||
<span v-else class="nope">{{ album.artist_name }}</span>
|
<span v-else class="nope">{{ album.artist_name }}</span>
|
||||||
<span>{{ pluralize(songs, 'item') }}</span>
|
<span>{{ pluralize(songs, 'item') }}</span>
|
||||||
<span>{{ duration }}</span>
|
<span>{{ duration }}</span>
|
||||||
|
@ -116,7 +118,7 @@ const AlbumInfo = defineAsyncComponent(() => import('@/components/album/AlbumInf
|
||||||
const AlbumCard = defineAsyncComponent(() => import('@/components/album/AlbumCard.vue'))
|
const AlbumCard = defineAsyncComponent(() => import('@/components/album/AlbumCard.vue'))
|
||||||
const AlbumCardSkeleton = defineAsyncComponent(() => import('@/components/ui/skeletons/ArtistAlbumCardSkeleton.vue'))
|
const AlbumCardSkeleton = defineAsyncComponent(() => import('@/components/ui/skeletons/ArtistAlbumCardSkeleton.vue'))
|
||||||
|
|
||||||
const { getRouteParam, go, onScreenActivated } = useRouter()
|
const { getRouteParam, go, onScreenActivated, url } = useRouter()
|
||||||
|
|
||||||
const albumId = ref<number>()
|
const albumId = ref<number>()
|
||||||
const album = ref<Album | undefined>()
|
const album = ref<Album | undefined>()
|
||||||
|
@ -194,5 +196,5 @@ watch(albumId, async id => {
|
||||||
onScreenActivated('Album', () => (albumId.value = Number.parseInt(getRouteParam('id')!)))
|
onScreenActivated('Album', () => (albumId.value = Number.parseInt(getRouteParam('id')!)))
|
||||||
|
|
||||||
// if the current album has been deleted, go back to the list
|
// if the current album has been deleted, go back to the list
|
||||||
eventBus.on('SONGS_UPDATED', () => albumStore.byId(albumId.value!) || go('albums'))
|
eventBus.on('SONGS_UPDATED', () => albumStore.byId(albumId.value!) || go(url('albums.index')))
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -35,7 +35,7 @@ new class extends UnitTestCase {
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(queueMock).toHaveBeenCalled()
|
expect(queueMock).toHaveBeenCalled()
|
||||||
expect(playMock).toHaveBeenCalled()
|
expect(playMock).toHaveBeenCalled()
|
||||||
expect(goMock).toHaveBeenCalledWith('queue')
|
expect(goMock).toHaveBeenCalledWith('/#/queue')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -101,7 +101,7 @@ const {
|
||||||
|
|
||||||
const { SongListControls, config } = useSongListControls('Songs')
|
const { SongListControls, config } = useSongListControls('Songs')
|
||||||
|
|
||||||
const { go, onScreenActivated } = useRouter()
|
const { go, onScreenActivated, url } = useRouter()
|
||||||
const { isPlus } = useKoelPlus()
|
const { isPlus } = useKoelPlus()
|
||||||
const { get: lsGet, set: lsSet } = useLocalStorage()
|
const { get: lsGet, set: lsSet } = useLocalStorage()
|
||||||
|
|
||||||
|
@ -144,7 +144,7 @@ const playAll = async (shuffle: boolean) => {
|
||||||
await queueStore.fetchInOrder(sortField, sortOrder)
|
await queueStore.fetchInOrder(sortField, sortOrder)
|
||||||
}
|
}
|
||||||
|
|
||||||
go('queue')
|
go(url('queue'))
|
||||||
await playbackService.playFirstInQueue()
|
await playbackService.playFirstInQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -64,11 +64,19 @@ new class extends UnitTestCase {
|
||||||
const byIdMock = this.mock(artistStore, 'byId', null)
|
const byIdMock = this.mock(artistStore, 'byId', null)
|
||||||
await this.renderComponent()
|
await this.renderComponent()
|
||||||
|
|
||||||
eventBus.emit('SONGS_UPDATED')
|
eventBus.emit('SONGS_UPDATED', {
|
||||||
|
songs: [],
|
||||||
|
artists: [],
|
||||||
|
albums: [],
|
||||||
|
removed: {
|
||||||
|
albums: [],
|
||||||
|
artists: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(byIdMock).toHaveBeenCalledWith(artist.id)
|
expect(byIdMock).toHaveBeenCalledWith(artist.id)
|
||||||
expect(goMock).toHaveBeenCalledWith('artists')
|
expect(goMock).toHaveBeenCalledWith('/#/artists')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -112,7 +112,7 @@ const AlbumCardSkeleton = defineAsyncComponent(() => import('@/components/ui/ske
|
||||||
type Tab = 'Songs' | 'Albums' | 'Info'
|
type Tab = 'Songs' | 'Albums' | 'Info'
|
||||||
const activeTab = ref<Tab>('Songs')
|
const activeTab = ref<Tab>('Songs')
|
||||||
|
|
||||||
const { getRouteParam, go, onScreenActivated } = useRouter()
|
const { getRouteParam, go, onScreenActivated, url } = useRouter()
|
||||||
|
|
||||||
const artistId = ref<number>()
|
const artistId = ref<number>()
|
||||||
const artist = ref<Artist>()
|
const artist = ref<Artist>()
|
||||||
|
@ -179,5 +179,5 @@ const download = () => downloadService.fromArtist(artist.value!)
|
||||||
onScreenActivated('Artist', () => (artistId.value = Number.parseInt(getRouteParam('id')!)))
|
onScreenActivated('Artist', () => (artistId.value = Number.parseInt(getRouteParam('id')!)))
|
||||||
|
|
||||||
// if the current artist has been deleted, go back to the list
|
// if the current artist has been deleted, go back to the list
|
||||||
eventBus.on('SONGS_UPDATED', () => artistStore.byId(artist.value!.id) || go('artists'))
|
eventBus.on('SONGS_UPDATED', () => artistStore.byId(artist.value!.id) || go(url('artists.index')))
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -7,7 +7,10 @@
|
||||||
<h1 class="text-ellipsis overflow-hidden whitespace-nowrap" :title="episode.title">{{ episode.title }}</h1>
|
<h1 class="text-ellipsis overflow-hidden whitespace-nowrap" :title="episode.title">{{ episode.title }}</h1>
|
||||||
|
|
||||||
<h2 class="text-2xl text-k-text-secondary">
|
<h2 class="text-2xl text-k-text-secondary">
|
||||||
<a :href="`/#/podcasts/${episode.podcast_id}`" class="!text-k-text-primary hover:!text-k-accent font-normal">
|
<a
|
||||||
|
:href="url('podcasts.show', { id: episode.podcast_id })"
|
||||||
|
class="!text-k-text-primary hover:!text-k-accent font-normal"
|
||||||
|
>
|
||||||
{{ episode.podcast_title }}
|
{{ episode.podcast_title }}
|
||||||
</a>
|
</a>
|
||||||
</h2>
|
</h2>
|
||||||
|
@ -51,6 +54,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { faDownload, faExternalLink, faPause, faPlay } from '@fortawesome/free-solid-svg-icons'
|
import { faDownload, faExternalLink, faPause, faPlay } from '@fortawesome/free-solid-svg-icons'
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
|
import { orderBy } from 'lodash'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { songStore as episodeStore } from '@/stores/songStore'
|
import { songStore as episodeStore } from '@/stores/songStore'
|
||||||
import { queueStore } from '@/stores/queueStore'
|
import { queueStore } from '@/stores/queueStore'
|
||||||
|
@ -66,9 +70,8 @@ import ScreenBase from '@/components/screens/ScreenBase.vue'
|
||||||
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
||||||
import Btn from '@/components/ui/form/Btn.vue'
|
import Btn from '@/components/ui/form/Btn.vue'
|
||||||
import ScreenHeaderSkeleton from '@/components/ui/skeletons/ScreenHeaderSkeleton.vue'
|
import ScreenHeaderSkeleton from '@/components/ui/skeletons/ScreenHeaderSkeleton.vue'
|
||||||
import { orderBy } from 'lodash'
|
|
||||||
|
|
||||||
const { onScreenActivated, getRouteParam, triggerNotFound } = useRouter()
|
const { onScreenActivated, getRouteParam, triggerNotFound, url } = useRouter()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const episodeId = ref<string>()
|
const episodeId = ref<string>()
|
||||||
|
|
|
@ -23,7 +23,7 @@
|
||||||
class="rounded-[0.5em] inline-block m-1.5 align-middle overflow-hidden"
|
class="rounded-[0.5em] inline-block m-1.5 align-middle overflow-hidden"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
:href="`/#/genres/${encodeURIComponent(genre.name)}`"
|
:href="url('genres.show', { name: encodeURIComponent(genre.name) })"
|
||||||
:title="`${genre.name}: ${pluralize(genre.song_count, 'song')}`"
|
:title="`${genre.name}: ${pluralize(genre.song_count, 'song')}`"
|
||||||
class="bg-white/15 inline-flex items-center justify-center !text-k-text-secondary
|
class="bg-white/15 inline-flex items-center justify-center !text-k-text-secondary
|
||||||
transition-colors duration-200 ease-in-out hover:!text-k-text-primary hover:bg-k-highlight"
|
transition-colors duration-200 ease-in-out hover:!text-k-text-primary hover:bg-k-highlight"
|
||||||
|
@ -53,6 +53,7 @@ import { genreStore } from '@/stores/genreStore'
|
||||||
import { pluralize } from '@/utils/formatters'
|
import { pluralize } from '@/utils/formatters'
|
||||||
import { useAuthorization } from '@/composables/useAuthorization'
|
import { useAuthorization } from '@/composables/useAuthorization'
|
||||||
import { useErrorHandler } from '@/composables/useErrorHandler'
|
import { useErrorHandler } from '@/composables/useErrorHandler'
|
||||||
|
import { useRouter } from '@/composables/useRouter'
|
||||||
|
|
||||||
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
||||||
import GenreItemSkeleton from '@/components/ui/skeletons/GenreItemSkeleton.vue'
|
import GenreItemSkeleton from '@/components/ui/skeletons/GenreItemSkeleton.vue'
|
||||||
|
@ -61,6 +62,7 @@ import ScreenBase from '@/components/screens/ScreenBase.vue'
|
||||||
|
|
||||||
const { isAdmin } = useAuthorization()
|
const { isAdmin } = useAuthorization()
|
||||||
const { handleHttpError } = useErrorHandler()
|
const { handleHttpError } = useErrorHandler()
|
||||||
|
const { url } = useRouter()
|
||||||
|
|
||||||
const genres = ref<Genre[]>()
|
const genres = ref<Genre[]>()
|
||||||
|
|
||||||
|
|
|
@ -83,7 +83,7 @@ const {
|
||||||
|
|
||||||
const { SongListControls, config } = useSongListControls('Genre')
|
const { SongListControls, config } = useSongListControls('Genre')
|
||||||
|
|
||||||
const { getRouteParam, go, onRouteChanged } = useRouter()
|
const { getRouteParam, go, onRouteChanged, url } = useRouter()
|
||||||
|
|
||||||
let sortField: MaybeArray<PlayableListSortField> = 'title'
|
let sortField: MaybeArray<PlayableListSortField> = 'title'
|
||||||
let sortOrder: SortOrder = 'asc'
|
let sortOrder: SortOrder = 'asc'
|
||||||
|
@ -164,7 +164,7 @@ const playAll = async () => {
|
||||||
playbackService.queueAndPlay(await songStore.fetchRandomForGenre(genre.value!, randomSongCount))
|
playbackService.queueAndPlay(await songStore.fetchRandomForGenre(genre.value!, randomSongCount))
|
||||||
}
|
}
|
||||||
|
|
||||||
go('queue')
|
go(url('queue'))
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => (name.value = getNameFromRoute()))
|
onMounted(() => (name.value = getNameFromRoute()))
|
||||||
|
|
|
@ -71,7 +71,7 @@ import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
|
||||||
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
|
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
|
||||||
import ScreenBase from '@/components/screens/ScreenBase.vue'
|
import ScreenBase from '@/components/screens/ScreenBase.vue'
|
||||||
|
|
||||||
const { go, onScreenActivated } = useRouter()
|
const { go, onScreenActivated, url } = useRouter()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
SongList,
|
SongList,
|
||||||
|
@ -97,7 +97,7 @@ const libraryNotEmpty = computed(() => commonStore.state.song_count > 0)
|
||||||
|
|
||||||
const playAll = async (shuffle = true) => {
|
const playAll = async (shuffle = true) => {
|
||||||
playbackService.queueAndPlay(songs.value, shuffle)
|
playbackService.queueAndPlay(songs.value, shuffle)
|
||||||
go('queue')
|
go(url('queue'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const shuffleSome = async () => {
|
const shuffleSome = async () => {
|
||||||
|
|
|
@ -22,7 +22,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(updateMock).toHaveBeenCalledWith({ media_path: '/media' })
|
expect(updateMock).toHaveBeenCalledWith({ media_path: '/media' })
|
||||||
expect(goMock).toHaveBeenCalledWith('home')
|
expect(goMock).toHaveBeenCalledWith('/#/home', true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
<ScreenHeader>Settings</ScreenHeader>
|
<ScreenHeader>Settings</ScreenHeader>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<p v-if="storageDriver !== 'local'" class="textk-text-secondary">
|
<p v-if="storageDriver !== 'local'" class="text-k-text-secondary">
|
||||||
Since you’re not using a cloud storage, there’s no need to set a media path.
|
Since you’re not using a cloud storage, there’s no need to set a media path.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
@ -53,11 +53,10 @@ import Btn from '@/components/ui/form/Btn.vue'
|
||||||
import TextInput from '@/components/ui/form/TextInput.vue'
|
import TextInput from '@/components/ui/form/TextInput.vue'
|
||||||
import ScreenBase from '@/components/screens/ScreenBase.vue'
|
import ScreenBase from '@/components/screens/ScreenBase.vue'
|
||||||
import FormRow from '@/components/ui/form/FormRow.vue'
|
import FormRow from '@/components/ui/form/FormRow.vue'
|
||||||
import { forceReloadWindow } from '@/utils/helpers'
|
|
||||||
|
|
||||||
const { toastSuccess } = useMessageToaster()
|
const { toastSuccess } = useMessageToaster()
|
||||||
const { showConfirmDialog } = useDialogBox()
|
const { showConfirmDialog } = useDialogBox()
|
||||||
const { go } = useRouter()
|
const { go, url } = useRouter()
|
||||||
const { showOverlay, hideOverlay } = useOverlay()
|
const { showOverlay, hideOverlay } = useOverlay()
|
||||||
|
|
||||||
const storageDriver = ref(commonStore.state.storage_driver)
|
const storageDriver = ref(commonStore.state.storage_driver)
|
||||||
|
@ -84,8 +83,7 @@ const save = async () => {
|
||||||
await settingStore.update({ media_path: mediaPath.value })
|
await settingStore.update({ media_path: mediaPath.value })
|
||||||
toastSuccess('Settings saved.')
|
toastSuccess('Settings saved.')
|
||||||
// Make sure we're back to home first.
|
// Make sure we're back to home first.
|
||||||
go('home')
|
go(url('home'), true)
|
||||||
forceReloadWindow()
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
useErrorHandler('dialog').handleHttpError(error)
|
useErrorHandler('dialog').handleHttpError(error)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
@ -25,7 +25,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
await this.user.click(screen.getByRole('button', { name: 'View All' }))
|
await this.user.click(screen.getByRole('button', { name: 'View All' }))
|
||||||
|
|
||||||
expect(mock).toHaveBeenCalledWith('recently-played')
|
expect(mock).toHaveBeenCalledWith('/#/recently-played')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,8 +9,8 @@ import { useRouter } from '@/composables/useRouter'
|
||||||
|
|
||||||
import Btn from '@/components/ui/form/Btn.vue'
|
import Btn from '@/components/ui/form/Btn.vue'
|
||||||
|
|
||||||
const { go } = useRouter()
|
const { go, url } = useRouter()
|
||||||
const goToRecentlyPlayedScreen = () => go('recently-played')
|
const goToRecentlyPlayedScreen = () => go(url('recently-played'))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="postcss" scoped>
|
<style lang="postcss" scoped>
|
||||||
|
|
|
@ -59,9 +59,9 @@ const headingText = computed(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const { playables, query, searching } = toRefs(props)
|
const { playables, query, searching } = toRefs(props)
|
||||||
const { go } = useRouter()
|
const { go, url } = useRouter()
|
||||||
|
|
||||||
const goToSongResults = () => go(`search/songs/?q=${query.value}`)
|
const goToSongResults = () => go(`${url('search.songs')}/?q=${query.value}`)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="postcss" scoped>
|
<style lang="postcss" scoped>
|
||||||
|
|
|
@ -57,7 +57,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
await this.user.click(screen.getByText('Go to Album'))
|
await this.user.click(screen.getByText('Go to Album'))
|
||||||
|
|
||||||
expect(goMock).toHaveBeenCalledWith(`album/${song.album_id}`)
|
expect(goMock).toHaveBeenCalledWith(`/#/albums/${song.album_id}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('goes to artist details screen', async () => {
|
it('goes to artist details screen', async () => {
|
||||||
|
@ -67,7 +67,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
await this.user.click(screen.getByText('Go to Artist'))
|
await this.user.click(screen.getByText('Go to Artist'))
|
||||||
|
|
||||||
expect(goMock).toHaveBeenCalledWith(`artist/${song.artist_id}`)
|
expect(goMock).toHaveBeenCalledWith(`/#/artists/${song.artist_id}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('downloads', async () => {
|
it('downloads', async () => {
|
||||||
|
|
|
@ -94,7 +94,7 @@ import { useKoelPlus } from '@/composables/useKoelPlus'
|
||||||
|
|
||||||
const { toastSuccess, toastError, toastWarning } = useMessageToaster()
|
const { toastSuccess, toastError, toastWarning } = useMessageToaster()
|
||||||
const { showConfirmDialog } = useDialogBox()
|
const { showConfirmDialog } = useDialogBox()
|
||||||
const { go, getRouteParam, isCurrentScreen } = useRouter()
|
const { go, getRouteParam, isCurrentScreen, url } = useRouter()
|
||||||
const { base, ContextMenu, open, close, trigger } = useContextMenu()
|
const { base, ContextMenu, open, close, trigger } = useContextMenu()
|
||||||
const { removeFromPlaylist } = usePlaylistManagement()
|
const { removeFromPlaylist } = usePlaylistManagement()
|
||||||
const { isPlus } = useKoelPlus()
|
const { isPlus } = useKoelPlus()
|
||||||
|
@ -227,10 +227,10 @@ const openEditForm = () => trigger(() =>
|
||||||
&& eventBus.emit('MODAL_SHOW_EDIT_SONG_FORM', playables.value as Song[]),
|
&& eventBus.emit('MODAL_SHOW_EDIT_SONG_FORM', playables.value as Song[]),
|
||||||
)
|
)
|
||||||
|
|
||||||
const viewAlbum = (song: Song) => trigger(() => go(`album/${song.album_id}`))
|
const viewAlbum = (song: Song) => trigger(() => go(url('albums.show', { id: song.album_id })))
|
||||||
const viewArtist = (song: Song) => trigger(() => go(`artist/${song.artist_id}`))
|
const viewArtist = (song: Song) => trigger(() => go(url('artists.show', { id: song.artist_id })))
|
||||||
const viewPodcast = (episode: Episode) => trigger(() => go(`podcasts/${episode.podcast_id}`))
|
const viewPodcast = (episode: Episode) => trigger(() => go(url('podcasts.show', { id: episode.podcast_id })))
|
||||||
const viewEpisode = (episode: Episode) => trigger(() => go(`episodes/${episode.id}`))
|
const viewEpisode = (episode: Episode) => trigger(() => go(url('episodes.show', { id: episode.id })))
|
||||||
const download = () => trigger(() => downloadService.fromPlayables(playables.value))
|
const download = () => trigger(() => downloadService.fromPlayables(playables.value))
|
||||||
|
|
||||||
const removePlayablesFromPlaylist = () => trigger(async () => {
|
const removePlayablesFromPlaylist = () => trigger(async () => {
|
||||||
|
|
|
@ -24,14 +24,14 @@
|
||||||
<p class="text-k-text-secondary text-[0.9rem] opacity-80 overflow-hidden">
|
<p class="text-k-text-secondary text-[0.9rem] opacity-80 overflow-hidden">
|
||||||
<a
|
<a
|
||||||
v-if="isSong(playable)"
|
v-if="isSong(playable)"
|
||||||
:href="`#/artist/${playable.artist_id}`"
|
:href="url('artists.show', { id: playable.artist_id })"
|
||||||
class="!text-k-text-primary hover:!text-k-accent"
|
class="!text-k-text-primary hover:!text-k-accent"
|
||||||
>
|
>
|
||||||
{{ playable.artist_name }}
|
{{ playable.artist_name }}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
v-if="isEpisode(playable)"
|
v-if="isEpisode(playable)"
|
||||||
:href="`#/podcasts/${playable.podcast_id}`"
|
:href="url('podcasts.show', { id: playable.podcast_id })"
|
||||||
class="!text-k-text-primary hover:!text-k-accent"
|
class="!text-k-text-primary hover:!text-k-accent"
|
||||||
>
|
>
|
||||||
{{ playable.podcast_title }}
|
{{ playable.podcast_title }}
|
||||||
|
@ -53,6 +53,7 @@ import { playbackService } from '@/services/playbackService'
|
||||||
import { useAuthorization } from '@/composables/useAuthorization'
|
import { useAuthorization } from '@/composables/useAuthorization'
|
||||||
import { useDraggable } from '@/composables/useDragAndDrop'
|
import { useDraggable } from '@/composables/useDragAndDrop'
|
||||||
import { useKoelPlus } from '@/composables/useKoelPlus'
|
import { useKoelPlus } from '@/composables/useKoelPlus'
|
||||||
|
import { useRouter } from '@/composables/useRouter'
|
||||||
|
|
||||||
import SongThumbnail from '@/components/song/SongThumbnail.vue'
|
import SongThumbnail from '@/components/song/SongThumbnail.vue'
|
||||||
import LikeButton from '@/components/song/SongLikeButton.vue'
|
import LikeButton from '@/components/song/SongLikeButton.vue'
|
||||||
|
@ -64,6 +65,7 @@ const { playable } = toRefs(props)
|
||||||
const { isPlus } = useKoelPlus()
|
const { isPlus } = useKoelPlus()
|
||||||
const { currentUser } = useAuthorization()
|
const { currentUser } = useAuthorization()
|
||||||
const { startDragging } = useDraggable('playables')
|
const { startDragging } = useDraggable('playables')
|
||||||
|
const { url } = useRouter()
|
||||||
|
|
||||||
const external = computed(() => {
|
const external = computed(() => {
|
||||||
if (!isSong(playable.value)) {
|
if (!isSong(playable.value)) {
|
||||||
|
|
|
@ -46,7 +46,7 @@ new class extends UnitTestCase {
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchMock).toHaveBeenCalledWith(id)
|
expect(fetchMock).toHaveBeenCalledWith(id)
|
||||||
expect(playMock).toHaveBeenCalledWith(songs)
|
expect(playMock).toHaveBeenCalledWith(songs)
|
||||||
expect(goMock).toHaveBeenCalledWith('queue')
|
expect(goMock).toHaveBeenCalledWith('/#/queue')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -75,7 +75,7 @@ new class extends UnitTestCase {
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchMock).toHaveBeenCalled()
|
expect(fetchMock).toHaveBeenCalled()
|
||||||
expect(playMock).toHaveBeenCalledWith(songs)
|
expect(playMock).toHaveBeenCalledWith(songs)
|
||||||
expect(goMock).toHaveBeenCalledWith('queue')
|
expect(goMock).toHaveBeenCalledWith('/#/queue')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -97,7 +97,7 @@ new class extends UnitTestCase {
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchMock).toHaveBeenCalled()
|
expect(fetchMock).toHaveBeenCalled()
|
||||||
expect(playMock).toHaveBeenCalledWith(songs)
|
expect(playMock).toHaveBeenCalledWith(songs)
|
||||||
expect(goMock).toHaveBeenCalledWith('queue')
|
expect(goMock).toHaveBeenCalledWith('/#/queue')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -25,7 +25,7 @@ import { CurrentPlayableKey } from '@/symbols'
|
||||||
|
|
||||||
import FooterButton from '@/components/layout/app-footer/FooterButton.vue'
|
import FooterButton from '@/components/layout/app-footer/FooterButton.vue'
|
||||||
|
|
||||||
const { getCurrentScreen, getRouteParam, go } = useRouter()
|
const { getCurrentScreen, getRouteParam, go, url } = useRouter()
|
||||||
const song = requireInjection(CurrentPlayableKey, ref())
|
const song = requireInjection(CurrentPlayableKey, ref())
|
||||||
|
|
||||||
const libraryEmpty = computed(() => commonStore.state.song_count === 0)
|
const libraryEmpty = computed(() => commonStore.state.song_count === 0)
|
||||||
|
@ -60,7 +60,7 @@ const initiatePlayback = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
await playbackService.queueAndPlay(playables)
|
await playbackService.queueAndPlay(playables)
|
||||||
go('queue')
|
go(url('queue'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggle = async () => song.value ? playbackService.toggle() : initiatePlayback()
|
const toggle = async () => song.value ? playbackService.toggle() : initiatePlayback()
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
v-koel-tooltip.left
|
v-koel-tooltip.left
|
||||||
class="view-profile rounded-full"
|
class="view-profile rounded-full"
|
||||||
data-testid="view-profile-link"
|
data-testid="view-profile-link"
|
||||||
href="/#/profile"
|
:href="url('profile')"
|
||||||
title="Profile and preferences"
|
title="Profile and preferences"
|
||||||
>
|
>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
|
@ -17,8 +17,10 @@
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useAuthorization } from '@/composables/useAuthorization'
|
import { useAuthorization } from '@/composables/useAuthorization'
|
||||||
|
import { useRouter } from '@/composables/useRouter'
|
||||||
|
|
||||||
import UserAvatar from '@/components/user/UserAvatar.vue'
|
import UserAvatar from '@/components/user/UserAvatar.vue'
|
||||||
|
|
||||||
|
const { url } = useRouter()
|
||||||
const { currentUser } = useAuthorization()
|
const { currentUser } = useAuthorization()
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -21,7 +21,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
await this.user.click(screen.getByRole('searchbox'))
|
await this.user.click(screen.getByRole('searchbox'))
|
||||||
|
|
||||||
expect(mock).toHaveBeenCalledWith('search')
|
expect(mock).toHaveBeenCalledWith('/#/search')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('emits an event when search query is changed', async () => {
|
it('emits an event when search query is changed', async () => {
|
||||||
|
@ -40,7 +40,7 @@ new class extends UnitTestCase {
|
||||||
await this.type(screen.getByRole('searchbox'), 'hey')
|
await this.type(screen.getByRole('searchbox'), 'hey')
|
||||||
await this.user.click(screen.getByRole('button', { name: 'Search' }))
|
await this.user.click(screen.getByRole('button', { name: 'Search' }))
|
||||||
|
|
||||||
expect(goMock).toHaveBeenCalledWith('search')
|
expect(goMock).toHaveBeenCalledWith('/#/search')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -45,7 +45,7 @@ import TextInput from '@/components/ui/form/TextInput.vue'
|
||||||
|
|
||||||
const placeholder = isMobile.any ? 'Search' : 'Press F to search'
|
const placeholder = isMobile.any ? 'Search' : 'Press F to search'
|
||||||
|
|
||||||
const { go } = useRouter()
|
const { go, url } = useRouter()
|
||||||
|
|
||||||
const input = ref<InstanceType<typeof TextInput>>()
|
const input = ref<InstanceType<typeof TextInput>>()
|
||||||
const q = ref('')
|
const q = ref('')
|
||||||
|
@ -61,10 +61,10 @@ if (process.env.NODE_ENV !== 'test') {
|
||||||
|
|
||||||
const onSubmit = () => {
|
const onSubmit = () => {
|
||||||
eventBus.emit('TOGGLE_SIDEBAR')
|
eventBus.emit('TOGGLE_SIDEBAR')
|
||||||
go('search')
|
go(url('search'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const maybeGoToSearchScreen = () => isMobile.any || go('search')
|
const maybeGoToSearchScreen = () => isMobile.any || go(url('search'))
|
||||||
|
|
||||||
eventBus.on('FOCUS_SEARCH_FIELD', () => {
|
eventBus.on('FOCUS_SEARCH_FIELD', () => {
|
||||||
input.value?.el?.focus()
|
input.value?.el?.focus()
|
||||||
|
|
|
@ -47,7 +47,7 @@ import { acceptedImageTypes } from '@/config/acceptedImageTypes'
|
||||||
|
|
||||||
const props = defineProps<{ entity: Album | Artist }>()
|
const props = defineProps<{ entity: Album | Artist }>()
|
||||||
const { toastSuccess } = useMessageToaster()
|
const { toastSuccess } = useMessageToaster()
|
||||||
const { go } = useRouter()
|
const { go, url } = useRouter()
|
||||||
const { currentUserCan } = usePolicies()
|
const { currentUserCan } = usePolicies()
|
||||||
|
|
||||||
const { entity } = toRefs(props)
|
const { entity } = toRefs(props)
|
||||||
|
@ -82,7 +82,7 @@ const playOrQueue = async (event: MouseEvent) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
playbackService.queueAndPlay(songs)
|
playbackService.queueAndPlay(songs)
|
||||||
go('queue')
|
go(url('queue'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const onDragEnter = () => (droppable.value = allowsUpload)
|
const onDragEnter = () => (droppable.value = allowsUpload)
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<a
|
<a
|
||||||
:href="url"
|
:href="href"
|
||||||
class="flex gap-3 !text-k-text-secondary hover:!text-k-text-primary focus:!text-k-text-primary active:!text-k-text-primary"
|
class="flex gap-3 !text-k-text-secondary hover:!text-k-text-primary focus:!text-k-text-primary active:!text-k-text-primary"
|
||||||
data-testid="youtube-search-result"
|
data-testid="youtube-search-result"
|
||||||
role="button"
|
role="button"
|
||||||
|
@ -16,21 +16,20 @@
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { unescape } from 'lodash'
|
import { unescape } from 'lodash'
|
||||||
import { computed, toRefs } from 'vue'
|
import { toRefs } from 'vue'
|
||||||
import { youTubeService } from '@/services/youTubeService'
|
import { youTubeService } from '@/services/youTubeService'
|
||||||
import { useRouter } from '@/composables/useRouter'
|
import { useRouter } from '@/composables/useRouter'
|
||||||
|
|
||||||
const props = defineProps<{ video: YouTubeVideo }>()
|
const props = defineProps<{ video: YouTubeVideo }>()
|
||||||
|
|
||||||
const { go } = useRouter()
|
|
||||||
|
|
||||||
const { video } = toRefs(props)
|
const { video } = toRefs(props)
|
||||||
|
|
||||||
const url = computed(() => `https://youtu.be/${video.value.id.videoId}`)
|
const { go, url } = useRouter()
|
||||||
|
|
||||||
|
const href = `https://youtu.be/${video.value.id.videoId}`
|
||||||
|
|
||||||
const play = () => {
|
const play = () => {
|
||||||
youTubeService.play(video.value)
|
youTubeService.play(video.value)
|
||||||
go('youtube')
|
go(url('youtube'))
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -36,7 +36,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
await this.user.click(screen.getByRole('button', { name: 'Your Profile' }))
|
await this.user.click(screen.getByRole('button', { name: 'Your Profile' }))
|
||||||
|
|
||||||
expect(mock).toHaveBeenCalledWith('profile')
|
expect(mock).toHaveBeenCalledWith('/#/profile')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('deletes user if confirmed', async () => {
|
it('deletes user if confirmed', async () => {
|
||||||
|
|
|
@ -65,13 +65,13 @@ const { user } = toRefs(props)
|
||||||
|
|
||||||
const { toastSuccess } = useMessageToaster()
|
const { toastSuccess } = useMessageToaster()
|
||||||
const { showConfirmDialog } = useDialogBox()
|
const { showConfirmDialog } = useDialogBox()
|
||||||
const { go } = useRouter()
|
const { go, url } = useRouter()
|
||||||
|
|
||||||
const { currentUser } = useAuthorization()
|
const { currentUser } = useAuthorization()
|
||||||
|
|
||||||
const isCurrentUser = computed(() => user.value.id === currentUser.value.id)
|
const isCurrentUser = computed(() => user.value.id === currentUser.value.id)
|
||||||
|
|
||||||
const edit = () => isCurrentUser.value ? go('profile') : eventBus.emit('MODAL_SHOW_EDIT_USER_FORM', user.value)
|
const edit = () => isCurrentUser.value ? go(url('profile')) : eventBus.emit('MODAL_SHOW_EDIT_USER_FORM', user.value)
|
||||||
|
|
||||||
const destroy = async () => {
|
const destroy = async () => {
|
||||||
if (!await showConfirmDialog(`Unperson ${user.value.name}?`)) {
|
if (!await showConfirmDialog(`Unperson ${user.value.name}?`)) {
|
||||||
|
|
|
@ -16,24 +16,26 @@ import { forceReloadWindow } from '@/utils/helpers'
|
||||||
let toastSuccess: ReturnType<typeof useMessageToaster>['toastSuccess']
|
let toastSuccess: ReturnType<typeof useMessageToaster>['toastSuccess']
|
||||||
let showConfirmDialog: ReturnType<typeof useDialogBox>['showConfirmDialog']
|
let showConfirmDialog: ReturnType<typeof useDialogBox>['showConfirmDialog']
|
||||||
let go: ReturnType<typeof useRouter>['go']
|
let go: ReturnType<typeof useRouter>['go']
|
||||||
|
let url: ReturnType<typeof useRouter>['url']
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
toastSuccess = useMessageToaster().toastSuccess
|
toastSuccess = useMessageToaster().toastSuccess
|
||||||
showConfirmDialog = useDialogBox().showConfirmDialog
|
showConfirmDialog = useDialogBox().showConfirmDialog
|
||||||
go = useRouter().go
|
go = useRouter().go
|
||||||
|
url = useRouter().url
|
||||||
})
|
})
|
||||||
|
|
||||||
eventBus.on('PLAYLIST_DELETE', async playlist => {
|
eventBus.on('PLAYLIST_DELETE', async playlist => {
|
||||||
if (await showConfirmDialog(`Delete the playlist "${playlist.name}"?`)) {
|
if (await showConfirmDialog(`Delete the playlist "${playlist.name}"?`)) {
|
||||||
await playlistStore.delete(playlist)
|
await playlistStore.delete(playlist)
|
||||||
toastSuccess(`Playlist "${playlist.name}" deleted.`)
|
toastSuccess(`Playlist "${playlist.name}" deleted.`)
|
||||||
go('home')
|
go(url('home'))
|
||||||
}
|
}
|
||||||
}).on('PLAYLIST_FOLDER_DELETE', async folder => {
|
}).on('PLAYLIST_FOLDER_DELETE', async folder => {
|
||||||
if (await showConfirmDialog(`Delete the playlist folder "${folder.name}"?`)) {
|
if (await showConfirmDialog(`Delete the playlist folder "${folder.name}"?`)) {
|
||||||
await playlistFolderStore.delete(folder)
|
await playlistFolderStore.delete(folder)
|
||||||
toastSuccess(`Playlist folder "${folder.name}" deleted.`)
|
toastSuccess(`Playlist folder "${folder.name}" deleted.`)
|
||||||
go('home')
|
go(url('home'))
|
||||||
}
|
}
|
||||||
}).on('LOG_OUT', async () => {
|
}).on('LOG_OUT', async () => {
|
||||||
await authService.logout()
|
await authService.logout()
|
||||||
|
|
|
@ -13,7 +13,7 @@ import { favoriteStore } from '@/stores/favoriteStore'
|
||||||
import { queueStore } from '@/stores/queueStore'
|
import { queueStore } from '@/stores/queueStore'
|
||||||
import { useRouter } from '@/composables/useRouter'
|
import { useRouter } from '@/composables/useRouter'
|
||||||
|
|
||||||
const { isCurrentScreen, go } = useRouter()
|
const { isCurrentScreen, go, url } = useRouter()
|
||||||
|
|
||||||
const onKeyStroke = (key: KeyFilter, callback: (e: KeyboardEvent) => void) => {
|
const onKeyStroke = (key: KeyFilter, callback: (e: KeyboardEvent) => void) => {
|
||||||
baseOnKeyStroke(key, e => {
|
baseOnKeyStroke(key, e => {
|
||||||
|
@ -43,8 +43,8 @@ onKeyStroke('j', () => playbackService.playNext())
|
||||||
onKeyStroke('k', () => playbackService.playPrev())
|
onKeyStroke('k', () => playbackService.playPrev())
|
||||||
onKeyStroke(' ', () => playbackService.toggle())
|
onKeyStroke(' ', () => playbackService.toggle())
|
||||||
onKeyStroke('r', () => playbackService.rotateRepeatMode())
|
onKeyStroke('r', () => playbackService.rotateRepeatMode())
|
||||||
onKeyStroke('q', () => go(isCurrentScreen('Queue') ? -1 : 'queue'))
|
onKeyStroke('q', () => go(isCurrentScreen('Queue') ? -1 : url('queue')))
|
||||||
onKeyStroke('h', () => go('home'))
|
onKeyStroke('h', () => go(url('home')))
|
||||||
|
|
||||||
onKeyStroke('ArrowRight', () => playbackService.seekBy(10))
|
onKeyStroke('ArrowRight', () => playbackService.seekBy(10))
|
||||||
onKeyStroke('ArrowLeft', () => playbackService.seekBy(-10))
|
onKeyStroke('ArrowLeft', () => playbackService.seekBy(-10))
|
||||||
|
|
|
@ -25,5 +25,6 @@ export const useRouter = () => {
|
||||||
onRouteChanged: router.onRouteChanged.bind(router),
|
onRouteChanged: router.onRouteChanged.bind(router),
|
||||||
resolveRoute: router.resolve.bind(router),
|
resolveRoute: router.resolve.bind(router),
|
||||||
triggerNotFound: router.triggerNotFound.bind(router),
|
triggerNotFound: router.triggerNotFound.bind(router),
|
||||||
|
url: Router.url,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,91 +10,111 @@ const UUID_REGEX = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}
|
||||||
|
|
||||||
export const routes: Route[] = [
|
export const routes: Route[] = [
|
||||||
{
|
{
|
||||||
|
name: 'home',
|
||||||
path: '/home',
|
path: '/home',
|
||||||
screen: 'Home',
|
screen: 'Home',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
name: '404',
|
||||||
path: '/404',
|
path: '/404',
|
||||||
screen: '404',
|
screen: '404',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
name: 'queue',
|
||||||
path: '/queue',
|
path: '/queue',
|
||||||
screen: 'Queue',
|
screen: 'Queue',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
name: 'songs.index',
|
||||||
path: '/songs',
|
path: '/songs',
|
||||||
screen: 'Songs',
|
screen: 'Songs',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
name: 'albums.index',
|
||||||
path: '/albums',
|
path: '/albums',
|
||||||
screen: 'Albums',
|
screen: 'Albums',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
name: 'artists.index',
|
||||||
path: '/artists',
|
path: '/artists',
|
||||||
screen: 'Artists',
|
screen: 'Artists',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
name: 'favorites',
|
||||||
path: '/favorites',
|
path: '/favorites',
|
||||||
screen: 'Favorites',
|
screen: 'Favorites',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
name: 'recently-played',
|
||||||
path: '/recently-played',
|
path: '/recently-played',
|
||||||
screen: 'RecentlyPlayed',
|
screen: 'RecentlyPlayed',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
name: 'search',
|
||||||
path: '/search',
|
path: '/search',
|
||||||
screen: 'Search.Excerpt',
|
screen: 'Search.Excerpt',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
name: 'search.songs',
|
||||||
path: '/search/songs',
|
path: '/search/songs',
|
||||||
screen: 'Search.Songs',
|
screen: 'Search.Songs',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
name: 'upload',
|
||||||
path: '/upload',
|
path: '/upload',
|
||||||
screen: 'Upload',
|
screen: 'Upload',
|
||||||
onResolve: () => useUpload().allowsUpload.value,
|
onResolve: () => useUpload().allowsUpload.value,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
name: 'settings',
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
screen: 'Settings',
|
screen: 'Settings',
|
||||||
onResolve: () => userStore.current?.is_admin,
|
onResolve: () => userStore.current?.is_admin,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
name: 'users.index',
|
||||||
path: '/users',
|
path: '/users',
|
||||||
screen: 'Users',
|
screen: 'Users',
|
||||||
onResolve: () => userStore.current?.is_admin,
|
onResolve: () => userStore.current?.is_admin,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
name: 'youtube',
|
||||||
path: '/youtube',
|
path: '/youtube',
|
||||||
screen: 'YouTube',
|
screen: 'YouTube',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
name: 'profile',
|
||||||
path: '/profile',
|
path: '/profile',
|
||||||
screen: 'Profile',
|
screen: 'Profile',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
name: 'visualizer',
|
||||||
path: 'visualizer',
|
path: 'visualizer',
|
||||||
screen: 'Visualizer',
|
screen: 'Visualizer',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/album/(?<id>\\d+)',
|
name: 'albums.show',
|
||||||
|
path: '/albums/(?<id>\\d+)',
|
||||||
screen: 'Album',
|
screen: 'Album',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/artist/(?<id>\\d+)',
|
name: 'artists.show',
|
||||||
|
path: '/artists/(?<id>\\d+)',
|
||||||
screen: 'Artist',
|
screen: 'Artist',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: `/playlist/(?<id>${UUID_REGEX})`,
|
name: 'playlists.show',
|
||||||
|
path: `/playlists/(?<id>${UUID_REGEX})`,
|
||||||
screen: 'Playlist',
|
screen: 'Playlist',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
name: 'playlist.collaborate',
|
||||||
path: `/playlist/collaborate/(?<id>${UUID_REGEX})`,
|
path: `/playlist/collaborate/(?<id>${UUID_REGEX})`,
|
||||||
screen: 'Blank',
|
screen: 'Blank',
|
||||||
onResolve: async params => {
|
onResolve: async params => {
|
||||||
try {
|
try {
|
||||||
const playlist = await playlistCollaborationService.acceptInvite(params.id)
|
const playlist = await playlistCollaborationService.acceptInvite(params.id)
|
||||||
Router.go(`/playlist/${playlist.id}`, true)
|
Router.go(Router.url('playlists.show', { id: playlist.id }), true)
|
||||||
return true
|
return true
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.error(error)
|
logger.error(error)
|
||||||
|
@ -103,31 +123,38 @@ export const routes: Route[] = [
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
name: 'genres.index',
|
||||||
path: '/genres',
|
path: '/genres',
|
||||||
screen: 'Genres',
|
screen: 'Genres',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
name: 'genres.show',
|
||||||
path: '/genres/(?<name>\.+)',
|
path: '/genres/(?<name>\.+)',
|
||||||
screen: 'Genre',
|
screen: 'Genre',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
name: 'podcasts.index',
|
||||||
path: '/podcasts',
|
path: '/podcasts',
|
||||||
screen: 'Podcasts',
|
screen: 'Podcasts',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
name: 'podcasts.show',
|
||||||
path: `/podcasts/(?<id>${UUID_REGEX})`,
|
path: `/podcasts/(?<id>${UUID_REGEX})`,
|
||||||
screen: 'Podcast',
|
screen: 'Podcast',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
name: 'episodes.show',
|
||||||
path: '/episodes/(?<id>\.+)',
|
path: '/episodes/(?<id>\.+)',
|
||||||
screen: 'Episode',
|
screen: 'Episode',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
name: 'visualizer',
|
||||||
path: '/visualizer',
|
path: '/visualizer',
|
||||||
screen: 'Visualizer',
|
screen: 'Visualizer',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: `/song/(?<id>${UUID_REGEX})`,
|
name: 'songs.queue',
|
||||||
|
path: `/songs/(?<id>${UUID_REGEX})`,
|
||||||
screen: 'Queue',
|
screen: 'Queue',
|
||||||
redirect: () => 'queue',
|
redirect: () => 'queue',
|
||||||
onResolve: params => {
|
onResolve: params => {
|
||||||
|
@ -136,10 +163,12 @@ export const routes: Route[] = [
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
name: 'invitation.accept',
|
||||||
path: `/invitation/accept/(?<token>${UUID_REGEX})`,
|
path: `/invitation/accept/(?<token>${UUID_REGEX})`,
|
||||||
screen: 'Invitation.Accept',
|
screen: 'Invitation.Accept',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
name: 'password.reset',
|
||||||
path: `/reset-password/(?<payload>[a-zA-Z0-9\\+/=]+)`,
|
path: `/reset-password/(?<payload>[a-zA-Z0-9\\+/=]+)`,
|
||||||
screen: 'Password.Reset',
|
screen: 'Password.Reset',
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
|
import { routes } from '@/config/routes'
|
||||||
import { forceReloadWindow } from '@/utils/helpers'
|
import { forceReloadWindow } from '@/utils/helpers'
|
||||||
|
|
||||||
type RouteParams = Record<string, string>
|
type RouteParams = Record<string, string>
|
||||||
|
@ -8,6 +8,7 @@ type ResolveHook = (params: RouteParams) => Promise<boolean | void> | boolean |
|
||||||
type RedirectHook = (params: RouteParams) => Route | string
|
type RedirectHook = (params: RouteParams) => Route | string
|
||||||
|
|
||||||
export interface Route {
|
export interface Route {
|
||||||
|
name?: string
|
||||||
path: string
|
path: string
|
||||||
screen: ScreenName
|
screen: ScreenName
|
||||||
params?: RouteParams
|
params?: RouteParams
|
||||||
|
@ -17,18 +18,15 @@ export interface Route {
|
||||||
|
|
||||||
type RouteChangedHandler = (newRoute: Route, oldRoute: Route | undefined) => any
|
type RouteChangedHandler = (newRoute: Route, oldRoute: Route | undefined) => any
|
||||||
|
|
||||||
// @TODO: Remove support for hashbang (#!) and only support hash (#)
|
|
||||||
export default class Router {
|
export default class Router {
|
||||||
public $currentRoute: Ref<Route>
|
public $currentRoute: Ref<Route>
|
||||||
|
|
||||||
private readonly routes: Route[]
|
|
||||||
private readonly homeRoute: Route
|
private readonly homeRoute: Route
|
||||||
private readonly notFoundRoute: Route
|
private readonly notFoundRoute: Route
|
||||||
private routeChangedHandlers: RouteChangedHandler[] = []
|
private routeChangedHandlers: RouteChangedHandler[] = []
|
||||||
private cache: Map<string, { route: Route, params: RouteParams }> = new Map()
|
private cache: Map<string, { route: Route, params: RouteParams }> = new Map()
|
||||||
|
|
||||||
constructor (routes: Route[]) {
|
constructor () {
|
||||||
this.routes = routes
|
|
||||||
this.homeRoute = routes.find(({ screen }) => screen === 'Home')!
|
this.homeRoute = routes.find(({ screen }) => screen === 'Home')!
|
||||||
this.notFoundRoute = routes.find(({ screen }) => screen === '404')!
|
this.notFoundRoute = routes.find(({ screen }) => screen === '404')!
|
||||||
this.$currentRoute = ref(this.homeRoute)
|
this.$currentRoute = ref(this.homeRoute)
|
||||||
|
@ -99,8 +97,8 @@ export default class Router {
|
||||||
|
|
||||||
private tryMatchRoute () {
|
private tryMatchRoute () {
|
||||||
if (!this.cache.has(location.hash)) {
|
if (!this.cache.has(location.hash)) {
|
||||||
for (let i = 0; i < this.routes.length; i++) {
|
for (let i = 0; i < routes.length; i++) {
|
||||||
const route = this.routes[i]
|
const route = routes[i]
|
||||||
const matches = location.hash.match(new RegExp(`^#!?${route.path}/?(?:\\?(.*))?$`))
|
const matches = location.hash.match(new RegExp(`^#!?${route.path}/?(?:\\?(.*))?$`))
|
||||||
|
|
||||||
if (matches) {
|
if (matches) {
|
||||||
|
@ -118,4 +116,29 @@ export default class Router {
|
||||||
|
|
||||||
return this.cache.get(location.hash)
|
return this.cache.get(location.hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static url (name: string, params: object = {}) {
|
||||||
|
const route = routes.find(route => route.name === name)
|
||||||
|
|
||||||
|
if (!route) {
|
||||||
|
throw new Error(`Route ${name} not found`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = route.path
|
||||||
|
|
||||||
|
// replace the params in the path with the actual values
|
||||||
|
Object.keys(params).forEach(key => {
|
||||||
|
path = path.replace(new RegExp(`\\(\\?<${key}>.*?\\)`), params[key])
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!path.startsWith('/')) {
|
||||||
|
path = `/${path}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!path.startsWith('/#')) {
|
||||||
|
path = `/#${path}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -167,7 +167,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
it('gets shareable URL', () => {
|
it('gets shareable URL', () => {
|
||||||
const song = factory('song', { id: 'foo' })
|
const song = factory('song', { id: 'foo' })
|
||||||
expect(songStore.getShareableUrl(song)).toBe('http://test/#/song/foo')
|
expect(songStore.getShareableUrl(song)).toBe('http://test/#/songs/foo')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('syncs with the vault', () => {
|
it('syncs with the vault', () => {
|
||||||
|
|
|
@ -161,7 +161,7 @@ export const songStore = {
|
||||||
: `${commonStore.state.cdn_url}play/${playable.id}?t=${authService.getAudioToken()}`
|
: `${commonStore.state.cdn_url}play/${playable.id}?t=${authService.getAudioToken()}`
|
||||||
},
|
},
|
||||||
|
|
||||||
getShareableUrl: (song: Playable) => `${window.BASE_URL}#/song/${song.id}`,
|
getShareableUrl: (song: Playable) => `${window.BASE_URL}#/songs/${song.id}`,
|
||||||
|
|
||||||
syncWithVault (playables: MaybeArray<Playable>) {
|
syncWithVault (playables: MaybeArray<Playable>) {
|
||||||
return arrayify(playables).map(playable => {
|
return arrayify(playables).map(playable => {
|
||||||
|
|
Loading…
Reference in a new issue