182 lines
8.2 KiB
Vue
182 lines
8.2 KiB
Vue
<template>
|
|
<div class="p-8">
|
|
<section class="mb-12">
|
|
<h2 class="text-3xl font-black text-black mb-6">Liked Tracks</h2>
|
|
<div v-if="loading" class="text-center py-12 text-gray-500">Loading...</div>
|
|
<div v-else-if="!authStore.isLoggedIn" class="text-center py-12">
|
|
<p class="text-gray-500 mb-4">Login to see your liked tracks</p>
|
|
<router-link to="/login" class="bg-noctune-yellow border-2 border-black px-6 py-3 font-bold inline-block hover:bg-yellow-400 transition-colors rounded-lg">Login</router-link>
|
|
</div>
|
|
<div v-else-if="likedTracks.length === 0" class="text-center py-12">
|
|
<p class="text-gray-500 mb-4">No liked tracks yet</p>
|
|
<router-link to="/home" class="bg-noctune-yellow border-2 border-black px-6 py-3 font-bold inline-block hover:bg-yellow-400 transition-colors rounded-lg">Discover Music</router-link>
|
|
</div>
|
|
<div v-else class="grid grid-cols-4 gap-6">
|
|
<div v-for="track in likedTracks" :key="track._id"
|
|
@click="playTrack(track, true)"
|
|
class="group cursor-pointer bg-white rounded-2xl border-2 border-black overflow-hidden hover:scale-[1.02] transition-transform"
|
|
>
|
|
<div class="aspect-square bg-gray-300 relative overflow-hidden">
|
|
<img v-if="track.coverUrl" :src="formatUrl(track.coverUrl)" class="w-full h-full object-cover" />
|
|
<div class="absolute bottom-2 right-2 w-10 h-10 rounded-full flex items-center justify-center border-2 border-black opacity-0 group-hover:opacity-100 transition-opacity"
|
|
:class="isCurrentTrack(track) && playerStore.isPlaying ? 'bg-white' : 'bg-noctune-yellow'">
|
|
<svg v-if="isCurrentTrack(track) && playerStore.isPlaying" class="w-5 h-5 text-black" fill="currentColor" viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
|
<svg v-else class="w-5 h-5 ml-0.5 text-black" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
|
</div>
|
|
</div>
|
|
<div class="p-3">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-sm font-bold text-black truncate">{{ track.title }}</p>
|
|
<p class="text-xs text-gray-500 truncate">{{ track.artist }}</p>
|
|
</div>
|
|
<button @click.stop="openAddToPlaylist(track)" class="ml-2 flex-shrink-0 text-gray-400 hover:text-noctune-teal transition-colors">
|
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<h2 class="text-2xl font-black text-black mb-6 flex items-center">
|
|
All Tracks
|
|
<svg class="w-5 h-5 ml-2" fill="currentColor" viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
|
|
</h2>
|
|
<div v-if="allLoading" class="space-y-3 p-4">
|
|
<div v-for="i in 5" :key="i" class="flex items-center gap-4 animate-pulse">
|
|
<div class="w-8 h-4 bg-gray-200 rounded" />
|
|
<div class="w-10 h-10 bg-gray-200 rounded-lg" />
|
|
<div class="h-3 bg-gray-200 rounded flex-1" />
|
|
<div class="h-3 bg-gray-200 rounded w-24" />
|
|
<div class="h-3 bg-gray-200 rounded w-20" />
|
|
</div>
|
|
</div>
|
|
<div v-else class="bg-white rounded-2xl border-4 border-black overflow-hidden">
|
|
<div class="bg-noctune-teal px-6 py-3 flex items-center text-black font-bold border-b-4 border-black">
|
|
<span class="flex-1">TITLE</span>
|
|
<span class="w-40 text-center">ARTIST</span>
|
|
<span class="w-32 text-center">ALBUM</span>
|
|
<span class="w-12 text-center"></span>
|
|
</div>
|
|
<div class="divide-y divide-gray-200">
|
|
<div v-for="(track, index) in allTracks" :key="track._id"
|
|
@click="playTrack(track, false, index)"
|
|
class="px-6 py-3 flex items-center hover:bg-gray-50 transition-colors cursor-pointer"
|
|
:class="isCurrentTrack(track) ? 'bg-noctune-teal/10' : ''"
|
|
>
|
|
<div class="flex-1 flex items-center gap-3">
|
|
<span class="text-sm text-gray-400 w-6">
|
|
<svg v-if="isCurrentTrack(track) && playerStore.isPlaying" class="w-4 h-4 text-noctune-teal inline" fill="currentColor" viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
|
<span v-else>{{ index + 1 }}</span>
|
|
</span>
|
|
<div class="w-10 h-10 rounded-lg flex-shrink-0 overflow-hidden bg-gray-300 border border-black">
|
|
<img v-if="track.coverUrl" :src="formatUrl(track.coverUrl)" class="w-full h-full object-cover" />
|
|
</div>
|
|
<span class="font-medium text-sm truncate" :class="isCurrentTrack(track) ? 'text-noctune-teal' : 'text-black'">{{ track.title }}</span>
|
|
</div>
|
|
<span class="w-40 text-center text-gray-600 text-sm truncate">{{ track.artist }}</span>
|
|
<span class="w-32 text-center text-gray-600 text-sm truncate">{{ track.album }}</span>
|
|
<button @click.stop="toggleLike(track)" class="w-10 flex justify-center transition-colors">
|
|
<svg class="w-5 h-5" :class="isLiked(track) ? 'fill-red-400 text-red-400' : 'text-gray-400 hover:text-red-400'" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/></svg>
|
|
</button>
|
|
<button @click.stop="openAddToPlaylist(track)" class="w-8 flex justify-center transition-colors text-gray-400 hover:text-noctune-teal">
|
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<AddToPlaylistModal :show="showAddModal" :track-id="trackToAdd?._id" @close="showAddModal = false" />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import { usePlayerStore } from '../stores/playerStore.js'
|
|
import { useQueueStore } from '../stores/queueStore.js'
|
|
import { useAuthStore } from '../stores/authStore.js'
|
|
import api from '../lib/api.js'
|
|
import { formatUrl } from '../lib/utils.js'
|
|
import { useAuthGate } from '../composables/useAuthGate.js'
|
|
import AddToPlaylistModal from '../components/AddToPlaylistModal.vue'
|
|
|
|
const playerStore = usePlayerStore()
|
|
const queueStore = useQueueStore()
|
|
const authStore = useAuthStore()
|
|
const { requireAuth } = useAuthGate()
|
|
|
|
const likedTracks = ref([])
|
|
const allTracks = ref([])
|
|
const loading = ref(true)
|
|
const allLoading = ref(true)
|
|
const trackToAdd = ref(null)
|
|
const showAddModal = ref(false)
|
|
|
|
function openAddToPlaylist(track) {
|
|
trackToAdd.value = track
|
|
requireAuth({
|
|
redirectTo: '/login',
|
|
onAuthenticated: () => { showAddModal.value = true }
|
|
})
|
|
}
|
|
|
|
function isCurrentTrack(track) {
|
|
return playerStore.currentTrack?._id === track._id
|
|
}
|
|
|
|
function isLiked(track) {
|
|
return authStore.likedTrackIds?.has(track._id)
|
|
}
|
|
|
|
function playTrack(track, fromLiked = false, idx) {
|
|
if (isCurrentTrack(track)) {
|
|
playerStore.togglePlay()
|
|
return
|
|
}
|
|
const source = fromLiked ? likedTracks.value : allTracks.value
|
|
const i = idx ?? source.findIndex((t) => t._id === track._id)
|
|
queueStore.setQueue(source, i)
|
|
playerStore.setTrack(track)
|
|
playerStore.play()
|
|
}
|
|
|
|
function toggleLike(track) {
|
|
const wasLiked = isLiked(track)
|
|
requireAuth({
|
|
redirectTo: '/login',
|
|
onAuthenticated: async () => {
|
|
await authStore.toggleLike(track._id)
|
|
if (wasLiked) {
|
|
likedTracks.value = likedTracks.value.filter((t) => t._id !== track._id)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
onMounted(async () => {
|
|
if (authStore.isLoggedIn) {
|
|
try {
|
|
const { data: liked } = await api.get('/tracks/liked')
|
|
if (liked.success) {
|
|
likedTracks.value = liked.data || []
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
loading.value = false
|
|
|
|
try {
|
|
const { data: all } = await api.get('/tracks')
|
|
if (all.success) allTracks.value = all.data
|
|
} catch {
|
|
// ignore
|
|
} finally {
|
|
allLoading.value = false
|
|
}
|
|
})
|
|
</script>
|