mirror of
https://github.com/koel/koel
synced 2024-11-14 00:17:13 +00:00
feat: allow toggling song list columns (#1857)
This commit is contained in:
parent
3e9b94c099
commit
575551c903
26 changed files with 424 additions and 169 deletions
|
@ -82,13 +82,16 @@ export default abstract class UnitTestCase {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
protected be (user?: User) {
|
protected auth (user?: User = null) {
|
||||||
|
return this.be(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected be (user?: User = null) {
|
||||||
userStore.state.current = user || factory('user')
|
userStore.state.current = user || factory('user')
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
protected beAdmin () {
|
protected beAdmin () {
|
||||||
factory.states('admin')('user')
|
|
||||||
return this.be(factory.states('admin')('user'))
|
return this.be(factory.states('admin')('user'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -54,4 +54,18 @@ window.SSO_PROVIDERS = []
|
||||||
|
|
||||||
window.createLemonSqueezy = vi.fn()
|
window.createLemonSqueezy = vi.fn()
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation(query => ({
|
||||||
|
matches: true,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
Axios.defaults.adapter = vi.fn()
|
Axios.defaults.adapter = vi.fn()
|
||||||
|
|
|
@ -49,7 +49,7 @@ import { useFuzzySearch } from '@/composables/useFuzzySearch'
|
||||||
|
|
||||||
import Btn from '@/components/ui/form/Btn.vue'
|
import Btn from '@/components/ui/form/Btn.vue'
|
||||||
import BtnGroup from '@/components/ui/form/BtnGroup.vue'
|
import BtnGroup from '@/components/ui/form/BtnGroup.vue'
|
||||||
import ListFilter from '@/components/song/SongListFilter.vue'
|
import ListFilter from '@/components/song/song-list/SongListFilter.vue'
|
||||||
import PodcastItem from '@/components/podcast/PodcastItem.vue'
|
import PodcastItem from '@/components/podcast/PodcastItem.vue'
|
||||||
import PodcastItemSkeleton from '@/components/ui/skeletons/PodcastItemSkeleton.vue'
|
import PodcastItemSkeleton from '@/components/ui/skeletons/PodcastItemSkeleton.vue'
|
||||||
import PodcastListSorter from '@/components/podcast/PodcastListSorter.vue'
|
import PodcastListSorter from '@/components/podcast/PodcastListSorter.vue'
|
||||||
|
|
|
@ -91,7 +91,7 @@ import ScreenHeaderSkeleton from '@/components/ui/skeletons/ScreenHeaderSkeleton
|
||||||
import EpisodeItem from '@/components/podcast/EpisodeItem.vue'
|
import EpisodeItem from '@/components/podcast/EpisodeItem.vue'
|
||||||
import VirtualScroller from '@/components/ui/VirtualScroller.vue'
|
import VirtualScroller from '@/components/ui/VirtualScroller.vue'
|
||||||
import Btn from '@/components/ui/form/Btn.vue'
|
import Btn from '@/components/ui/form/Btn.vue'
|
||||||
import ListFilter from '@/components/song/SongListFilter.vue'
|
import ListFilter from '@/components/song/song-list/SongListFilter.vue'
|
||||||
import BtnGroup from '@/components/ui/form/BtnGroup.vue'
|
import BtnGroup from '@/components/ui/form/BtnGroup.vue'
|
||||||
import EpisodeItemSkeleton from '@/components/ui/skeletons/EpisodeItemSkeleton.vue'
|
import EpisodeItemSkeleton from '@/components/ui/skeletons/EpisodeItemSkeleton.vue'
|
||||||
|
|
||||||
|
|
|
@ -1,9 +0,0 @@
|
||||||
// Vitest Snapshot v1
|
|
||||||
|
|
||||||
exports[`renders 1`] = `
|
|
||||||
<div class="song-list-wrap relative flex flex-col flex-1 overflow-auto py-0 px-3 md:p-0" data-testid="song-list" tabindex="0">
|
|
||||||
<div class="sortable song-list-header flex z-[2] bg-k-bg-secondary"><span class="track-number" data-testid="header-track-number" role="button" title="Sort by track number"> # <!--v-if--><!--v-if--></span><span class="title-artist" data-testid="header-title" role="button" title="Sort by title"> Title <br data-testid="Icon" icon="[object Object]" class="text-k-highlight"><!--v-if--></span><span class="album" data-testid="header-album" role="button" title="Sort by album">Album<span class="ml-2"><!--v-if--><!--v-if--></span></span>
|
|
||||||
<!--v-if--><span class="time" data-testid="header-length" role="button" title="Sort by duration"> Time <!--v-if--><!--v-if--></span><span class="extra"><br data-testid="song-list-sorter" field="title" order="asc" content-type="songs"></span>
|
|
||||||
</div><br data-testid="virtual-scroller" item-height="64" items="[object Object],[object Object],[object Object],[object Object],[object Object]">
|
|
||||||
</div>
|
|
||||||
`;
|
|
|
@ -1,10 +0,0 @@
|
||||||
// Vitest Snapshot v1
|
|
||||||
|
|
||||||
exports[`renders 1`] = `
|
|
||||||
<div data-v-9a89d9b9="">
|
|
||||||
<!--v-if-->
|
|
||||||
<article data-v-9a89d9b9="" class="playing song-item group text-k-text-secondary border-b border-k-border !max-w-full h-[64px] flex items-center transition-[background-color,_box-shadow] ease-in-out duration-200 focus:rounded-md focus focus-within:rounded-md focus:ring-inset focus:ring-1 focus:!ring-k-accent focus-within:ring-inset focus-within:ring-1 focus-within:!ring-k-accent hover:bg-white/5 hover:ring-inset hover:ring-1 hover:ring-white/10 hover:rounded-md" data-testid="song-item" tabindex="0"><span data-v-9a89d9b9="" class="track-number"><i data-v-47e95701="" data-v-9a89d9b9="" class="relative flex gap-1 content-between w-[13px] aspect-square"><span data-v-47e95701=""></span><span data-v-47e95701=""></span><span data-v-47e95701=""></span></i></span><span data-v-9a89d9b9="" class="thumbnail leading-none"><button data-v-9a89d9b9="" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" title="Pause" class="song-thumbnail w-[48px] aspect-square bg-cover relative rounded overflow-hidden active:scale-95"><img alt="Cover image" src="https://example.com/cover.jpg" class="w-full aspect-square object-cover" loading="lazy"><span class="absolute top-0 left-0 w-full h-full group-hover:bg-black/40 no-hover:bg-black/40 z-10"></span><span class="absolute flex opacity-0 no-hover:opacity-100 items-center justify-center w-[24px] 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-testid="Icon" icon="[object Object]" class="text-white"></span></button></span><span data-v-9a89d9b9="" class="title-artist flex flex-col gap-2 overflow-hidden"><span data-v-9a89d9b9="" class="title text-k-text-primary !flex gap-2 items-center"><!--v-if--> Test Song</span><span data-v-9a89d9b9="" class="artist">Test Artist</span></span><span data-v-9a89d9b9="" class="album">Test Album</span>
|
|
||||||
<!--v-if--><span data-v-9a89d9b9="" class="time">16:40</span><span data-v-9a89d9b9="" class="extra"><button data-v-9a89d9b9="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary" type="button" title="Unlike"><br data-testid="Icon" icon="[object Object]"></button></span>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
`;
|
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { expect, it } from 'vitest'
|
||||||
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
|
import factory from '@/__tests__/factory'
|
||||||
|
import { arrayify } from '@/utils/helpers'
|
||||||
|
import {
|
||||||
|
PlayableListConfigKey,
|
||||||
|
PlayableListContextKey,
|
||||||
|
PlayableListSortFieldKey,
|
||||||
|
PlayablesKey,
|
||||||
|
SelectedPlayablesKey,
|
||||||
|
SongListSortOrderKey,
|
||||||
|
} from '@/symbols'
|
||||||
|
import SongList from './SongList.vue'
|
||||||
|
|
||||||
|
let songs: Playable[]
|
||||||
|
|
||||||
|
new class extends UnitTestCase {
|
||||||
|
protected test () {
|
||||||
|
it('renders', async () => {
|
||||||
|
const { html } = await this.renderComponent(factory('song', 5))
|
||||||
|
expect(html()).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async renderComponent (
|
||||||
|
_songs: MaybeArray<Playable>,
|
||||||
|
config: Partial<PlayableListConfig> = {
|
||||||
|
sortable: true,
|
||||||
|
reorderable: true,
|
||||||
|
},
|
||||||
|
context: PlayableListContext = {
|
||||||
|
type: 'Album',
|
||||||
|
},
|
||||||
|
selectedPlayables: Playable[] = [],
|
||||||
|
sortField: PlayableListSortField = 'title',
|
||||||
|
sortOrder: SortOrder = 'asc',
|
||||||
|
) {
|
||||||
|
songs = arrayify(_songs)
|
||||||
|
|
||||||
|
const sortFieldRef = ref(sortField)
|
||||||
|
const sortOrderRef = ref(sortOrder)
|
||||||
|
|
||||||
|
await this.router.activateRoute({
|
||||||
|
screen: 'Songs',
|
||||||
|
path: '/songs',
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.render(SongList, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
VirtualScroller: this.stub('virtual-scroller'),
|
||||||
|
SongListSorter: this.stub('song-list-sorter'),
|
||||||
|
SongListHeader: this.stub('song-list-header'),
|
||||||
|
},
|
||||||
|
provide: {
|
||||||
|
[<symbol>PlayablesKey]: [ref(songs)],
|
||||||
|
[<symbol>SelectedPlayablesKey]: [ref(selectedPlayables), (value: Playable[]) => (selectedPlayables = value)],
|
||||||
|
[<symbol>PlayableListConfigKey]: [config],
|
||||||
|
[<symbol>PlayableListContextKey]: [context],
|
||||||
|
[<symbol>PlayableListSortFieldKey]: [sortFieldRef, (value: PlayableListSortField) => (sortFieldRef.value = value)],
|
||||||
|
[<symbol>SongListSortOrderKey]: [sortOrderRef, (value: SortOrder) => (sortOrderRef.value = value)],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,80 +8,7 @@
|
||||||
@keydown.enter.prevent.stop="handleEnter"
|
@keydown.enter.prevent.stop="handleEnter"
|
||||||
@keydown.a.prevent="handleA"
|
@keydown.a.prevent="handleA"
|
||||||
>
|
>
|
||||||
<div
|
<SongListHeader :content-type="contentType" @sort="sort" />
|
||||||
:class="config.sortable ? 'sortable' : 'unsortable'"
|
|
||||||
class="song-list-header flex z-[2] bg-k-bg-secondary"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="track-number"
|
|
||||||
data-testid="header-track-number"
|
|
||||||
role="button"
|
|
||||||
title="Sort by track number"
|
|
||||||
@click="sort('track')"
|
|
||||||
>
|
|
||||||
#
|
|
||||||
<template v-if="config.sortable">
|
|
||||||
<Icon v-if="sortField === 'track' && sortOrder === 'asc'" :icon="faCaretUp" class="text-k-highlight" />
|
|
||||||
<Icon v-if="sortField === 'track' && sortOrder === 'desc'" :icon="faCaretDown" class="text-k-highlight" />
|
|
||||||
</template>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="title-artist"
|
|
||||||
data-testid="header-title"
|
|
||||||
role="button"
|
|
||||||
title="Sort by title"
|
|
||||||
@click="sort('title')"
|
|
||||||
>
|
|
||||||
Title
|
|
||||||
<template v-if="config.sortable">
|
|
||||||
<Icon v-if="sortField === 'title' && sortOrder === 'asc'" :icon="faCaretUp" class="text-k-highlight" />
|
|
||||||
<Icon v-if="sortField === 'title' && sortOrder === 'desc'" :icon="faCaretDown" class="text-k-highlight" />
|
|
||||||
</template>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="album"
|
|
||||||
data-testid="header-album"
|
|
||||||
role="button"
|
|
||||||
:title="`Sort by ${contentType === 'episodes' ? 'podcast' : (contentType === 'songs' ? 'album' : 'album/podcast')}`"
|
|
||||||
@click="sort(contentType === 'episodes' ? 'podcast_title' : (contentType === 'songs' ? 'album_name' : ['album_name', 'podcast_title']))"
|
|
||||||
>
|
|
||||||
<template v-if="contentType === 'episodes'">Podcast</template>
|
|
||||||
<template v-else-if="contentType === 'songs'">Album</template>
|
|
||||||
<template v-else>Album <span class="opacity-50">/</span> Podcast</template>
|
|
||||||
|
|
||||||
<span v-if="config.sortable" class="ml-2">
|
|
||||||
<Icon v-if="sortingByAlbumOrPodcast && sortOrder === 'asc'" :icon="faCaretUp" class="text-k-highlight" />
|
|
||||||
<Icon v-if="sortingByAlbumOrPodcast && sortOrder === 'desc'" :icon="faCaretDown" class="text-k-highlight" />
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<template v-if="config.collaborative">
|
|
||||||
<span class="collaborator">User</span>
|
|
||||||
<span class="added-at">Added</span>
|
|
||||||
</template>
|
|
||||||
<span
|
|
||||||
class="time"
|
|
||||||
data-testid="header-length"
|
|
||||||
role="button"
|
|
||||||
title="Sort by duration"
|
|
||||||
@click="sort('length')"
|
|
||||||
>
|
|
||||||
Time
|
|
||||||
<template v-if="config.sortable">
|
|
||||||
<Icon v-if="sortField === 'length' && sortOrder === 'asc'" :icon="faCaretUp" class="text-k-highlight" />
|
|
||||||
<Icon v-if="sortField === 'length' && sortOrder === 'desc'" :icon="faCaretDown" class="text-k-highlight" />
|
|
||||||
</template>
|
|
||||||
</span>
|
|
||||||
<span class="extra">
|
|
||||||
<SongListSorter
|
|
||||||
v-if="config.sortable"
|
|
||||||
:field="sortField"
|
|
||||||
:has-custom-order-sort="config.hasCustomOrderSort"
|
|
||||||
:order="sortOrder"
|
|
||||||
:content-type="contentType"
|
|
||||||
@sort="sort"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<VirtualScroller
|
<VirtualScroller
|
||||||
v-slot="{ item }: { item: PlayableRow }"
|
v-slot="{ item }: { item: PlayableRow }"
|
||||||
|
@ -111,11 +38,10 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { findIndex, findLastIndex, throttle } from 'lodash'
|
import { findIndex, findLastIndex, throttle } from 'lodash'
|
||||||
import isMobile from 'ismobilejs'
|
import isMobile from 'ismobilejs'
|
||||||
import { faCaretDown, faCaretUp } from '@fortawesome/free-solid-svg-icons'
|
|
||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||||
import { eventBus } from '@/utils/eventBus'
|
import { eventBus } from '@/utils/eventBus'
|
||||||
import { arrayify, requireInjection } from '@/utils/helpers'
|
import { requireInjection } from '@/utils/helpers'
|
||||||
import { getPlayableCollectionContentType } from '@/utils/typeGuards'
|
import { getPlayableCollectionContentType } from '@/utils/typeGuards'
|
||||||
import { preferenceStore as preferences } from '@/stores/preferenceStore'
|
import { preferenceStore as preferences } from '@/stores/preferenceStore'
|
||||||
import { queueStore } from '@/stores/queueStore'
|
import { queueStore } from '@/stores/queueStore'
|
||||||
|
@ -127,12 +53,11 @@ import {
|
||||||
PlayableListSortFieldKey,
|
PlayableListSortFieldKey,
|
||||||
PlayablesKey,
|
PlayablesKey,
|
||||||
SelectedPlayablesKey,
|
SelectedPlayablesKey,
|
||||||
SongListSortOrderKey,
|
|
||||||
} from '@/symbols'
|
} from '@/symbols'
|
||||||
|
|
||||||
import SongListItem from '@/components/song/SongListItem.vue'
|
import SongListItem from '@/components/song/song-list/SongListItem.vue'
|
||||||
import SongListSorter from '@/components/song/SongListSorter.vue'
|
|
||||||
import VirtualScroller from '@/components/ui/VirtualScroller.vue'
|
import VirtualScroller from '@/components/ui/VirtualScroller.vue'
|
||||||
|
import SongListHeader from '@/components/song/song-list/SongListHeader.vue'
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'press:enter', event: KeyboardEvent): void
|
(e: 'press:enter', event: KeyboardEvent): void
|
||||||
|
@ -148,8 +73,7 @@ const { getDroppedData, acceptsDrop } = useDroppable(['playables'])
|
||||||
|
|
||||||
const [playables] = requireInjection<[Ref<Playable[]>]>(PlayablesKey)
|
const [playables] = requireInjection<[Ref<Playable[]>]>(PlayablesKey)
|
||||||
const [selectedPlayables, setSelectedPlayables] = requireInjection<[Ref<Playable[]>, Closure]>(SelectedPlayablesKey)
|
const [selectedPlayables, setSelectedPlayables] = requireInjection<[Ref<Playable[]>, Closure]>(SelectedPlayablesKey)
|
||||||
const [sortField, setSortField] = requireInjection<[Ref<MaybeArray<PlayableListSortField>>, Closure]>(PlayableListSortFieldKey)
|
const [sortField] = requireInjection<[Ref<MaybeArray<PlayableListSortField>>, Closure]>(PlayableListSortFieldKey)
|
||||||
const [sortOrder, setSortOrder] = requireInjection<[Ref<SortOrder>, Closure]>(SongListSortOrderKey)
|
|
||||||
const [config] = requireInjection<[Partial<PlayableListConfig>]>(PlayableListConfigKey, [{}])
|
const [config] = requireInjection<[Partial<PlayableListConfig>]>(PlayableListConfigKey, [{}])
|
||||||
const [context] = requireInjection<[PlayableListContext]>(PlayableListContextKey)
|
const [context] = requireInjection<[PlayableListContext]>(PlayableListContextKey)
|
||||||
|
|
||||||
|
@ -166,11 +90,6 @@ const shouldTriggerContinuousPlayback = computed(() => {
|
||||||
|
|
||||||
const contentType = computed(() => getPlayableCollectionContentType(rows.value.map(({ playable }) => playable)))
|
const contentType = computed(() => getPlayableCollectionContentType(rows.value.map(({ playable }) => playable)))
|
||||||
|
|
||||||
const sortingByAlbumOrPodcast = computed(() => {
|
|
||||||
const sortFields = arrayify(sortField.value)
|
|
||||||
return sortFields[0] === 'album_name' || sortFields[0] === 'podcast_title'
|
|
||||||
})
|
|
||||||
|
|
||||||
const selectAllRows = () => rows.value.forEach(row => (row.selected = true))
|
const selectAllRows = () => rows.value.forEach(row => (row.selected = true))
|
||||||
const clearSelection = () => rows.value.forEach(row => (row.selected = false))
|
const clearSelection = () => rows.value.forEach(row => (row.selected = false))
|
||||||
const handleA = (event: KeyboardEvent) => (event.ctrlKey || event.metaKey) && selectAllRows()
|
const handleA = (event: KeyboardEvent) => (event.ctrlKey || event.metaKey) && selectAllRows()
|
||||||
|
@ -212,16 +131,9 @@ const generateRows = () => {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const sort = (field: MaybeArray<PlayableListSortField>) => {
|
const sort = (field: MaybeArray<PlayableListSortField>, order: SortOrder) => {
|
||||||
// there are certain circumstances where sorting is simply disallowed, e.g. in Queue
|
// we simply pass the sort event from the header up to the parent component
|
||||||
if (!config.sortable) {
|
emit('sort', field, order)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setSortField(field)
|
|
||||||
setSortOrder(sortOrder.value === 'asc' ? 'desc' : 'asc')
|
|
||||||
|
|
||||||
emit('sort', field, sortOrder.value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const render = () => {
|
const render = () => {
|
||||||
|
@ -418,9 +330,8 @@ const calculatedItemHeight = computed(() => {
|
||||||
const totalAdditionalPixels = discCount * discNumberHeight
|
const totalAdditionalPixels = discCount * discNumberHeight
|
||||||
|
|
||||||
const totalHeight = (rows.value.length * standardSongItemHeight) + totalAdditionalPixels
|
const totalHeight = (rows.value.length * standardSongItemHeight) + totalAdditionalPixels
|
||||||
const averageHeight = totalHeight / rows.value.length
|
|
||||||
|
|
||||||
return averageHeight
|
return totalHeight / rows.value.length
|
||||||
})
|
})
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
|
@ -449,7 +360,7 @@ onMounted(() => render())
|
||||||
}
|
}
|
||||||
|
|
||||||
&.track-number {
|
&.track-number {
|
||||||
@apply basis-16 pl-6;
|
@apply basis-16;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.album {
|
&.album {
|
|
@ -119,7 +119,7 @@ const emit = defineEmits<{
|
||||||
(e: 'clearQueue' | 'deletePlaylist' | 'refresh'): void
|
(e: 'clearQueue' | 'deletePlaylist' | 'refresh'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const SongListFilter = defineAsyncComponent(() => import('@/components/song/SongListFilter.vue'))
|
const SongListFilter = defineAsyncComponent(() => import('@/components/song/song-list/SongListFilter.vue'))
|
||||||
|
|
||||||
const config = toRef(props, 'config')
|
const config = toRef(props, 'config')
|
||||||
|
|
|
@ -2,24 +2,19 @@ import { screen } from '@testing-library/vue'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import factory from '@/__tests__/factory'
|
|
||||||
import { arrayify } from '@/utils/helpers'
|
|
||||||
import {
|
import {
|
||||||
PlayableListConfigKey,
|
PlayableListConfigKey,
|
||||||
PlayableListContextKey,
|
PlayableListContextKey,
|
||||||
PlayableListSortFieldKey,
|
PlayableListSortFieldKey,
|
||||||
PlayablesKey,
|
|
||||||
SelectedPlayablesKey,
|
SelectedPlayablesKey,
|
||||||
SongListSortOrderKey,
|
SongListSortOrderKey,
|
||||||
} from '@/symbols'
|
} from '@/symbols'
|
||||||
import SongList from './SongList.vue'
|
import Component from './SongListHeader.vue'
|
||||||
|
|
||||||
let songs: Playable[]
|
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
protected test () {
|
protected test () {
|
||||||
it('renders', async () => {
|
it('renders', async () => {
|
||||||
const { html } = await this.renderComponent(factory('song', 5))
|
const { html } = await this.renderComponent()
|
||||||
expect(html()).toMatchSnapshot()
|
expect(html()).toMatchSnapshot()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -29,7 +24,7 @@ new class extends UnitTestCase {
|
||||||
['album_name', 'header-album'],
|
['album_name', 'header-album'],
|
||||||
['length', 'header-length'],
|
['length', 'header-length'],
|
||||||
])('sorts by %s upon %s clicked', async (field, testId) => {
|
])('sorts by %s upon %s clicked', async (field, testId) => {
|
||||||
const { emitted } = await this.renderComponent(factory('song', 5))
|
const { emitted } = await this.renderComponent()
|
||||||
|
|
||||||
await this.user.click(screen.getByTestId(testId))
|
await this.user.click(screen.getByTestId(testId))
|
||||||
expect(emitted().sort[0]).toEqual([field, 'desc'])
|
expect(emitted().sort[0]).toEqual([field, 'desc'])
|
||||||
|
@ -39,7 +34,7 @@ new class extends UnitTestCase {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('cannot be sorted if configured so', async () => {
|
it('cannot be sorted if configured so', async () => {
|
||||||
const { emitted } = await this.renderComponent(factory('song', 5), {
|
const { emitted } = await this.renderComponent({
|
||||||
sortable: false,
|
sortable: false,
|
||||||
reorderable: true,
|
reorderable: true,
|
||||||
})
|
})
|
||||||
|
@ -50,7 +45,6 @@ new class extends UnitTestCase {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async renderComponent (
|
private async renderComponent (
|
||||||
_songs: MaybeArray<Playable>,
|
|
||||||
config: Partial<PlayableListConfig> = {
|
config: Partial<PlayableListConfig> = {
|
||||||
sortable: true,
|
sortable: true,
|
||||||
reorderable: true,
|
reorderable: true,
|
||||||
|
@ -62,8 +56,6 @@ new class extends UnitTestCase {
|
||||||
sortField: PlayableListSortField = 'title',
|
sortField: PlayableListSortField = 'title',
|
||||||
sortOrder: SortOrder = 'asc',
|
sortOrder: SortOrder = 'asc',
|
||||||
) {
|
) {
|
||||||
songs = arrayify(_songs)
|
|
||||||
|
|
||||||
const sortFieldRef = ref(sortField)
|
const sortFieldRef = ref(sortField)
|
||||||
const sortOrderRef = ref(sortOrder)
|
const sortOrderRef = ref(sortOrder)
|
||||||
|
|
||||||
|
@ -72,14 +64,15 @@ new class extends UnitTestCase {
|
||||||
path: '/songs',
|
path: '/songs',
|
||||||
})
|
})
|
||||||
|
|
||||||
return this.render(SongList, {
|
return this.render(Component, {
|
||||||
|
props: {
|
||||||
|
contentType: 'songs',
|
||||||
|
},
|
||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
VirtualScroller: this.stub('virtual-scroller'),
|
|
||||||
SongListSorter: this.stub('song-list-sorter'),
|
SongListSorter: this.stub('song-list-sorter'),
|
||||||
},
|
},
|
||||||
provide: {
|
provide: {
|
||||||
[<symbol>PlayablesKey]: [ref(songs)],
|
|
||||||
[<symbol>SelectedPlayablesKey]: [ref(selectedPlayables), (value: Playable[]) => (selectedPlayables = value)],
|
[<symbol>SelectedPlayablesKey]: [ref(selectedPlayables), (value: Playable[]) => (selectedPlayables = value)],
|
||||||
[<symbol>PlayableListConfigKey]: [config],
|
[<symbol>PlayableListConfigKey]: [config],
|
||||||
[<symbol>PlayableListContextKey]: [context],
|
[<symbol>PlayableListContextKey]: [context],
|
124
resources/assets/js/components/song/song-list/SongListHeader.vue
Normal file
124
resources/assets/js/components/song/song-list/SongListHeader.vue
Normal file
|
@ -0,0 +1,124 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="config.sortable ? 'sortable' : 'unsortable'"
|
||||||
|
class="song-list-header flex z-[2] bg-k-bg-secondary pl-5"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="shouldShowColumn('track')"
|
||||||
|
class="track-number"
|
||||||
|
data-testid="header-track-number"
|
||||||
|
role="button"
|
||||||
|
title="Sort by track number"
|
||||||
|
@click="sort('track')"
|
||||||
|
>
|
||||||
|
#
|
||||||
|
<template v-if="config.sortable">
|
||||||
|
<Icon v-if="sortField === 'track' && sortOrder === 'asc'" :icon="faCaretUp" class="text-k-highlight" />
|
||||||
|
<Icon v-if="sortField === 'track' && sortOrder === 'desc'" :icon="faCaretDown" class="text-k-highlight" />
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="title-artist"
|
||||||
|
data-testid="header-title"
|
||||||
|
role="button"
|
||||||
|
title="Sort by title"
|
||||||
|
@click="sort('title')"
|
||||||
|
>
|
||||||
|
Title
|
||||||
|
<template v-if="config.sortable">
|
||||||
|
<Icon v-if="sortField === 'title' && sortOrder === 'asc'" :icon="faCaretUp" class="text-k-highlight" />
|
||||||
|
<Icon v-if="sortField === 'title' && sortOrder === 'desc'" :icon="faCaretDown" class="text-k-highlight" />
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="shouldShowColumn('album')"
|
||||||
|
:title="`Sort by ${contentType === 'episodes' ? 'podcast' : (contentType === 'songs' ? 'album' : 'album/podcast')}`"
|
||||||
|
class="album"
|
||||||
|
data-testid="header-album"
|
||||||
|
role="button"
|
||||||
|
@click="sort(contentType === 'episodes' ? 'podcast_title' : (contentType === 'songs' ? 'album_name' : ['album_name', 'podcast_title']))"
|
||||||
|
>
|
||||||
|
<template v-if="contentType === 'episodes'">Podcast</template>
|
||||||
|
<template v-else-if="contentType === 'songs'">Album</template>
|
||||||
|
<template v-else>Album <span class="opacity-50">/</span> Podcast</template>
|
||||||
|
|
||||||
|
<span v-if="config.sortable" class="ml-2">
|
||||||
|
<Icon v-if="sortingByAlbumOrPodcast && sortOrder === 'asc'" :icon="faCaretUp" class="text-k-highlight" />
|
||||||
|
<Icon v-if="sortingByAlbumOrPodcast && sortOrder === 'desc'" :icon="faCaretDown" class="text-k-highlight" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<template v-if="config.collaborative">
|
||||||
|
<span class="collaborator">User</span>
|
||||||
|
<span class="added-at">Added</span>
|
||||||
|
</template>
|
||||||
|
<span
|
||||||
|
v-if="shouldShowColumn('duration')"
|
||||||
|
class="time"
|
||||||
|
data-testid="header-length"
|
||||||
|
role="button"
|
||||||
|
title="Sort by duration"
|
||||||
|
@click="sort('length')"
|
||||||
|
>
|
||||||
|
Time
|
||||||
|
<template v-if="config.sortable">
|
||||||
|
<Icon v-if="sortField === 'length' && sortOrder === 'asc'" :icon="faCaretUp" class="text-k-highlight" />
|
||||||
|
<Icon v-if="sortField === 'length' && sortOrder === 'desc'" :icon="faCaretDown" class="text-k-highlight" />
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
<span class="extra">
|
||||||
|
<SongListSorter
|
||||||
|
v-if="config.sortable"
|
||||||
|
:field="sortField"
|
||||||
|
:has-custom-order-sort="config.hasCustomOrderSort"
|
||||||
|
:order="sortOrder"
|
||||||
|
:content-type="contentType"
|
||||||
|
@sort="sort"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Ref } from 'vue'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { faCaretDown, faCaretUp } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { arrayify, requireInjection } from '@/utils/helpers'
|
||||||
|
import { PlayableListConfigKey, PlayableListSortFieldKey, SongListSortOrderKey } from '@/symbols'
|
||||||
|
import type { getPlayableCollectionContentType } from '@/utils/typeGuards'
|
||||||
|
import { usePlayableListColumnVisibility } from '@/composables/usePlayableListColumnVisibility'
|
||||||
|
|
||||||
|
import SongListSorter from '@/components/song/song-list/SongListSorter.vue'
|
||||||
|
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
contentType?: ReturnType<getPlayableCollectionContentType>
|
||||||
|
}>(), {
|
||||||
|
contentType: 'songs',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'sort', field: MaybeArray<PlayableListSortField>, order: SortOrder): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { shouldShowColumn } = usePlayableListColumnVisibility()
|
||||||
|
|
||||||
|
const [sortField, setSortField] = requireInjection<[Ref<MaybeArray<PlayableListSortField>>, Closure]>(PlayableListSortFieldKey)
|
||||||
|
const [sortOrder, setSortOrder] = requireInjection<[Ref<SortOrder>, Closure]>(SongListSortOrderKey)
|
||||||
|
const [config] = requireInjection<[Partial<PlayableListConfig>]>(PlayableListConfigKey, [{}])
|
||||||
|
|
||||||
|
const sort = (field: MaybeArray<PlayableListSortField>) => {
|
||||||
|
// there are certain circumstances where sorting is simply disallowed, e.g. in Queue
|
||||||
|
if (!config.sortable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSortField(field)
|
||||||
|
setSortOrder(sortOrder.value === 'asc' ? 'desc' : 'asc')
|
||||||
|
|
||||||
|
emit('sort', field, sortOrder.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortingByAlbumOrPodcast = computed(() => {
|
||||||
|
const sortFields = arrayify(sortField.value)
|
||||||
|
return sortFields[0] === 'album_name' || sortFields[0] === 'podcast_title'
|
||||||
|
})
|
||||||
|
</script>
|
|
@ -2,23 +2,23 @@
|
||||||
<div>
|
<div>
|
||||||
<h4
|
<h4
|
||||||
v-if="showDisc && item.playable.disc"
|
v-if="showDisc && item.playable.disc"
|
||||||
class="title text-k-text-primary !flex gap-2 p-2 uppercase"
|
class="title text-k-text-primary !flex gap-2 p-2 uppercase pl-5"
|
||||||
>
|
>
|
||||||
Disc {{ item.playable.disc }}
|
Disc {{ item.playable.disc }}
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
<article
|
<article
|
||||||
:class="{ playing, external, selected: item.selected }"
|
:class="{ playing, external, selected: item.selected }"
|
||||||
class="song-item group text-k-text-secondary border-b border-k-border !max-w-full h-[64px] flex
|
class="song-item group pl-5 text-k-text-secondary border-b border-k-border !max-w-full h-[64px] flex
|
||||||
items-center transition-[background-color,_box-shadow] ease-in-out duration-200
|
items-center transition-[background-color,_box-shadow] ease-in-out duration-200
|
||||||
focus:rounded-md focus focus-within:rounded-md focus:ring-inset focus:ring-1 focus:!ring-k-accent
|
focus:rounded-md focus focus-within:rounded-md focus:ring-inset focus:ring-1 focus:!ring-k-accent
|
||||||
focus-within:ring-inset focus-within:ring-1 focus-within:!ring-k-accent
|
focus-within:ring-inset focus-within:ring-1 focus-within:!ring-k-accent
|
||||||
hover:bg-white/5 hover:ring-inset hover:ring-1 hover:ring-white/10 hover:rounded-md"
|
hover:bg-white/5 hover:ring-inset hover:ring-1 hover:ring-white/10 hover:rounded-md"
|
||||||
data-testid="song-item"
|
data-testid="song-item"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@dblclick.prevent.stop="play"
|
@dblclick.prevent.stop="play"
|
||||||
>
|
>
|
||||||
<span class="track-number">
|
<span v-if="shouldShowColumn('track')" class="track-number">
|
||||||
<SoundBars v-if="playable.playback_state === 'Playing'" />
|
<SoundBars v-if="playable.playback_state === 'Playing'" />
|
||||||
<span v-else class="text-k-text-secondary">
|
<span v-else class="text-k-text-secondary">
|
||||||
<template v-if="isSong(playable)">{{ playable.track || '' }}</template>
|
<template v-if="isSong(playable)">{{ playable.track || '' }}</template>
|
||||||
|
@ -35,14 +35,14 @@
|
||||||
</span>
|
</span>
|
||||||
<span class="artist">{{ artist }}</span>
|
<span class="artist">{{ artist }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="album">{{ album }}</span>
|
<span v-if="shouldShowColumn('album')" class="album">{{ album }}</span>
|
||||||
<template v-if="config.collaborative">
|
<template v-if="config.collaborative">
|
||||||
<span class="collaborator">
|
<span class="collaborator">
|
||||||
<UserAvatar :user="collaborator" width="24" />
|
<UserAvatar :user="collaborator" width="24" />
|
||||||
</span>
|
</span>
|
||||||
<span :title="playable.collaboration.added_at" class="added-at">{{ playable.collaboration.fmt_added_at }}</span>
|
<span :title="playable.collaboration.added_at" class="added-at">{{ playable.collaboration.fmt_added_at }}</span>
|
||||||
</template>
|
</template>
|
||||||
<span class="time">{{ fmtLength }}</span>
|
<span v-if="shouldShowColumn('duration')" class="time">{{ fmtLength }}</span>
|
||||||
<span class="extra">
|
<span class="extra">
|
||||||
<LikeButton :playable="playable" />
|
<LikeButton :playable="playable" />
|
||||||
</span>
|
</span>
|
||||||
|
@ -58,6 +58,7 @@ import { isSong } from '@/utils/typeGuards'
|
||||||
import { secondsToHis } from '@/utils/formatters'
|
import { secondsToHis } from '@/utils/formatters'
|
||||||
import { useAuthorization } from '@/composables/useAuthorization'
|
import { useAuthorization } from '@/composables/useAuthorization'
|
||||||
import { useKoelPlus } from '@/composables/useKoelPlus'
|
import { useKoelPlus } from '@/composables/useKoelPlus'
|
||||||
|
import { usePlayableListColumnVisibility } from '@/composables/usePlayableListColumnVisibility'
|
||||||
import { PlayableListConfigKey } from '@/symbols'
|
import { PlayableListConfigKey } from '@/symbols'
|
||||||
|
|
||||||
import LikeButton from '@/components/song/SongLikeButton.vue'
|
import LikeButton from '@/components/song/SongLikeButton.vue'
|
||||||
|
@ -76,6 +77,7 @@ const [config] = requireInjection<[Partial<PlayableListConfig>]>(PlayableListCon
|
||||||
|
|
||||||
const { currentUser } = useAuthorization()
|
const { currentUser } = useAuthorization()
|
||||||
const { isPlus } = useKoelPlus()
|
const { isPlus } = useKoelPlus()
|
||||||
|
const { shouldShowColumn } = usePlayableListColumnVisibility()
|
||||||
|
|
||||||
const { item } = toRefs(props)
|
const { item } = toRefs(props)
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { screen } from '@testing-library/vue'
|
import { screen } from '@testing-library/vue'
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
|
import { useLocalStorage } from '@/composables/useLocalStorage'
|
||||||
import Component from './SongListSorter.vue'
|
import Component from './SongListSorter.vue'
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
|
@ -47,5 +48,39 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
screen.getByText('Custom Order')
|
screen.getByText('Custom Order')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('has a checkbox to toggle the column visibility', async () => {
|
||||||
|
this.be().render(Component)
|
||||||
|
|
||||||
|
;['Album', 'Track & Disc', 'Time'].forEach(text => screen.getByTitle(`Click to toggle the ${text} column`))
|
||||||
|
|
||||||
|
await this.user.click(screen.getByTitle('Click to toggle the Album column'))
|
||||||
|
|
||||||
|
expect(useLocalStorage().get('playable-list-columns')).toEqual(
|
||||||
|
<PlayableListColumnName[]>['track', 'title', 'artist', 'duration'],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gets the column visibility from local storage', async () => {
|
||||||
|
// ensure the localstorage is properly namespaced
|
||||||
|
this.be()
|
||||||
|
|
||||||
|
useLocalStorage().set('playable-list-columns', ['track'])
|
||||||
|
this.render(Component)
|
||||||
|
|
||||||
|
;[{
|
||||||
|
title: 'Track & Disc',
|
||||||
|
checked: true,
|
||||||
|
}, {
|
||||||
|
title: 'Album',
|
||||||
|
checked: false,
|
||||||
|
}, {
|
||||||
|
title: 'Time',
|
||||||
|
checked: true,
|
||||||
|
}].forEach(({ title, checked }) => {
|
||||||
|
const el: HTMLInputElement = screen.getByTitle(`Click to toggle the ${title} column`)
|
||||||
|
expect(el.checked).toBe(checked)
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -9,10 +9,23 @@
|
||||||
v-for="item in menuItems"
|
v-for="item in menuItems"
|
||||||
:key="item.label"
|
:key="item.label"
|
||||||
:class="currentlySortedBy(item.field) && 'active'"
|
:class="currentlySortedBy(item.field) && 'active'"
|
||||||
class="cursor-pointer flex justify-between"
|
class="cursor-pointer flex justify-between !pl-3 hover:!bg-white/10"
|
||||||
@click="sort(item.field)"
|
@click="sort(item.field)"
|
||||||
>
|
>
|
||||||
<span>{{ item.label }}</span>
|
<label
|
||||||
|
v-if="shouldShowColumnVisibilityCheckboxes()"
|
||||||
|
class="w-4 mr-2.5 flex items-center"
|
||||||
|
@click.stop="item.visibilityToggleable && toggleColumn(item.column)"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:checked="shouldShowColumn(item.column)"
|
||||||
|
:disabled="!item.visibilityToggleable"
|
||||||
|
:title="item.visibilityToggleable ? `Click to toggle the ${item.label} column` : ''"
|
||||||
|
class="disabled:opacity-20 bg-white h-4 aspect-square rounded checked:border-white/75 checked:border-2 checked:bg-k-highlight"
|
||||||
|
type="checkbox"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<span class="flex-1 text-left">{{ item.label }}</span>
|
||||||
<span class="icon hidden ml-3">
|
<span class="icon hidden ml-3">
|
||||||
<Icon v-if="field === 'position'" :icon="faCheck" />
|
<Icon v-if="field === 'position'" :icon="faCheck" />
|
||||||
<Icon v-else-if="order === 'asc'" :icon="faArrowUp" />
|
<Icon v-else-if="order === 'asc'" :icon="faArrowUp" />
|
||||||
|
@ -32,6 +45,7 @@ import { computed, onBeforeUnmount, onMounted, ref, toRefs } from 'vue'
|
||||||
import { useFloatingUi } from '@/composables/useFloatingUi'
|
import { useFloatingUi } from '@/composables/useFloatingUi'
|
||||||
import { arrayify } from '@/utils/helpers'
|
import { arrayify } from '@/utils/helpers'
|
||||||
import type { getPlayableCollectionContentType } from '@/utils/typeGuards'
|
import type { getPlayableCollectionContentType } from '@/utils/typeGuards'
|
||||||
|
import { usePlayableListColumnVisibility } from '@/composables/usePlayableListColumnVisibility'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
field?: MaybeArray<PlayableListSortField> // the current field(s) being sorted by
|
field?: MaybeArray<PlayableListSortField> // the current field(s) being sorted by
|
||||||
|
@ -47,30 +61,57 @@ const props = withDefaults(defineProps<{
|
||||||
|
|
||||||
const emit = defineEmits<{ (e: 'sort', field: MaybeArray<PlayableListSortField>): void }>()
|
const emit = defineEmits<{ (e: 'sort', field: MaybeArray<PlayableListSortField>): void }>()
|
||||||
|
|
||||||
|
interface MenuItem {
|
||||||
|
column?: PlayableListColumnName
|
||||||
|
label: string
|
||||||
|
field: MaybeArray<PlayableListSortField>
|
||||||
|
visibilityToggleable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
shouldShowColumn,
|
||||||
|
toggleColumn,
|
||||||
|
isConfigurable: shouldShowColumnVisibilityCheckboxes,
|
||||||
|
} = usePlayableListColumnVisibility()
|
||||||
|
|
||||||
const { field, order, hasCustomOrderSort, contentType } = toRefs(props)
|
const { field, order, hasCustomOrderSort, contentType } = toRefs(props)
|
||||||
|
|
||||||
const button = ref<HTMLButtonElement>()
|
const button = ref<HTMLButtonElement>()
|
||||||
const menu = ref<HTMLDivElement>()
|
const menu = ref<HTMLDivElement>()
|
||||||
|
|
||||||
const menuItems = computed(() => {
|
const menuItems = computed(() => {
|
||||||
interface MenuItems {
|
const title: MenuItem = { column: 'title', label: 'Title', field: 'title', visibilityToggleable: false }
|
||||||
label: string
|
const artist: MenuItem = { label: 'Artist', field: 'artist_name', visibilityToggleable: false }
|
||||||
field: MaybeArray<PlayableListSortField>
|
const author: MenuItem = { label: 'Author', field: 'podcast_author', visibilityToggleable: false }
|
||||||
|
|
||||||
|
const artistOrAuthor: MenuItem = {
|
||||||
|
label: 'Artist or Author',
|
||||||
|
field: ['artist_name', 'podcast_author'],
|
||||||
|
visibilityToggleable: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
const title: MenuItems = { label: 'Title', field: 'title' }
|
const album: MenuItem = { column: 'album', label: 'Album', field: 'album_name', visibilityToggleable: true }
|
||||||
const artist: MenuItems = { label: 'Artist', field: 'artist_name' }
|
const track: MenuItem = { column: 'track', label: 'Track & Disc', field: 'track', visibilityToggleable: true }
|
||||||
const author: MenuItems = { label: 'Author', field: 'podcast_author' }
|
const time: MenuItem = { column: 'duration', label: 'Time', field: 'length', visibilityToggleable: true }
|
||||||
const artistOrAuthor: MenuItems = { label: 'Artist or Author', field: ['artist_name', 'podcast_author'] }
|
|
||||||
const album: MenuItems = { label: 'Album', field: 'album_name' }
|
|
||||||
const track: MenuItems = { label: 'Track & Disc', field: 'track' }
|
|
||||||
const time: MenuItems = { label: 'Time', field: 'length' }
|
|
||||||
const dateAdded: MenuItems = { label: 'Date Added', field: 'created_at' }
|
|
||||||
const podcast: MenuItems = { label: 'Podcast', field: 'podcast_title' }
|
|
||||||
const albumOrPodcast: MenuItems = { label: 'Album or Podcast', field: ['album_name', 'podcast_title'] }
|
|
||||||
const customOrder: MenuItems = { label: 'Custom Order', field: 'position' }
|
|
||||||
|
|
||||||
let items: MenuItems[] = [title, album, artist, track, time, dateAdded]
|
const dateAdded: MenuItem = {
|
||||||
|
label: 'Date Added',
|
||||||
|
field: 'created_at',
|
||||||
|
visibilityToggleable: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const podcast: MenuItem = { column: 'album', label: 'Podcast', field: 'podcast_title', visibilityToggleable: true }
|
||||||
|
|
||||||
|
const albumOrPodcast: MenuItem = {
|
||||||
|
column: 'album',
|
||||||
|
label: 'Album or Podcast',
|
||||||
|
field: ['album_name', 'podcast_title'],
|
||||||
|
visibilityToggleable: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const customOrder: MenuItem = { label: 'Custom Order', field: 'position', visibilityToggleable: false }
|
||||||
|
|
||||||
|
let items: MenuItem[] = [title, album, artist, track, time, dateAdded]
|
||||||
|
|
||||||
if (contentType.value === 'episodes') {
|
if (contentType.value === 'episodes') {
|
||||||
items = [title, podcast, author, time, dateAdded]
|
items = [title, podcast, author, time, dateAdded]
|
|
@ -0,0 +1,3 @@
|
||||||
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
|
exports[`renders 1`] = `<div class="song-list-wrap relative flex flex-col flex-1 overflow-auto py-0 px-3 md:p-0" data-testid="song-list" tabindex="0"><br data-testid="song-list-header" content-type="songs"><br data-testid="virtual-scroller" item-height="64" items="[object Object],[object Object],[object Object],[object Object],[object Object]"></div>`;
|
|
@ -0,0 +1,7 @@
|
||||||
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
|
exports[`renders 1`] = `
|
||||||
|
<div class="sortable song-list-header flex z-[2] bg-k-bg-secondary pl-5"><span class="track-number" data-testid="header-track-number" role="button" title="Sort by track number"> # <!--v-if--><!--v-if--></span><span class="title-artist" data-testid="header-title" role="button" title="Sort by title"> Title <br data-testid="Icon" icon="[object Object]" class="text-k-highlight"><!--v-if--></span><span title="Sort by album" class="album" data-testid="header-album" role="button">Album<span class="ml-2"><!--v-if--><!--v-if--></span></span>
|
||||||
|
<!--v-if--><span class="time" data-testid="header-length" role="button" title="Sort by duration"> Time <!--v-if--><!--v-if--></span><span class="extra"><br data-testid="song-list-sorter" field="title" order="asc" content-type="songs"></span>
|
||||||
|
</div>
|
||||||
|
`;
|
|
@ -0,0 +1,10 @@
|
||||||
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
|
exports[`renders 1`] = `
|
||||||
|
<div data-v-8a9b98e4="">
|
||||||
|
<!--v-if-->
|
||||||
|
<article data-v-8a9b98e4="" class="playing song-item group pl-5 text-k-text-secondary border-b border-k-border !max-w-full h-[64px] flex items-center transition-[background-color,_box-shadow] ease-in-out duration-200 focus:rounded-md focus focus-within:rounded-md focus:ring-inset focus:ring-1 focus:!ring-k-accent focus-within:ring-inset focus-within:ring-1 focus-within:!ring-k-accent hover:bg-white/5 hover:ring-inset hover:ring-1 hover:ring-white/10 hover:rounded-md" data-testid="song-item" tabindex="0"><span data-v-8a9b98e4="" class="track-number"><i data-v-47e95701="" data-v-8a9b98e4="" class="relative flex gap-1 content-between w-[13px] aspect-square"><span data-v-47e95701=""></span><span data-v-47e95701=""></span><span data-v-47e95701=""></span></i></span><span data-v-8a9b98e4="" class="thumbnail leading-none"><button data-v-8a9b98e4="" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" title="Pause" class="song-thumbnail w-[48px] aspect-square bg-cover relative rounded overflow-hidden active:scale-95"><img alt="Cover image" src="https://example.com/cover.jpg" class="w-full aspect-square object-cover" loading="lazy"><span class="absolute top-0 left-0 w-full h-full group-hover:bg-black/40 no-hover:bg-black/40 z-10"></span><span class="absolute flex opacity-0 no-hover:opacity-100 items-center justify-center w-[24px] 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-testid="Icon" icon="[object Object]" class="text-white"></span></button></span><span data-v-8a9b98e4="" class="title-artist flex flex-col gap-2 overflow-hidden"><span data-v-8a9b98e4="" class="title text-k-text-primary !flex gap-2 items-center"><!--v-if--> Test Song</span><span data-v-8a9b98e4="" class="artist">Test Artist</span></span><span data-v-8a9b98e4="" class="album">Test Album</span>
|
||||||
|
<!--v-if--><span data-v-8a9b98e4="" class="time">16:40</span><span data-v-8a9b98e4="" class="extra"><button data-v-8a9b98e4="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary" type="button" title="Unlike"><br data-testid="Icon" icon="[object Object]"></button></span>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
`;
|
|
@ -1,12 +1,11 @@
|
||||||
import { useAuthorization } from '@/composables/useAuthorization'
|
import { useAuthorization } from '@/composables/useAuthorization'
|
||||||
import { get as baseGet, remove as baseRemove, set as baseSet } from 'local-storage'
|
import { get as baseGet, remove as baseRemove, set as baseSet } from 'local-storage'
|
||||||
|
|
||||||
export const useLocalStorage = (namespaced = true) => {
|
export const useLocalStorage = (namespaced = true, user?: User = null) => {
|
||||||
let namespace = ''
|
let namespace = ''
|
||||||
|
|
||||||
if (namespaced) {
|
if (namespaced) {
|
||||||
const { currentUser } = useAuthorization()
|
namespace = user ? `${user.id}::` : `${useAuthorization().currentUser.value.id}::`
|
||||||
namespace = `${currentUser.value.id}::`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const get = <T> (key: string, defaultValue: T | null = null): T | null => {
|
const get = <T> (key: string, defaultValue: T | null = null): T | null => {
|
||||||
|
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
||||||
|
import { useLocalStorage } from '@/composables/useLocalStorage'
|
||||||
|
import { logger } from '@/utils/logger'
|
||||||
|
|
||||||
|
const visibleColumns: Ref<PlayableListColumnName[]> = ref([])
|
||||||
|
|
||||||
|
export const usePlayableListColumnVisibility = () => {
|
||||||
|
const collectVisibleColumns = () => {
|
||||||
|
const validColumns: PlayableListColumnName[] = ['track', 'title', 'artist', 'album', 'duration', 'play_count']
|
||||||
|
const defaultColumns: PlayableListColumnName[] = ['track', 'title', 'artist', 'album', 'duration']
|
||||||
|
|
||||||
|
try {
|
||||||
|
let columns = useLocalStorage().get<PlayableListColumnName[]>('playable-list-columns', defaultColumns)!
|
||||||
|
|
||||||
|
columns = columns.filter(column => validColumns.includes(column))
|
||||||
|
|
||||||
|
// Ensure 'title' is always visible
|
||||||
|
columns.push('title')
|
||||||
|
return Array.from(new Set(columns))
|
||||||
|
} catch (error: unknown) {
|
||||||
|
process.env.NODE_ENV !== 'test' && logger.error('Failed to load columns from local storage', error)
|
||||||
|
return defaultColumns
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!visibleColumns.value.length) {
|
||||||
|
visibleColumns.value = collectVisibleColumns()
|
||||||
|
}
|
||||||
|
|
||||||
|
const isConfigurable = () => {
|
||||||
|
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||||
|
return breakpoints.isGreaterOrEqual('md')
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldShowColumn = (name: PlayableListColumnName) => {
|
||||||
|
if (!isConfigurable()) {
|
||||||
|
// on a smaller screen we render the columns nonetheless and let CSS handles their visibility
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return visibleColumns.value.includes(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleColumn = (column: PlayableListColumnName) => {
|
||||||
|
let columns = visibleColumns.value
|
||||||
|
|
||||||
|
if (!columns.includes(column)) {
|
||||||
|
columns.push(column)
|
||||||
|
} else {
|
||||||
|
columns = columns.filter(c => c !== column)
|
||||||
|
}
|
||||||
|
|
||||||
|
visibleColumns.value = columns
|
||||||
|
useLocalStorage().set('playable-list-columns', columns)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
shouldShowColumn,
|
||||||
|
toggleColumn,
|
||||||
|
isConfigurable,
|
||||||
|
}
|
||||||
|
}
|
|
@ -22,7 +22,7 @@ import {
|
||||||
} from '@/symbols'
|
} from '@/symbols'
|
||||||
|
|
||||||
import ControlsToggle from '@/components/ui/ScreenControlsToggle.vue'
|
import ControlsToggle from '@/components/ui/ScreenControlsToggle.vue'
|
||||||
import SongList from '@/components/song/SongList.vue'
|
import SongList from '@/components/song/song-list/SongList.vue'
|
||||||
import ThumbnailStack from '@/components/ui/ThumbnailStack.vue'
|
import ThumbnailStack from '@/components/ui/ThumbnailStack.vue'
|
||||||
|
|
||||||
export const useSongList = (
|
export const useSongList = (
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { merge } from 'lodash'
|
import { merge } from 'lodash'
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
import SongListControls from '@/components/song/SongListControls.vue'
|
import SongListControls from '@/components/song/song-list/SongListControls.vue'
|
||||||
|
|
||||||
export const useSongListControls = (
|
export const useSongListControls = (
|
||||||
screen: ScreenName,
|
screen: ScreenName,
|
||||||
|
|
2
resources/assets/js/types.d.ts
vendored
2
resources/assets/js/types.d.ts
vendored
|
@ -517,3 +517,5 @@ interface Visualizer {
|
||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PlayableListColumnName = 'title' | 'album' | 'track' | 'duration' | 'created_at' | 'play_count'
|
||||||
|
|
Loading…
Reference in a new issue