mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +00:00
feat(podcast): sort podcasts
This commit is contained in:
parent
af237d5419
commit
5d126c2cba
11 changed files with 132 additions and 24 deletions
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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> */
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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: {}
|
||||
|
|
|
@ -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">
|
||||
|
|
77
resources/assets/js/components/podcast/PodcastListSorter.vue
Normal file
77
resources/assets/js/components/podcast/PodcastListSorter.vue
Normal 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>
|
|
@ -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
|
||||
|
|
|
@ -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'])
|
||||
|
|
|
@ -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' }
|
||||
|
|
3
resources/assets/js/types.d.ts
vendored
3
resources/assets/js/types.d.ts
vendored
|
@ -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'
|
||||
|
||||
|
|
Loading…
Reference in a new issue