Files
frontend/app/pages/edit.vue

226 lines
8.4 KiB
Vue

<script setup lang="ts">
import { Search } from 'lucide-vue-next'
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 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('')
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) });
},
});
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 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()))
)
})
const selectTrack = (trackId: number) => {
mappedTracks.value.forEach(track => {
track.selected = track.id === trackId
})
}
const { elementRef: tracksRef } = useDroppable({
data: {
source: mappedTracks.value,
},
events: {
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>
<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">
<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>
<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>