enhancement(front):use paginated page for playlist

fix(front): enable more link on artist and user profiles
refactor(front): use new ui components in track modal and track-mobile-row, render track-modal only once in parent and populate with data from mobile-row
This commit is contained in:
petitminion 2025-06-02 11:27:52 +00:00
parent 0d9c5e77c4
commit b063a3616e
9 changed files with 176 additions and 153 deletions

View File

@ -1,7 +1,7 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: Funkwhale API title: Funkwhale API
version: 1.4.0 version: 2.0.0a1
description: | description: |
# Funkwhale API # Funkwhale API

View File

@ -7,6 +7,7 @@ from django.db.models import Count
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema
from rest_framework import exceptions, mixins, status, viewsets from rest_framework import exceptions, mixins, status, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.pagination import PageNumberPagination
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.renderers import JSONRenderer from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response from rest_framework.response import Response
@ -138,9 +139,15 @@ class PlaylistViewSet(
plugins.TRIGGER_THIRD_PARTY_UPLOAD, plugins.TRIGGER_THIRD_PARTY_UPLOAD,
track=plt.track, track=plt.track,
) )
serializer = serializers.PlaylistTrackSerializer(plts, many=True)
data = {"count": len(plts), "results": serializer.data} # Apply pagination
return Response(data, status=200) paginator = PageNumberPagination()
paginator.page_size = 100 # Set the page size (number of items per page)
paginated_plts = paginator.paginate_queryset(plts, request)
# Serialize the paginated data
serializer = serializers.PlaylistTrackSerializer(paginated_plts, many=True)
return paginator.get_paginated_response(serializer.data)
@extend_schema( @extend_schema(
operation_id="add_to_playlist", request=serializers.PlaylistAddManySerializer operation_id="add_to_playlist", request=serializers.PlaylistAddManySerializer

View File

@ -2,7 +2,7 @@
import type { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types' import type { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types'
import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions' import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions'
import { ref, computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { usePlayer } from '~/composables/audio/player' import { usePlayer } from '~/composables/audio/player'
@ -12,9 +12,10 @@ import { useStore } from '~/store'
import usePlayOptions from '~/composables/audio/usePlayOptions' import usePlayOptions from '~/composables/audio/usePlayOptions'
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue' import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
import TrackModal from '~/components/audio/track/Modal.vue'
import { generateTrackCreditString } from '~/utils/utils' import { generateTrackCreditString } from '~/utils/utils'
import Button from '~/components/ui/Button.vue'
interface Props extends PlayOptionsProps { interface Props extends PlayOptionsProps {
track: Track track: Track
index: number index: number
@ -25,7 +26,6 @@ interface Props extends PlayOptionsProps {
// TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged // TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged
isPlayable?: boolean isPlayable?: boolean
tracks?: Track[]
artist?: Artist | null artist?: Artist | null
album?: Album | null album?: Album | null
playlist?: Playlist | null playlist?: Playlist | null
@ -39,7 +39,6 @@ const props = withDefaults(defineProps<Props>(), {
isArtist: false, isArtist: false,
isAlbum: false, isAlbum: false,
tracks: () => [],
artist: null, artist: null,
album: null, album: null,
playlist: null, playlist: null,
@ -48,7 +47,9 @@ const props = withDefaults(defineProps<Props>(), {
account: null account: null
}) })
const showTrackModal = ref(false) const emit = defineEmits<{
(e: 'open-modal', track: Track, index: number): void
}>()
const { currentTrack } = useQueue() const { currentTrack } = useQueue()
const { isPlaying } = usePlayer() const { isPlaying } = usePlayer()
@ -115,8 +116,10 @@ const actionsButtonLabel = computed(() => t('components.audio.track.MobileRow.bu
/> />
</p> </p>
</div> </div>
<div <track-favorite-icon
v-if="store.state.auth.authenticated" v-if="store.state.auth.authenticated"
ghost
tiny
:class="[ :class="[
'meta', 'meta',
'right', 'right',
@ -125,17 +128,14 @@ const actionsButtonLabel = computed(() => t('components.audio.track.MobileRow.bu
'mobile', 'mobile',
{ 'with-art': showArt }, { 'with-art': showArt },
]" ]"
role="button" :track="track"
> />
<track-favorite-icon <!-- TODO: Replace with <PlayButton :dropdown-only="true"> after its display is fixed for mobile -->
class="tiny" <Button
:border="false"
:track="track"
/>
</div>
<div
role="button"
:aria-label="actionsButtonLabel" :aria-label="actionsButtonLabel"
icon="bi-three-dots-vertical"
ghost
tiny
:class="[ :class="[
'modal-button', 'modal-button',
'right', 'right',
@ -144,16 +144,7 @@ const actionsButtonLabel = computed(() => t('components.audio.track.MobileRow.bu
'mobile', 'mobile',
{ 'with-art': showArt }, { 'with-art': showArt },
]" ]"
@click.prevent.exact="showTrackModal = !showTrackModal" @click.prevent.exact="emit('open-modal', track, index)"
>
<i class="ellipsis large vertical icon" />
</div>
<track-modal
v-model:show="showTrackModal"
:track="track"
:index="index"
:is-artist="isArtist"
:is-album="isAlbum"
/> />
</div> </div>
</template> </template>

View File

@ -12,6 +12,8 @@ import { useVModel } from '@vueuse/core'
import { generateTrackCreditString, getArtistCoverUrl } from '~/utils/utils' import { generateTrackCreditString, getArtistCoverUrl } from '~/utils/utils'
import Modal from '~/components/ui/Modal.vue' import Modal from '~/components/ui/Modal.vue'
import Button from '~/components/ui/Button.vue'
import Layout from '~/components/ui/Layout.vue'
interface Events { interface Events {
(e: 'update:show', value: boolean): void (e: 'update:show', value: boolean): void
@ -92,9 +94,11 @@ const labels = computed(() => ({
</script> </script>
<template> <template>
<!-- TODO: Delete this file after this modal is replaced with playbutton dropdown-only popover -->
<Modal <Modal
v-model="show" v-model="show"
:title="track.title" :title="track.title"
class="small"
> >
<div class="header"> <div class="header">
<div class="ui large centered rounded image"> <div class="ui large centered rounded image">
@ -127,126 +131,105 @@ const labels = computed(() => ({
{{ generateTrackCreditString(track) }} {{ generateTrackCreditString(track) }}
</h4> </h4>
</div> </div>
<div class="ui hidden divider" />
<div class="content"> <div class="content">
<div class="ui one column unstackable grid"> <Layout
<div stack
no-gap
>
<Button
v-if="store.state.auth.authenticated && track.artist_credit?.[0].artist.content_category !== 'podcast'" v-if="store.state.auth.authenticated && track.artist_credit?.[0].artist.content_category !== 'podcast'"
class="row" full
ghost
:aria-label="favoriteButton"
:icon="isFavorite ? 'bi-heart-fill' : 'bi-heart'"
:is-active="isFavorite"
@click.stop="store.dispatch('favorites/toggle', track.id)"
> >
<div {{ favoriteButton }}
tabindex="0" </Button>
class="column" <Button
role="button" full
:aria-label="favoriteButton" ghost
@click.stop="store.dispatch('favorites/toggle', track.id)" :aria-label="labels.addToQueue"
> icon="bi-plus"
<i :class="[ 'heart', 'favorite-icon', { favorited: isFavorite, pink: isFavorite }, 'icon', 'track-modal', 'list-icon' ]" /> @click.stop.prevent="enqueue(); show = false"
<span class="track-modal list-item">{{ favoriteButton }}</span> >
</div> {{ labels.addToQueue }}
</div> </Button>
<div class="row"> <Button
<div full
class="column" ghost
role="button" :aria-label="labels.playNext"
:aria-label="labels.addToQueue" icon="bi-skip-end"
@click.stop.prevent="enqueue(); show = false" @click.stop.prevent="enqueueNext(true); show = false"
> >
<i class="plus icon track-modal list-icon" /> {{ labels.playNext }}
<span class="track-modal list-item">{{ labels.addToQueue }}</span> </Button>
</div> <Button
</div> full
<div class="row"> ghost
<div :aria-label="labels.startRadio"
class="column" icon="bi-rss"
role="button" @click.stop.prevent="store.dispatch('radios/start', { type: 'similar', objectId: track.id }); show = false"
:aria-label="labels.playNext" >
@click.stop.prevent="enqueueNext(true);show = false" {{ labels.startRadio }}
> </Button>
<i class="step forward icon track-modal list-icon" /> <Button
<span class="track-modal list-item">{{ labels.playNext }}</span> full
</div> ghost
</div> :aria-label="labels.addToPlaylist"
<div class="row"> icon="bi-list"
<div @click.stop="store.commit('playlists/chooseTrack', track)"
class="column" >
role="button" {{ labels.addToPlaylist }}
:aria-label="labels.startRadio" </Button>
@click.stop.prevent="() => { store.dispatch('radios/start', { type: 'similar', objectId: track.id }); show = false }" <hr>
> <Button
<i class="rss icon track-modal list-icon" />
<span class="track-modal list-item">{{ labels.startRadio }}</span>
</div>
</div>
<div class="row">
<div
class="column"
role="button"
:aria-label="labels.addToPlaylist"
@click.stop="store.commit('playlists/chooseTrack', track)"
>
<i class="list icon track-modal list-icon" />
<span class="track-modal list-item">
{{ labels.addToPlaylist }}
</span>
</div>
</div>
<div class="ui divider" />
<div
v-if="!isAlbum && track.album" v-if="!isAlbum && track.album"
class="row" full
ghost
:aria-label="albumDetailsButton"
icon="bi-disc"
@click.prevent.exact="router.push({ name: 'library.albums.detail', params: { id: track.album?.id } })"
> >
<div {{ albumDetailsButton }}
class="column" </Button>
role="button" <template
:aria-label="albumDetailsButton"
@click.prevent.exact="router.push({ name: 'library.albums.detail', params: { id: track.album?.id } })"
>
<i class="compact disc icon track-modal list-icon" />
<span class="track-modal list-item">{{ albumDetailsButton }}</span>
</div>
</div>
<div
v-if="!isArtist" v-if="!isArtist"
class="row"
> >
<div <Button
v-for="ac in track.artist_credit" v-for="ac in track.artist_credit"
:key="ac.artist.id" :key="ac.artist.id"
class="column" full
role="button" ghost
:aria-label="artistDetailsButton" :aria-label="artistDetailsButton"
icon="bi-person-fill"
@click.prevent.exact="router.push({ name: 'library.artists.detail', params: { id: ac.artist.id } })" @click.prevent.exact="router.push({ name: 'library.artists.detail', params: { id: ac.artist.id } })"
> >
<i class="user icon track-modal list-icon" /> {{ ac.credit }}<span v-if="ac.joinphrase">{{ ac.joinphrase }}</span>
<span class="track-modal list-item">{{ ac.credit }}</span> </Button>
<span v-if="ac.joinphrase">{{ ac.joinphrase }}</span> </template>
</div> <Button
</div> full
<div class="row"> ghost
<div :aria-label="trackDetailsButton"
class="column" icon="bi-info-circle"
role="button" @click.prevent.exact="router.push({ name: 'library.tracks.detail', params: { id: track.id } })"
:aria-label="trackDetailsButton" >
@click.prevent.exact="router.push({ name: 'library.tracks.detail', params: { id: track.id } })" {{ trackDetailsButton }}
> </Button>
<i class="info icon track-modal list-icon" /> <hr>
<span class="track-modal list-item">{{ trackDetailsButton }}</span> <Button
</div>
</div>
<div class="ui divider" />
<div
v-for="obj in getReportableObjects({ track, album: track.album, artistCredit: track.artist_credit })" v-for="obj in getReportableObjects({ track, album: track.album, artistCredit: track.artist_credit })"
:key="obj.target.type + obj.target.id" :key="obj.target.type + obj.target.id"
class="row" full
ghost
icon="bi-share"
@click.stop.prevent="report(obj)" @click.stop.prevent="report(obj)"
> >
<div class="column"> {{ obj.label }}
<i class="share icon track-modal list-icon" /> </Button>
<span class="track-modal list-item">{{ obj.label }}</span> </Layout>
</div>
</div>
</div>
</div> </div>
</Modal> </Modal>
</template> </template>

View File

@ -8,6 +8,8 @@ import { ref, computed } from 'vue'
import axios from 'axios' import axios from 'axios'
import TrackMobileRow from '~/components/audio/track/MobileRow.vue' import TrackMobileRow from '~/components/audio/track/MobileRow.vue'
import TrackModal from '~/components/audio/track/Modal.vue'
import Pagination from '~/components/ui/Pagination.vue' import Pagination from '~/components/ui/Pagination.vue'
import TrackRow from '~/components/audio/track/Row.vue' import TrackRow from '~/components/audio/track/Row.vue'
import Input from '~/components/ui/Input.vue' import Input from '~/components/ui/Input.vue'
@ -143,6 +145,16 @@ const updatePage = (page: number) => {
emit('page-changed', page) emit('page-changed', page)
} }
} }
const showTrackModal = ref(false)
const modalTrack = ref<Track | null>(null)
const modalIndex = ref<number | null>(null)
function openTrackModal(track: Track, index: number) {
showTrackModal.value = true
modalTrack.value = track
modalIndex.value = index
}
</script> </script>
<template> <template>
@ -251,13 +263,22 @@ const updatePage = (page: number) => {
:key="track.id" :key="track.id"
:track="track" :track="track"
:index="index" :index="index"
:tracks="allTracks"
:show-position="showPosition" :show-position="showPosition"
:show-art="showArt" :show-art="showArt"
:show-duration="showDuration" :show-duration="showDuration"
:is-artist="isArtist" :is-artist="isArtist"
:is-album="isAlbum" :is-album="isAlbum"
:is-podcast="isPodcast" :is-podcast="isPodcast"
@open-modal="openTrackModal"
/>
<!-- TODO: Replace with <PlayButton :dropdown-only="true"> after its display is fixed for mobile -->
<track-modal
v-if="modalTrack"
v-model:show="showTrackModal"
:track="modalTrack"
:index="modalIndex ?? 0"
:is-artist="isArtist"
:is-album="isAlbum"
/> />
<Pagination <Pagination
v-if="paginateResults" v-if="paginateResults"

View File

@ -176,7 +176,6 @@ const isOpen = useModal('artist-description').isOpen
class="description" class="description"
:content="{ ...object.description, text: object.description.text ?? undefined }" :content="{ ...object.description, text: object.description.text ?? undefined }"
:truncate-length="100" :truncate-length="100"
:more-link="false"
/> />
<Spacer grow /> <Spacer grow />
<Link <Link

View File

@ -4597,7 +4597,8 @@
"edit": "Edit", "edit": "Edit",
"embed": "Embed", "embed": "Embed",
"playAll": "Play all", "playAll": "Play all",
"stopEdit": "Stop Editing" "stopEdit": "Stop Editing",
"loadMoreTracks": "Load more tracks"
}, },
"empty": { "empty": {
"noTracks": "There are no tracks in this playlist yet" "noTracks": "There are no tracks in this playlist yet"

View File

@ -190,7 +190,6 @@ const isOpen = useModal('artist-description').isOpen
class="description" class="description"
:content="{ html: object?.summary.html || '' }" :content="{ html: object?.summary.html || '' }"
:truncate-length="100" :truncate-length="100"
:more-link="false"
/> />
<Spacer grow /> <Spacer grow />
<Link <Link

View File

@ -31,7 +31,7 @@ import useErrorHandler from '~/composables/useErrorHandler'
// } // }
interface Props { interface Props {
id: number id: string
defaultEdit?: boolean defaultEdit?: boolean
} }
@ -40,7 +40,7 @@ const props = withDefaults(defineProps<Props>(), {
}) })
const store = useStore() const store = useStore()
const isLoadingMoreTracks = ref(false)
const edit = ref(props.defaultEdit) const edit = ref(props.defaultEdit)
const playlist = ref<Playlist | null>(null) const playlist = ref<Playlist | null>(null)
const playlistTracks = ref<PlaylistTrack[]>([]) const playlistTracks = ref<PlaylistTrack[]>([])
@ -57,17 +57,24 @@ const labels = computed(() => ({
})) }))
const isLoading = ref(false) const isLoading = ref(false)
const nextPage = ref<string | null>(null) // Tracks the next page URL
const previousPage = ref<string | null>(null) // Tracks the previous page URL
const totalTracks = ref<number>(0) // Total number of tracks
const fetchData = async () => { const fetchData = async () => {
isLoading.value = true isLoading.value = true
try { try {
const [playlistResponse, tracksResponse] = await Promise.all([ const [playlistResponse, tracksResponse] = await Promise.all([
axios.get(`playlists/${props.id}/`), axios.get(`playlists/${props.id}/`),
axios.get(`playlists/${props.id}/tracks/`) axios.get(`playlists/${props.id}/tracks?page=1`)
]) ])
playlist.value = playlistResponse.data playlist.value = playlistResponse.data
fullPlaylistTracks.value = tracksResponse.data.results fullPlaylistTracks.value = tracksResponse.data.results
nextPage.value = tracksResponse.data.next
previousPage.value = tracksResponse.data.previous
totalTracks.value = tracksResponse.data.count
} catch (error) { } catch (error) {
useErrorHandler(error as Error) useErrorHandler(error as Error)
} }
@ -75,6 +82,25 @@ const fetchData = async () => {
isLoading.value = false isLoading.value = false
} }
const loadMoreTracks = async () => {
if (nextPage.value) {
isLoadingMoreTracks.value = true; // Set loading state for the button
try {
const response = await axios.get(nextPage.value);
// Append new tracks to the existing list
fullPlaylistTracks.value = [...fullPlaylistTracks.value, ...response.data.results];
// Update pagination metadata
nextPage.value = response.data.next;
} catch (error) {
useErrorHandler(error as Error)
} finally {
isLoadingMoreTracks.value = false; // Reset loading state
}
}
};
fetchData() fetchData()
const images = computed(() => { const images = computed(() => {
@ -113,17 +139,6 @@ const randomizedColors = computed(() => shuffleArray(bgcolors.value))
// return t('components.audio.ChannelCard.title', { date }) // return t('components.audio.ChannelCard.title', { date })
// }) // })
// TODO: Check if this function is still needed
// const deletePlaylist = async () => {
// try {
// await axios.delete(`playlists/${props.id}/`)
// store.dispatch('playlists/fetchOwn')
// return router.push({ path: '/library' })
// } catch (error) {
// useErrorHandler(error as Error)
// }
// }
// TODO: Implement shuffle // TODO: Implement shuffle
const shuffle = () => {} const shuffle = () => {}
</script> </script>
@ -178,7 +193,6 @@ const shuffle = () => {}
<RenderedDescription <RenderedDescription
:content="{ html: playlist.description }" :content="{ html: playlist.description }"
:truncate-length="100" :truncate-length="100"
:show-more="true"
/> />
<Layout <Layout
flex flex
@ -240,6 +254,14 @@ const shuffle = () => {}
:tracks="tracks" :tracks="tracks"
:unique="false" :unique="false"
/> />
<Button
v-if="nextPage"
primary
:is-loading="isLoadingMoreTracks"
@click="loadMoreTracks"
>
{{ t('views.playlists.Detail.button.loadMoreTracks') }}
</Button>
</template> </template>
<Alert <Alert
v-else-if="!isLoading" v-else-if="!isLoading"