Implement reordering with optimistic updates
This commit is contained in:
@ -7,56 +7,102 @@ import { SidebarTrigger } from '@/components/ui/sidebar'
|
||||
import MusicCard from '~/components/internal/musiccard/MusicCard.vue'
|
||||
import { DnDOperations, useDroppable } from '@vue-dnd-kit/core'
|
||||
import Draggable from '~/components/action/Draggable.vue'
|
||||
import { useCurrentPlaylistStore } from '~/stores/use-current-playlist-store'
|
||||
import { bulkReorder, getGetPlaylistTracksQueryKey } from '~/composeables/api/track-controller/track-controller'
|
||||
import { useGetPlaylistTracks } from '~/composeables/api/track-controller/track-controller'
|
||||
import { useMutation, useQueryClient } from '@tanstack/vue-query'
|
||||
|
||||
const searchValue = ref('')
|
||||
|
||||
// Mock data
|
||||
const tracks = ref([
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const currentPlaylistStore = useCurrentPlaylistStore();
|
||||
|
||||
const { data: playlistTracks, isLoading, error, refetch } = useGetPlaylistTracks(
|
||||
computed(() => currentPlaylistStore.id),
|
||||
{
|
||||
id: 1,
|
||||
title: "Best of Chobits OST - Beyond",
|
||||
author: "ビヨンド",
|
||||
authorLabel: "Author",
|
||||
badges: ["mp3", "jpop", "anime"],
|
||||
imageUrl: "https://github.com/yavuzceliker/sample-images/blob/main/docs/image-1.jpg?raw=true",
|
||||
date: "about 17 years ago",
|
||||
selected: true
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Summer Vibessssssssssssssssssssssssssssssssssssfawefjioawefjeaiofjeaoifjeaofejiaofejaiojfoaiss",
|
||||
author: "Various Artists",
|
||||
authorLabel: "Artists",
|
||||
badges: ["mp3", "summer", "mix"],
|
||||
imageUrl: "https://github.com/yavuzceliker/sample-images/blob/main/docs/image-2.jpg?raw=true",
|
||||
date: "about 1 hour ago",
|
||||
selected: false
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Unknown Track",
|
||||
author: "Unknown Artist",
|
||||
authorLabel: "Author",
|
||||
badges: ["wav"],
|
||||
imageUrl: "https://github.com/yavuzceliker/sample-images/blob/main/docs/image-3.jpg?raw=true",
|
||||
selected: false
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Single Track",
|
||||
author: "Solo Artist",
|
||||
authorLabel: "Author",
|
||||
badges: [],
|
||||
imageUrl: "https://github.com/yavuzceliker/sample-images/blob/main/docs/image-5.jpg?raw=true",
|
||||
date: "recently added",
|
||||
selected: false
|
||||
query: {
|
||||
enabled: computed(() => currentPlaylistStore.id !== -1)
|
||||
}
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const { mutate: reorderTracks } = useMutation({
|
||||
mutationFn: ({ playlistId, trackIds }: { playlistId: number; trackIds: number[] }) =>
|
||||
bulkReorder(playlistId, { trackIds }),
|
||||
onMutate: async ({ playlistId, trackIds }) => {
|
||||
await queryClient.cancelQueries({ queryKey: getGetPlaylistTracksQueryKey(playlistId) });
|
||||
|
||||
const previousTracks = queryClient.getQueryData(getGetPlaylistTracksQueryKey(playlistId));
|
||||
|
||||
queryClient.setQueryData(getGetPlaylistTracksQueryKey(playlistId), (old: any) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
data: trackIds.map(id => old.data.find((t: any) => t.trackId === id || t.id === id)),
|
||||
};
|
||||
});
|
||||
|
||||
return { previousTracks };
|
||||
},
|
||||
onError: (err, { playlistId }, context) => {
|
||||
if (context?.previousTracks) {
|
||||
queryClient.setQueryData(getGetPlaylistTracksQueryKey(playlistId), context.previousTracks);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: getGetPlaylistTracksQueryKey(currentPlaylistStore.id) });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const trackCount = computed(() => playlistTracks.value?.data.length || 0)
|
||||
|
||||
const mappedTracks = computed(() => {
|
||||
if (!playlistTracks.value) return []
|
||||
const tracks = playlistTracks.value.data
|
||||
|
||||
return tracks.map((track: any, index: number) => ({
|
||||
id: track.trackId || track.id,
|
||||
title: track.track?.title || track.title || 'Unknown Track',
|
||||
author: track.track?.artist?.name || track.artist || 'Unknown Artist',
|
||||
authorLabel: "Artist",
|
||||
badges: track.track?.format ? [track.track.format] : ['mp3'],
|
||||
imageUrl: track.track?.coverUrl || track.coverUrl || getDefaultImage(index),
|
||||
date: formatDate(track.addedDate || track.createdAt),
|
||||
selected: false
|
||||
}))
|
||||
})
|
||||
|
||||
const getDefaultImage = (index: number) => {
|
||||
const defaultImages = [
|
||||
"https://github.com/yavuzceliker/sample-images/blob/main/docs/image-1.jpg?raw=true",
|
||||
"https://github.com/yavuzceliker/sample-images/blob/main/docs/image-2.jpg?raw=true",
|
||||
"https://github.com/yavuzceliker/sample-images/blob/main/docs/image-3.jpg?raw=true",
|
||||
"https://github.com/yavuzceliker/sample-images/blob/main/docs/image-5.jpg?raw=true"
|
||||
]
|
||||
return defaultImages[index % defaultImages.length]
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
if (!dateString) return 'recently added'
|
||||
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffTime = Math.abs(now.getTime() - date.getTime())
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffDays === 1) return 'yesterday'
|
||||
if (diffDays < 7) return `${diffDays} days ago`
|
||||
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`
|
||||
if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`
|
||||
return `${Math.floor(diffDays / 365)} years ago`
|
||||
}
|
||||
|
||||
const filteredTracks = computed(() => {
|
||||
if (!searchValue.value) return tracks.value
|
||||
if (!searchValue.value) return mappedTracks.value
|
||||
|
||||
return tracks.value.filter(track =>
|
||||
return mappedTracks.value.filter(track =>
|
||||
track.title.toLowerCase().includes(searchValue.value.toLowerCase()) ||
|
||||
track.author.toLowerCase().includes(searchValue.value.toLowerCase()) ||
|
||||
track.badges.some(badge => badge.toLowerCase().includes(searchValue.value.toLowerCase()))
|
||||
@ -64,19 +110,36 @@ const filteredTracks = computed(() => {
|
||||
})
|
||||
|
||||
const selectTrack = (trackId: number) => {
|
||||
tracks.value.forEach(track => {
|
||||
mappedTracks.value.forEach(track => {
|
||||
track.selected = track.id === trackId
|
||||
})
|
||||
}
|
||||
|
||||
const { elementRef: tracksRef } = useDroppable({
|
||||
data: {
|
||||
source: tracks.value,
|
||||
source: mappedTracks.value,
|
||||
},
|
||||
events: {
|
||||
onDrop: DnDOperations.applyTransfer,
|
||||
onDrop: (store, payload) => {
|
||||
DnDOperations.applyTransfer(store);
|
||||
if (currentPlaylistStore.id === -1) return;
|
||||
if (mappedTracks.value.length !== trackCount.value) return;
|
||||
if (mappedTracks.value.some(t => !t)) return;
|
||||
|
||||
const trackIds = mappedTracks.value?.map(t => t.id);
|
||||
reorderTracks({
|
||||
playlistId: currentPlaylistStore.id,
|
||||
trackIds,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
watch(() => currentPlaylistStore.id, (newId) => {
|
||||
if (newId !== -1) {
|
||||
refetch()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -99,15 +162,30 @@ const { elementRef: tracksRef } = useDroppable({
|
||||
<InputWithIcon v-model="searchValue" :icon="Search" placeholder="Search..." type="search" icon-size="5"
|
||||
id="user-search" class="w-full" />
|
||||
|
||||
<div ref="tracksRef" class="space-y-2">
|
||||
<TransitionGroup name="list" ref="tracksRef">
|
||||
<Draggable v-for="(track, index) in filteredTracks" :key="track.id" :index="index"
|
||||
:source="tracks">
|
||||
<MusicCard :key="track.id" :title="track.title" :author="track.author"
|
||||
:authorLabel="track.authorLabel" :badges="track.badges" :imageUrl="track.imageUrl"
|
||||
:date="track.date" :selected="track.selected" @click="selectTrack(track.id)" />
|
||||
</Draggable>
|
||||
</TransitionGroup>
|
||||
<div ref="tracksRef">
|
||||
<div v-if="isLoading" class="flex justify-center items-center py-8">
|
||||
<p>Loading tracks...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="flex justify-center items-center py-8">
|
||||
<p class="text-red-500">Error loading tracks: {{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!filteredTracks.length" class="flex justify-center items-center py-8">
|
||||
<p v-if="searchValue">No tracks found matching "{{ searchValue }}"</p>
|
||||
<p v-else>No tracks in this playlist</p>
|
||||
</div>
|
||||
|
||||
<div v-else ref="tracksRef" class="space-y-2">
|
||||
<TransitionGroup name="list" ref="tracksRef">
|
||||
<Draggable v-for="(track, index) in filteredTracks" :key="track.id" :index="index"
|
||||
:source="mappedTracks">
|
||||
<MusicCard :key="track.id" :title="track.title" :author="track.author"
|
||||
:authorLabel="track.authorLabel" :badges="track.badges" :imageUrl="track.imageUrl"
|
||||
:date="track.date" :selected="track.selected" @click="selectTrack(track.id)" />
|
||||
</Draggable>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #sidebar>
|
||||
|
||||
Reference in New Issue
Block a user