feat(podcast): sort podcasts

This commit is contained in:
Phan An 2024-06-13 16:46:13 +02:00
parent af237d5419
commit 5d126c2cba
11 changed files with 132 additions and 24 deletions

View file

@ -52,6 +52,7 @@ class PodcastResource extends JsonResource
/** @var PodcastUserPivot $pivot */
$pivot = $this->podcast->subscribers->sole('id', $user->id)->pivot;
$data['subscribed_at'] = $pivot->created_at;
$data['last_played_at'] = $pivot->updated_at;
$data['state'] = $pivot->state->toArray();
}

View file

@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Relations\Pivot;
/**
* @property Carbon $created_at
* @property Carbon $updated_at
* @property PodcastState $state
*/
class PodcastUserPivot extends Pivot

View file

@ -17,7 +17,7 @@ class PodcastRepository extends Repository
/** @return Collection<Podcast> */
public function getAllByUser(User $user): Collection
{
return $user->podcasts;
return $user->podcasts()->orderByPivot('updated_at', 'desc')->get();
}
/** @return Collection<Podcast> */

View file

@ -37,7 +37,7 @@ class SongRepository extends Repository
return Song::query(type: PlayableType::SONG, user: $scopedUser ?? $this->auth->user())
->accessible()
->withMeta()
->latest('songs.created_at')
->latest()
->limit($count)
->get();
}

View file

