217 lines
7.8 KiB
Vue
217 lines
7.8 KiB
Vue
<script setup lang="ts">
|
|
import { Button } from '@/components/ui/button'
|
|
import { InputWithIcon } from '@/components/ui/input'
|
|
import { Outline } from '@/components/ui/outline'
|
|
import { SidebarTrigger } from '@/components/ui/sidebar'
|
|
import { useMutation, useQueryClient } from '@tanstack/vue-query'
|
|
import { Search } from 'lucide-vue-next'
|
|
import { VueDraggableNext } from 'vue-draggable-next'
|
|
import MusicCard from '~/components/internal/musiccard/MusicCard.vue'
|
|
import type { TrackResponse } from '~/composeables/api/models'
|
|
import { bulkReorder, getGetPlaylistTracksQueryKey, useGetPlaylistTracks } from '~/composeables/api/track-controller/track-controller'
|
|
import { useCurrentPlaylistStore } from '~/stores/use-current-playlist-store'
|
|
|
|
const searchValue = ref('')
|
|
|
|
|
|
const queryClient = useQueryClient();
|
|
const currentPlaylistStore = useCurrentPlaylistStore();
|
|
|
|
const { data: playlistTracks, isLoading, error, refetch } = useGetPlaylistTracks(
|
|
computed(() => currentPlaylistStore.id),
|
|
{
|
|
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) => {
|
|
console.log(err)
|
|
if (context?.previousTracks) {
|
|
queryClient.setQueryData(getGetPlaylistTracksQueryKey(playlistId), context.previousTracks);
|
|
}
|
|
},
|
|
onSettled: () => {
|
|
queryClient.invalidateQueries({ queryKey: getGetPlaylistTracksQueryKey(currentPlaylistStore.id) });
|
|
},
|
|
});
|
|
|
|
interface MappedTrack {
|
|
id: number;
|
|
title: string;
|
|
author: string;
|
|
authorLabel: string;
|
|
badges: string[];
|
|
imageUrl: string | undefined;
|
|
date: string;
|
|
}
|
|
|
|
const mappedTracks = ref<MappedTrack[]>([])
|
|
|
|
watchEffect(() => {
|
|
if (!playlistTracks.value) return []
|
|
const tracks = playlistTracks.value.data
|
|
|
|
const result = tracks
|
|
.map((track: TrackResponse, index: number) => ({
|
|
id: track.trackId!,
|
|
title: track.title || 'Unknown Track',
|
|
author: track.artist || 'Unknown Artist',
|
|
authorLabel: "Artist",
|
|
badges: ['mp3'], // TODO: badges
|
|
imageUrl: getDefaultImage(index), // TODO: imageUrl
|
|
date: formatDate(''), // TODO: createdAt
|
|
}));
|
|
mappedTracks.value = result;
|
|
})
|
|
|
|
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 mappedTracks.value
|
|
|
|
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()))
|
|
)
|
|
});
|
|
|
|
function onTrackOrderChange() {
|
|
const trackIds = mappedTracks.value?.map(t => t.id);
|
|
reorderTracks({
|
|
playlistId: currentPlaylistStore.id,
|
|
trackIds,
|
|
});
|
|
}
|
|
|
|
watch(() => currentPlaylistStore.id, (newId) => {
|
|
if (newId !== -1) {
|
|
refetch()
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="flex-1">
|
|
<NuxtLayout name="default">
|
|
<template #header>
|
|
<Outline side="bottom" padding="dense" class="w-full flex">
|
|
<div class="flex gap-8 w-full items-center">
|
|
<SidebarTrigger :size="5" />
|
|
<h2 class="scroll-m-20 text-3xl font-semibold tracking-tight transition-colors first:mt-0">
|
|
Tracks
|
|
</h2>
|
|
</div>
|
|
<Button>
|
|
Export
|
|
</Button>
|
|
</Outline>
|
|
</template>
|
|
<div class="flex flex-col w-full p-4 gap-4 h-full">
|
|
<InputWithIcon v-model="searchValue" :icon="Search" placeholder="Search..." type="search" icon-size="5"
|
|
id="user-search" class="w-full" />
|
|
|
|
<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">
|
|
<VueDraggableNext v-model="mappedTracks" group="tracks" @change="onTrackOrderChange"
|
|
item-key="id">
|
|
<div v-for="track in mappedTracks" :key="track.id">
|
|
<MusicCard :key="track.id" :title="track.title" :author="track.author"
|
|
:authorLabel="track.authorLabel" :badges="track.badges" :imageUrl="track.imageUrl"
|
|
:date="track.date" />
|
|
</div>
|
|
</VueDraggableNext>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<template #sidebar>
|
|
<Outline padding="none" class="h-full" side="left">
|
|
<Outline padding="dense" side="bottom">
|
|
<p class="leading-7 not-first:mt-6 font-semibold">
|
|
Metadata editor
|
|
</p>
|
|
</Outline>
|
|
</Outline>
|
|
</template>
|
|
</NuxtLayout>
|
|
</div>
|
|
</template>
|
|
|
|
<style>
|
|
.list-move {
|
|
transition: 0.3s cubic-bezier(0.165, 0.84, 0.44, 1);
|
|
}
|
|
|
|
.list-enter-active,
|
|
.list-leave-active {
|
|
transition: 0.3s cubic-bezier(0.165, 0.84, 0.44, 1);
|
|
}
|
|
|
|
.list-enter-from,
|
|
.list-leave-to {
|
|
opacity: 0;
|
|
transform: translateY(-20px);
|
|
}
|
|
|
|
.list-leave-active {
|
|
position: absolute;
|
|
pointer-events: none;
|
|
}
|
|
</style>
|