feat: add and use a url helper for router (#1863)

This commit is contained in:
Phan An 2024-10-26 00:56:43 +07:00 committed by GitHub
parent b5a42d485b
commit 86e4b65ec7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
64 changed files with 253 additions and 157 deletions

View file

@ -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))
} }

View file

@ -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)

View file

@ -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)

View file

@ -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}`)
}) })
} }

View file

@ -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) => {

View file

@ -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>

View file

@ -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)

View file

@ -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 () => {

View file

@ -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) => {

View file

@ -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>

View file

@ -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', () => {

View file

@ -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 () => {

View file

@ -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>

View file

@ -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(() => {

View file

@ -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>
`; `;

View file

@ -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>

View file

@ -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.')

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>`;

View file

@ -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 {

View file

@ -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')
}) })
}) })

View file

@ -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.')
} }

View file

@ -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')
}) })
}) })

View file

@ -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.')
} }

View file

@ -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 {

View file

@ -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)

View file

@ -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>

View file

@ -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

View file

@ -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')
}) })
}) })

View file

@ -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>

View file

@ -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')
}) })
}) })

View file

@ -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()
} }

View file

@ -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')
}) })
}) })

View file

@ -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>

View file

@ -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>()

View file

@ -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[]>()

View file

@ -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()))

View file

@ -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 () => {

View file

@ -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)
}) })
}) })

View file

@ -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 youre not using a cloud storage, theres no need to set a media path. Since youre not using a cloud storage, theres 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 {

View file

@ -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')
}) })
} }
} }

View file

@ -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>

View file

@ -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>

View file

@ -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 () => {

View file

@ -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 () => {

View file

@ -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)) {

View file

@ -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')
}) })
}) })

View file

@ -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()

View file

@ -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>

View file

@ -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')
}) })
} }
} }

View file

@ -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()

View file

@ -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)

View file

@ -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>

View file

@ -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 () => {

View file

@ -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}?`)) {

View file

@ -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()

View file

@ -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))

View file

@ -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,
} }
} }

View file

@ -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',
}, },

View file

@ -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
}
} }

View file

@ -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', () => {

View file

@ -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 => {