@ -11,6 +11,7 @@ export default (faker: Faker): Podcast => {
description: faker.lorem.paragraph(),
author: faker.name.findName(),
subscribed_at: faker.date.past().toISOString(),
created_at: faker.date.past().toISOString(),
state: {
current_episode: null,
progresses: {}

View file

@ -10,7 +10,15 @@
<main class="flex-1">
<header>
<h3 class="text-3xl font-bold">{{ podcast.title }}</h3>
<p class="mt-2">{{ podcast.author }}</p>
<p class="mt-2">
{{ podcast.author }}
<template v-if="lastPlayedAt">
<span class="opacity-70">
Last played
<time :datetime="podcast.last_played_at" :title="podcast.last_played_at">{{ lastPlayedAt }}</time>
</span>
</template>
</p>
</header>
<div class="description text-k-text-secondary mt-3 line-clamp-3" v-html="description" v-koel-new-tab />
</main>
@ -20,10 +28,16 @@
<script setup lang="ts">
import { computed } from 'vue'
import DOMPurify from 'dompurify'
import { formatTimeAgo } from '@vueuse/core'
const { podcast } = defineProps<{ podcast: Podcast }>()
const description = computed(() => DOMPurify.sanitize(podcast.description))
const lastPlayedAt = computed(() => podcast.state.current_episode
? formatTimeAgo(new Date(podcast.last_played_at))
: null
)
</script>
<style scoped lang="postcss">

View file

@ -0,0 +1,77 @@
<template>
<article>
<button
ref="button"
class="border px-3 rounded-md h-full border-white/10 w-full focus:text-k-highlight text-k-text-secondary active:text-white focus:text-white"
title="Sort"
@click.stop="trigger"
>
<span class="mr-2">{{ currentFieldLabel }}</span>
<Icon :icon="order === 'asc' ? faArrowUp : faArrowDown" />
</button>
<OnClickOutside @trigger="hide">
<menu ref="menu" class="context-menu normal-case tracking-normal">
<li
v-for="item in items"
:key="item.label"
:class="currentlySortedBy(item.field) && 'active'"
class="cursor-pointer flex justify-between"
@click="sort(item.field)"
>
<span>{{ item.label }}</span>
<span v-if="currentlySortedBy(item.field)" class="opacity-80">
<Icon class="" v-if="order === 'asc'" :icon="faArrowUp" />
<Icon v-else :icon="faArrowDown" />
</span>
</li>
</menu>
</OnClickOutside>
</article>
</template>
<script setup lang="ts">
import { faArrowDown, faArrowUp } from '@fortawesome/free-solid-svg-icons'
import { OnClickOutside } from '@vueuse/components'
import { useFloatingUi } from '@/composables'
import { computed, onBeforeUnmount, onMounted, ref, toRefs } from 'vue'
const props = withDefaults(defineProps<{
field?: PodcastListSortField
order?: SortOrder
}>(), {
field: 'last_played_at',
order: 'asc',
})
const { field: activeField, order } = toRefs(props)
const emit = defineEmits<{ (e: 'sort', field: MaybeArray<PodcastListSortField>): void }>()
const button = ref<HTMLButtonElement>()
const menu = ref<HTMLDivElement>()
const { setup, teardown, trigger, hide } = useFloatingUi(button, menu, {
placement: 'bottom-end',
useArrow: false,
autoTrigger: false
})
const items: { label: string, field: PodcastListSortField }[] = [
{ label: 'Last played', field: 'last_played_at' },
{ label: 'Subscribed', field: 'subscribed_at' },
{ label: 'Title', field: 'title' },
{ label: 'Author', field: 'author' }
]
const currentFieldLabel = computed(() => items.find(item => item.field === activeField.value).label)
const sort = (field: MaybeArray<PodcastListSortField>) => {
emit('sort', field)
hide()
}
const currentlySortedBy = (field: PodcastListSortField) => field === activeField.value
onMounted(() => menu.value && setup())
onBeforeUnmount(() => teardown())
</script>

View file

@ -6,6 +6,7 @@
<template #controls>
<div class="flex gap-2" v-if="!loading">
<PodcastListSorter :field="sortParams.field" :order="sortParams.order" @sort="sort" />
<ListFilter @change="onFilterChanged" />
<BtnGroup uppercase>
<Btn @click.prevent="requestAddPodcastForm" highlight>
@ -40,19 +41,20 @@
import Fuse from 'fuse.js'
import { faAdd, faPodcast } from '@fortawesome/free-solid-svg-icons'
import { orderBy } from 'lodash'
import { computed, ref } from 'vue'
import { computed, reactive, ref } from 'vue'
import { eventBus } from '@/utils'
import { useErrorHandler, useRouter } from '@/composables'
import { podcastStore } from '@/stores'
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
import ScreenBase from '@/components/screens/ScreenBase.vue'
import Btn from '@/components/ui/form/Btn.vue'
import BtnGroup from '@/components/ui/form/BtnGroup.vue'
import PodcastItem from '@/components/podcast/PodcastItem.vue'
import ListFilter from '@/components/song/SongListFilter.vue'
import PodcastItem from '@/components/podcast/PodcastItem.vue'
import PodcastItemSkeleton from '@/components/ui/skeletons/PodcastItemSkeleton.vue'
import PodcastListSorter from '@/components/podcast/PodcastListSorter.vue'
import ScreenBase from '@/components/screens/ScreenBase.vue'
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
const { onScreenActivated } = useRouter()
@ -60,6 +62,11 @@ let initialized = false
const loading = ref(false)
const keywords = ref('')
const sortParams = reactive<{ field: PodcastListSortField, order: SortOrder }>({
field: 'last_played_at',
order: 'desc'
})
let fuse: Fuse<Podcast> | null = null
const noPodcasts = computed(() => !loading.value && podcasts.value.length === 0)
@ -87,13 +94,18 @@ const podcasts = computed(() => {
list = fuse?.search(keywords.value).map(result => result.item) || []
}
return orderBy(list, 'subscribed_at', 'desc')
return orderBy(list, sortParams.field, sortParams.order)
})
const onFilterChanged = (q: string) => (keywords.value = q)
const requestAddPodcastForm = () => eventBus.emit('MODAL_SHOW_ADD_PODCAST_FORM')
const sort = (field: SortField) => {
sortParams.field = field
sortParams.order = sortParams.order === 'asc' ? 'desc' : 'asc'
}
onScreenActivated('Podcasts', async () => {
if (!initialized) {
initialized = true

View file

@ -21,8 +21,8 @@
>
#
<template v-if="config.sortable">
<Icon v-if="sortField === 'track' && sortOrder === 'asc'" :icon="faCaretDown" class="text-k-highlight" />
<Icon v-if="sortField === 'track' && sortOrder === 'desc'" :icon="faCaretUp" class="text-k-highlight" />
<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
@ -34,8 +34,8 @@
>
Title
<template v-if="config.sortable">
<Icon v-if="sortField === 'title' && sortOrder === 'asc'" :icon="faCaretDown" class="text-k-highlight" />
<Icon v-if="sortField === 'title' && sortOrder === 'desc'" :icon="faCaretUp" class="text-k-highlight" />
<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
@ -50,8 +50,8 @@
<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="faCaretDown" class="text-k-highlight" />
<Icon v-if="sortingByAlbumOrPodcast && sortOrder === 'desc'" :icon="faCaretUp" class="text-k-highlight" />
<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">
@ -67,8 +67,8 @@
>
Time
<template v-if="config.sortable">
<Icon v-if="sortField === 'length' && sortOrder === 'asc'" :icon="faCaretDown" class="text-k-highlight" />
<Icon v-if="sortField === 'length' && sortOrder === 'desc'" :icon="faCaretUp" class="text-k-highlight" />
<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">
@ -127,9 +127,9 @@ import {
SongListSortOrderKey
} from '@/symbols'
import VirtualScroller from '@/components/ui/VirtualScroller.vue'
import SongListItem from '@/components/song/SongListItem.vue'
import SongListSorter from '@/components/song/SongListSorter.vue'
import VirtualScroller from '@/components/ui/VirtualScroller.vue'
const { startDragging } = useDraggable('playables')
const { getDroppedData, acceptsDrop } = useDroppable(['playables'])

View file

@ -1,5 +1,5 @@
<template>
<div>
<article>
<button ref="button" class="w-full focus:text-k-highlight" title="Sort" @click.stop="trigger">
<Icon :icon="faSort" />
</button>
@ -15,13 +15,13 @@
<span>{{ item.label }}</span>
<span class="icon hidden ml-3">
<Icon v-if="field === 'position'" :icon="faCheck" />
<Icon v-else-if="order === 'asc'" :icon="faArrowDown" />
<Icon v-else :icon="faArrowUp" />
<Icon v-else-if="order === 'asc'" :icon="faArrowUp" />
<Icon v-else :icon="faArrowDown" />
</span>
</li>
</menu>
</OnClickOutside>
</div>
</article>
</template>
<script lang="ts" setup>
@ -52,8 +52,7 @@ const button = ref<HTMLButtonElement>()
const menu = ref<HTMLDivElement>()
const menuItems = computed(() => {
type MenuItems = { label: string, field: MaybeArray<PlayableListSortField> }
type MenuItems = { label: string, field: MaybeArray<PlayableListSortField> }
const title: MenuItems = { label: 'Title', field: 'title' }
const artist: MenuItems = { label: 'Artist', field: 'artist_name' }
const author: MenuItems = { label: 'Author', field: 'podcast_author' }

View file

@ -288,6 +288,7 @@ interface Podcast {
readonly description: string
readonly author: string
readonly subscribed_at: string
readonly last_played_at: string
readonly state: {
current_episode: Playable['id'] | null
progresses: Record<Playable['id'], number>
@ -470,6 +471,8 @@ type PlayableListSortField =
| keyof Pick<Episode, 'podcast_author' | 'podcast_title'>
| 'position'
type PodcastListSortField = keyof Pick<Podcast, 'title' | 'last_played_at' | 'subscribed_at' | 'author'>
type SortOrder = 'asc' | 'desc'
type MoveType = 'before' | 'after'