diff --git a/resources/assets/js/__tests__/UnitTestCase.ts b/resources/assets/js/__tests__/UnitTestCase.ts
index fef84265..030bba8e 100644
--- a/resources/assets/js/__tests__/UnitTestCase.ts
+++ b/resources/assets/js/__tests__/UnitTestCase.ts
@@ -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')
return this
}
protected beAdmin () {
- factory.states('admin')('user')
return this.be(factory.states('admin')('user'))
}
diff --git a/resources/assets/js/__tests__/setup.ts b/resources/assets/js/__tests__/setup.ts
index 48e403ec..fa1d5b20 100644
--- a/resources/assets/js/__tests__/setup.ts
+++ b/resources/assets/js/__tests__/setup.ts
@@ -54,4 +54,18 @@ window.SSO_PROVIDERS = []
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()
diff --git a/resources/assets/js/components/screens/PodcastListScreen.vue b/resources/assets/js/components/screens/PodcastListScreen.vue
index ca31744c..64f2983d 100644
--- a/resources/assets/js/components/screens/PodcastListScreen.vue
+++ b/resources/assets/js/components/screens/PodcastListScreen.vue
@@ -49,7 +49,7 @@ import { useFuzzySearch } from '@/composables/useFuzzySearch'
import Btn from '@/components/ui/form/Btn.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 PodcastItemSkeleton from '@/components/ui/skeletons/PodcastItemSkeleton.vue'
import PodcastListSorter from '@/components/podcast/PodcastListSorter.vue'
diff --git a/resources/assets/js/components/screens/PodcastScreen.vue b/resources/assets/js/components/screens/PodcastScreen.vue
index 8ba36616..e69854e7 100644
--- a/resources/assets/js/components/screens/PodcastScreen.vue
+++ b/resources/assets/js/components/screens/PodcastScreen.vue
@@ -91,7 +91,7 @@ import ScreenHeaderSkeleton from '@/components/ui/skeletons/ScreenHeaderSkeleton
import EpisodeItem from '@/components/podcast/EpisodeItem.vue'
import VirtualScroller from '@/components/ui/VirtualScroller.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 EpisodeItemSkeleton from '@/components/ui/skeletons/EpisodeItemSkeleton.vue'
diff --git a/resources/assets/js/components/song/__snapshots__/SongList.spec.ts.snap b/resources/assets/js/components/song/__snapshots__/SongList.spec.ts.snap
deleted file mode 100644
index e9c27506..00000000
--- a/resources/assets/js/components/song/__snapshots__/SongList.spec.ts.snap
+++ /dev/null
@@ -1,9 +0,0 @@
-// Vitest Snapshot v1
-
-exports[`renders 1`] = `
-
-
-
-`;
diff --git a/resources/assets/js/components/song/__snapshots__/SongListItem.spec.ts.snap b/resources/assets/js/components/song/__snapshots__/SongListItem.spec.ts.snap
deleted file mode 100644
index 77270888..00000000
--- a/resources/assets/js/components/song/__snapshots__/SongListItem.spec.ts.snap
+++ /dev/null
@@ -1,10 +0,0 @@
-// Vitest Snapshot v1
-
-exports[`renders 1`] = `
-
-
-
Test SongTest ArtistTest Album
- 16:40
-
-
-`;
diff --git a/resources/assets/js/components/song/song-list/SongList.spec.ts b/resources/assets/js/components/song/song-list/SongList.spec.ts
new file mode 100644
index 00000000..678f94bd
--- /dev/null
+++ b/resources/assets/js/components/song/song-list/SongList.spec.ts
@@ -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,
+ config: Partial = {
+ 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: {
+ [PlayablesKey]: [ref(songs)],
+ [SelectedPlayablesKey]: [ref(selectedPlayables), (value: Playable[]) => (selectedPlayables = value)],
+ [PlayableListConfigKey]: [config],
+ [PlayableListContextKey]: [context],
+ [PlayableListSortFieldKey]: [sortFieldRef, (value: PlayableListSortField) => (sortFieldRef.value = value)],
+ [SongListSortOrderKey]: [sortOrderRef, (value: SortOrder) => (sortOrderRef.value = value)],
+ },
+ },
+ })
+ }
+}
diff --git a/resources/assets/js/components/song/SongList.vue b/resources/assets/js/components/song/song-list/SongList.vue
similarity index 74%
rename from resources/assets/js/components/song/SongList.vue
rename to resources/assets/js/components/song/song-list/SongList.vue
index 84d6e976..5566f049 100644
--- a/resources/assets/js/components/song/SongList.vue
+++ b/resources/assets/js/components/song/song-list/SongList.vue
@@ -8,80 +8,7 @@
@keydown.enter.prevent.stop="handleEnter"
@keydown.a.prevent="handleA"
>
-
+
import { findIndex, findLastIndex, throttle } from 'lodash'
import isMobile from 'ismobilejs'
-import { faCaretDown, faCaretUp } from '@fortawesome/free-solid-svg-icons'
import type { Ref } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { eventBus } from '@/utils/eventBus'
-import { arrayify, requireInjection } from '@/utils/helpers'
+import { requireInjection } from '@/utils/helpers'
import { getPlayableCollectionContentType } from '@/utils/typeGuards'
import { preferenceStore as preferences } from '@/stores/preferenceStore'
import { queueStore } from '@/stores/queueStore'
@@ -127,12 +53,11 @@ import {
PlayableListSortFieldKey,
PlayablesKey,
SelectedPlayablesKey,
- SongListSortOrderKey,
} from '@/symbols'
-import SongListItem from '@/components/song/SongListItem.vue'
-import SongListSorter from '@/components/song/SongListSorter.vue'
+import SongListItem from '@/components/song/song-list/SongListItem.vue'
import VirtualScroller from '@/components/ui/VirtualScroller.vue'
+import SongListHeader from '@/components/song/song-list/SongListHeader.vue'
const emit = defineEmits<{
(e: 'press:enter', event: KeyboardEvent): void
@@ -148,8 +73,7 @@ const { getDroppedData, acceptsDrop } = useDroppable(['playables'])
const [playables] = requireInjection<[Ref]>(PlayablesKey)
const [selectedPlayables, setSelectedPlayables] = requireInjection<[Ref, Closure]>(SelectedPlayablesKey)
-const [sortField, setSortField] = requireInjection<[Ref>, Closure]>(PlayableListSortFieldKey)
-const [sortOrder, setSortOrder] = requireInjection<[Ref, Closure]>(SongListSortOrderKey)
+const [sortField] = requireInjection<[Ref>, Closure]>(PlayableListSortFieldKey)
const [config] = requireInjection<[Partial]>(PlayableListConfigKey, [{}])
const [context] = requireInjection<[PlayableListContext]>(PlayableListContextKey)
@@ -166,11 +90,6 @@ const shouldTriggerContinuousPlayback = computed(() => {
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 clearSelection = () => rows.value.forEach(row => (row.selected = false))
const handleA = (event: KeyboardEvent) => (event.ctrlKey || event.metaKey) && selectAllRows()
@@ -212,16 +131,9 @@ const generateRows = () => {
}))
}
-const sort = (field: MaybeArray) => {
- // 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 sort = (field: MaybeArray, order: SortOrder) => {
+ // we simply pass the sort event from the header up to the parent component
+ emit('sort', field, order)
}
const render = () => {
@@ -418,9 +330,8 @@ const calculatedItemHeight = computed(() => {
const totalAdditionalPixels = discCount * discNumberHeight
const totalHeight = (rows.value.length * standardSongItemHeight) + totalAdditionalPixels
- const averageHeight = totalHeight / rows.value.length
- return averageHeight
+ return totalHeight / rows.value.length
})
defineExpose({
@@ -449,7 +360,7 @@ onMounted(() => render())
}
&.track-number {
- @apply basis-16 pl-6;
+ @apply basis-16;
}
&.album {
diff --git a/resources/assets/js/components/song/SongListControls.spec.ts b/resources/assets/js/components/song/song-list/SongListControls.spec.ts
similarity index 100%
rename from resources/assets/js/components/song/SongListControls.spec.ts
rename to resources/assets/js/components/song/song-list/SongListControls.spec.ts
diff --git a/resources/assets/js/components/song/SongListControls.vue b/resources/assets/js/components/song/song-list/SongListControls.vue
similarity index 99%
rename from resources/assets/js/components/song/SongListControls.vue
rename to resources/assets/js/components/song/song-list/SongListControls.vue
index 6b6d19ea..41abedb0 100644
--- a/resources/assets/js/components/song/SongListControls.vue
+++ b/resources/assets/js/components/song/song-list/SongListControls.vue
@@ -119,7 +119,7 @@ const emit = defineEmits<{
(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')
diff --git a/resources/assets/js/components/song/SongListFilter.spec.ts b/resources/assets/js/components/song/song-list/SongListFilter.spec.ts
similarity index 100%
rename from resources/assets/js/components/song/SongListFilter.spec.ts
rename to resources/assets/js/components/song/song-list/SongListFilter.spec.ts
diff --git a/resources/assets/js/components/song/SongListFilter.vue b/resources/assets/js/components/song/song-list/SongListFilter.vue
similarity index 100%
rename from resources/assets/js/components/song/SongListFilter.vue
rename to resources/assets/js/components/song/song-list/SongListFilter.vue
diff --git a/resources/assets/js/components/song/SongList.spec.ts b/resources/assets/js/components/song/song-list/SongListHeader.spec.ts
similarity index 79%
rename from resources/assets/js/components/song/SongList.spec.ts
rename to resources/assets/js/components/song/song-list/SongListHeader.spec.ts
index 4f28474a..6b3e2afd 100644
--- a/resources/assets/js/components/song/SongList.spec.ts
+++ b/resources/assets/js/components/song/song-list/SongListHeader.spec.ts
@@ -2,24 +2,19 @@ import { screen } from '@testing-library/vue'
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[]
+import Component from './SongListHeader.vue'
new class extends UnitTestCase {
protected test () {
it('renders', async () => {
- const { html } = await this.renderComponent(factory('song', 5))
+ const { html } = await this.renderComponent()
expect(html()).toMatchSnapshot()
})
@@ -29,7 +24,7 @@ new class extends UnitTestCase {
['album_name', 'header-album'],
['length', 'header-length'],
])('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))
expect(emitted().sort[0]).toEqual([field, 'desc'])
@@ -39,7 +34,7 @@ new class extends UnitTestCase {
})
it('cannot be sorted if configured so', async () => {
- const { emitted } = await this.renderComponent(factory('song', 5), {
+ const { emitted } = await this.renderComponent({
sortable: false,
reorderable: true,
})
@@ -50,7 +45,6 @@ new class extends UnitTestCase {
}
private async renderComponent (
- _songs: MaybeArray,
config: Partial = {
sortable: true,
reorderable: true,
@@ -62,8 +56,6 @@ new class extends UnitTestCase {
sortField: PlayableListSortField = 'title',
sortOrder: SortOrder = 'asc',
) {
- songs = arrayify(_songs)
-
const sortFieldRef = ref(sortField)
const sortOrderRef = ref(sortOrder)
@@ -72,14 +64,15 @@ new class extends UnitTestCase {
path: '/songs',
})
- return this.render(SongList, {
+ return this.render(Component, {
+ props: {
+ contentType: 'songs',
+ },
global: {
stubs: {
- VirtualScroller: this.stub('virtual-scroller'),
SongListSorter: this.stub('song-list-sorter'),
},
provide: {
- [PlayablesKey]: [ref(songs)],
[SelectedPlayablesKey]: [ref(selectedPlayables), (value: Playable[]) => (selectedPlayables = value)],
[PlayableListConfigKey]: [config],
[PlayableListContextKey]: [context],
diff --git a/resources/assets/js/components/song/song-list/SongListHeader.vue b/resources/assets/js/components/song/song-list/SongListHeader.vue
new file mode 100644
index 00000000..51654b43
--- /dev/null
+++ b/resources/assets/js/components/song/song-list/SongListHeader.vue
@@ -0,0 +1,124 @@
+
+
+
+
+
diff --git a/resources/assets/js/components/song/SongListItem.spec.ts b/resources/assets/js/components/song/song-list/SongListItem.spec.ts
similarity index 100%
rename from resources/assets/js/components/song/SongListItem.spec.ts
rename to resources/assets/js/components/song/song-list/SongListItem.spec.ts
diff --git a/resources/assets/js/components/song/SongListItem.vue b/resources/assets/js/components/song/song-list/SongListItem.vue
similarity index 82%
rename from resources/assets/js/components/song/SongListItem.vue
rename to resources/assets/js/components/song/song-list/SongListItem.vue
index 10fefa98..17ca7c72 100644
--- a/resources/assets/js/components/song/SongListItem.vue
+++ b/resources/assets/js/components/song/song-list/SongListItem.vue
@@ -2,23 +2,23 @@
Disc {{ item.playable.disc }}
-
+
{{ playable.track || '' }}
@@ -35,14 +35,14 @@
{{ artist }}
- {{ album }}
+ {{ album }}
{{ playable.collaboration.fmt_added_at }}
- {{ fmtLength }}
+ {{ fmtLength }}
@@ -58,6 +58,7 @@ import { isSong } from '@/utils/typeGuards'
import { secondsToHis } from '@/utils/formatters'
import { useAuthorization } from '@/composables/useAuthorization'
import { useKoelPlus } from '@/composables/useKoelPlus'
+import { usePlayableListColumnVisibility } from '@/composables/usePlayableListColumnVisibility'
import { PlayableListConfigKey } from '@/symbols'
import LikeButton from '@/components/song/SongLikeButton.vue'
@@ -76,6 +77,7 @@ const [config] = requireInjection<[Partial]>(PlayableListCon
const { currentUser } = useAuthorization()
const { isPlus } = useKoelPlus()
+const { shouldShowColumn } = usePlayableListColumnVisibility()
const { item } = toRefs(props)
diff --git a/resources/assets/js/components/song/SongListSorter.spec.ts b/resources/assets/js/components/song/song-list/SongListSorter.spec.ts
similarity index 58%
rename from resources/assets/js/components/song/SongListSorter.spec.ts
rename to resources/assets/js/components/song/song-list/SongListSorter.spec.ts
index 0e40c6e6..da9c0cc9 100644
--- a/resources/assets/js/components/song/SongListSorter.spec.ts
+++ b/resources/assets/js/components/song/song-list/SongListSorter.spec.ts
@@ -1,6 +1,7 @@
import { screen } from '@testing-library/vue'
import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase'
+import { useLocalStorage } from '@/composables/useLocalStorage'
import Component from './SongListSorter.vue'
new class extends UnitTestCase {
@@ -47,5 +48,39 @@ new class extends UnitTestCase {
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(
+ ['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)
+ })
+ })
}
}
diff --git a/resources/assets/js/components/song/SongListSorter.vue b/resources/assets/js/components/song/song-list/SongListSorter.vue
similarity index 52%
rename from resources/assets/js/components/song/SongListSorter.vue
rename to resources/assets/js/components/song/song-list/SongListSorter.vue
index 9823e9e2..ac03ea6e 100644
--- a/resources/assets/js/components/song/SongListSorter.vue
+++ b/resources/assets/js/components/song/song-list/SongListSorter.vue
@@ -9,10 +9,23 @@
v-for="item in menuItems"
:key="item.label"
: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)"
>
- {{ item.label }}
+
+ {{ item.label }}
@@ -32,6 +45,7 @@ import { computed, onBeforeUnmount, onMounted, ref, toRefs } from 'vue'
import { useFloatingUi } from '@/composables/useFloatingUi'
import { arrayify } from '@/utils/helpers'
import type { getPlayableCollectionContentType } from '@/utils/typeGuards'
+import { usePlayableListColumnVisibility } from '@/composables/usePlayableListColumnVisibility'
const props = withDefaults(defineProps<{
field?: MaybeArray // the current field(s) being sorted by
@@ -47,30 +61,57 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{ (e: 'sort', field: MaybeArray): void }>()
+interface MenuItem {
+ column?: PlayableListColumnName
+ label: string
+ field: MaybeArray
+ visibilityToggleable: boolean
+}
+
+const {
+ shouldShowColumn,
+ toggleColumn,
+ isConfigurable: shouldShowColumnVisibilityCheckboxes,
+} = usePlayableListColumnVisibility()
+
const { field, order, hasCustomOrderSort, contentType } = toRefs(props)
const button = ref()
const menu = ref()
const menuItems = computed(() => {
- interface MenuItems {
- label: string
- field: MaybeArray
+ const title: MenuItem = { column: 'title', label: 'Title', field: 'title', visibilityToggleable: false }
+ const artist: MenuItem = { label: 'Artist', field: 'artist_name', visibilityToggleable: false }
+ 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 artist: MenuItems = { label: 'Artist', field: 'artist_name' }
- const author: MenuItems = { label: 'Author', field: 'podcast_author' }
- 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' }
+ const album: MenuItem = { column: 'album', label: 'Album', field: 'album_name', visibilityToggleable: true }
+ const track: MenuItem = { column: 'track', label: 'Track & Disc', field: 'track', visibilityToggleable: true }
+ const time: MenuItem = { column: 'duration', label: 'Time', field: 'length', visibilityToggleable: true }
- 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') {
items = [title, podcast, author, time, dateAdded]
diff --git a/resources/assets/js/components/song/song-list/__snapshots__/SongList.spec.ts.snap b/resources/assets/js/components/song/song-list/__snapshots__/SongList.spec.ts.snap
new file mode 100644
index 00000000..90e4f502
--- /dev/null
+++ b/resources/assets/js/components/song/song-list/__snapshots__/SongList.spec.ts.snap
@@ -0,0 +1,3 @@
+// Vitest Snapshot v1
+
+exports[`renders 1`] = `
`;
diff --git a/resources/assets/js/components/song/song-list/__snapshots__/SongListHeader.spec.ts.snap b/resources/assets/js/components/song/song-list/__snapshots__/SongListHeader.spec.ts.snap
new file mode 100644
index 00000000..1628fee2
--- /dev/null
+++ b/resources/assets/js/components/song/song-list/__snapshots__/SongListHeader.spec.ts.snap
@@ -0,0 +1,7 @@
+// Vitest Snapshot v1
+
+exports[`renders 1`] = `
+
+`;
diff --git a/resources/assets/js/components/song/song-list/__snapshots__/SongListItem.spec.ts.snap b/resources/assets/js/components/song/song-list/__snapshots__/SongListItem.spec.ts.snap
new file mode 100644
index 00000000..750f238c
--- /dev/null
+++ b/resources/assets/js/components/song/song-list/__snapshots__/SongListItem.spec.ts.snap
@@ -0,0 +1,10 @@
+// Vitest Snapshot v1
+
+exports[`renders 1`] = `
+
+
+
Test SongTest ArtistTest Album
+ 16:40
+
+
+`;
diff --git a/resources/assets/js/composables/useLocalStorage.ts b/resources/assets/js/composables/useLocalStorage.ts
index c7ccbd37..5d507ecc 100644
--- a/resources/assets/js/composables/useLocalStorage.ts
+++ b/resources/assets/js/composables/useLocalStorage.ts
@@ -1,12 +1,11 @@
import { useAuthorization } from '@/composables/useAuthorization'
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 = ''
if (namespaced) {
- const { currentUser } = useAuthorization()
- namespace = `${currentUser.value.id}::`
+ namespace = user ? `${user.id}::` : `${useAuthorization().currentUser.value.id}::`
}
const get = (key: string, defaultValue: T | null = null): T | null => {
diff --git a/resources/assets/js/composables/usePlayableListColumnVisibility.ts b/resources/assets/js/composables/usePlayableListColumnVisibility.ts
new file mode 100644
index 00000000..57fed863
--- /dev/null
+++ b/resources/assets/js/composables/usePlayableListColumnVisibility.ts
@@ -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 = 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('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,
+ }
+}
diff --git a/resources/assets/js/composables/useSongList.ts b/resources/assets/js/composables/useSongList.ts
index 4d167c42..cf2887c3 100644
--- a/resources/assets/js/composables/useSongList.ts
+++ b/resources/assets/js/composables/useSongList.ts
@@ -22,7 +22,7 @@ import {
} from '@/symbols'
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'
export const useSongList = (
diff --git a/resources/assets/js/composables/useSongListControls.ts b/resources/assets/js/composables/useSongListControls.ts
index 5fb28337..2ac6d8ce 100644
--- a/resources/assets/js/composables/useSongListControls.ts
+++ b/resources/assets/js/composables/useSongListControls.ts
@@ -1,7 +1,7 @@
import { merge } from 'lodash'
import { reactive } from 'vue'
-import SongListControls from '@/components/song/SongListControls.vue'
+import SongListControls from '@/components/song/song-list/SongListControls.vue'
export const useSongListControls = (
screen: ScreenName,
diff --git a/resources/assets/js/types.d.ts b/resources/assets/js/types.d.ts
index ecd40368..637651b0 100644
--- a/resources/assets/js/types.d.ts
+++ b/resources/assets/js/types.d.ts
@@ -517,3 +517,5 @@ interface Visualizer {
url: string
}
}
+
+type PlayableListColumnName = 'title' | 'album' | 'track' | 'duration' | 'created_at' | 'play_count'