feat: add CreatePlaylistModal component for creating new playlists
feat: implement useKeyboard composable for keyboard controls in the player feat: create PlaylistDetail view to display playlist information and tracks
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div class="p-8">
|
||||
<div v-if="loading" class="space-y-6 animate-pulse">
|
||||
<div class="flex items-start gap-8 mb-8">
|
||||
<div class="w-56 h-56 rounded-2xl bg-gray-300 border-4 border-black" />
|
||||
<div class="flex-1 space-y-4">
|
||||
<div class="h-6 bg-gray-200 rounded w-24" />
|
||||
<div class="h-10 bg-gray-200 rounded w-64" />
|
||||
<div class="h-4 bg-gray-200 rounded w-32" />
|
||||
<div class="h-10 bg-gray-200 rounded w-28" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div v-for="i in 5" :key="i" class="flex items-center gap-4">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else-if="playlist">
|
||||
<div class="flex items-start gap-8 mb-8">
|
||||
<div class="w-56 h-56 rounded-2xl overflow-hidden border-4 border-black flex-shrink-0 bg-black">
|
||||
<img v-if="playlist.coverUrl" :src="formatUrl(playlist.coverUrl)" class="w-full h-full object-cover" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<svg class="w-40 h-24 text-white/60" viewBox="0 0 100 50">
|
||||
<rect x="5" y="20" width="4" height="15" fill="currentColor"/>
|
||||
<rect x="12" y="10" width="4" height="30" fill="currentColor"/>
|
||||
<rect x="19" y="15" width="4" height="20" fill="currentColor"/>
|
||||
<rect x="26" y="5" width="4" height="40" fill="currentColor"/>
|
||||
<rect x="33" y="12" width="4" height="26" fill="currentColor"/>
|
||||
<rect x="40" y="8" width="4" height="34" fill="currentColor"/>
|
||||
<rect x="47" y="15" width="4" height="20" fill="currentColor"/>
|
||||
<rect x="54" y="18" width="4" height="14" fill="currentColor"/>
|
||||
<rect x="61" y="12" width="4" height="26" fill="currentColor"/>
|
||||
<rect x="68" y="8" width="4" height="34" fill="currentColor"/>
|
||||
<rect x="75" y="15" width="4" height="20" fill="currentColor"/>
|
||||
<rect x="82" y="20" width="4" height="15" fill="currentColor"/>
|
||||
<rect x="89" y="22" width="4" height="10" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<span class="inline-block bg-noctune-teal text-black text-xs font-bold px-3 py-1 rounded mb-3 border border-black">PLAYLIST</span>
|
||||
<h1 class="text-4xl font-black text-black mb-4 leading-tight">{{ playlist.name }}</h1>
|
||||
<p v-if="playlist.description" class="text-gray-600 mb-4">{{ playlist.description }}</p>
|
||||
<div class="flex items-center gap-3 text-gray-600 mb-6">
|
||||
<span class="font-medium">{{ playlist.tracks?.length || 0 }} songs</span>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<button @click="playAll" :disabled="!playlist.tracks?.length" class="bg-noctune-teal border-2 border-black px-6 py-2 font-bold flex items-center gap-2 rounded-lg hover:bg-teal-600 transition-colors disabled:opacity-50 cursor-pointer">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
PLAY
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div 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="w-12 text-center">#</span>
|
||||
<span class="flex-1 text-center">TITLE</span>
|
||||
<span class="w-48 text-center">ARTIST</span>
|
||||
<span class="w-12 text-center"></span>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-200">
|
||||
<div v-for="(track, index) in playlist.tracks" :key="track._id"
|
||||
@click="playTrack(index)"
|
||||
class="px-6 py-3 flex items-center hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
:class="isCurrentTrack(track) ? 'bg-noctune-teal/10' : ''"
|
||||
>
|
||||
<span class="w-12 text-center">
|
||||
<svg v-if="isCurrentTrack(track) && playerStore.isPlaying" class="w-4 h-4 text-noctune-teal mx-auto" fill="currentColor" viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
||||
<span v-else class="text-gray-500">{{ index + 1 }}</span>
|
||||
</span>
|
||||
<div class="flex-1 flex items-center gap-4">
|
||||
<div class="w-12 h-12 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-48 text-center text-gray-600 text-sm truncate">{{ track.artist }}</span>
|
||||
<button @click.stop="toggleLike(track)" class="w-12 flex justify-center transition-colors cursor-pointer">
|
||||
<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>
|
||||
</div>
|
||||
<div v-if="!playlist.tracks?.length" class="p-8 text-center text-gray-500">No tracks in this playlist yet</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="text-center py-20 text-gray-500">Playlist not found</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
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'
|
||||
|
||||
const route = useRoute()
|
||||
const playerStore = usePlayerStore()
|
||||
const queueStore = useQueueStore()
|
||||
const authStore = useAuthStore()
|
||||
const { requireAuth } = useAuthGate()
|
||||
|
||||
const playlist = ref(null)
|
||||
const loading = ref(true)
|
||||
|
||||
function isCurrentTrack(track) {
|
||||
return playerStore.currentTrack?._id === track._id
|
||||
}
|
||||
|
||||
function isLiked(track) {
|
||||
return authStore.likedTrackIds?.has(track._id)
|
||||
}
|
||||
|
||||
function playTrack(index) {
|
||||
if (!playlist.value?.tracks?.length) return
|
||||
const track = playlist.value.tracks[index]
|
||||
if (isCurrentTrack(track)) {
|
||||
playerStore.togglePlay()
|
||||
return
|
||||
}
|
||||
const tracks = playlist.value.tracks
|
||||
queueStore.setQueue(tracks, index)
|
||||
playerStore.setTrack(track)
|
||||
playerStore.play()
|
||||
}
|
||||
|
||||
function playAll() {
|
||||
if (playlist.value?.tracks?.length) playTrack(0)
|
||||
}
|
||||
|
||||
function toggleLike(track) {
|
||||
requireAuth({
|
||||
redirectTo: '/login',
|
||||
onAuthenticated: () => authStore.toggleLike(track._id)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const id = route.params.id
|
||||
try {
|
||||
if (id) {
|
||||
const { data } = await api.get(`/playlists/${id}`)
|
||||
if (data.success) playlist.value = data.data
|
||||
}
|
||||
} catch {
|
||||
playlist.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user