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:
2026-05-26 20:35:52 +07:00
parent c0be5d76d6
commit a109f5bab1
15 changed files with 433 additions and 143 deletions
+3
View File
@@ -46,6 +46,7 @@ import { usePlayerStore } from './stores/playerStore.js';
import AppHeader from './components/AppHeader.vue';
import Sidebar from './components/Sidebar.vue';
import PlayerBar from './components/PlayerBar.vue';
import { useKeyboard } from './composables/useKeyboard.js';
const route = useRoute();
const authStore = useAuthStore();
@@ -56,6 +57,8 @@ const isAuthPage = computed(() => route.meta?.guest === true)
const hasPlayer = computed(() => !!playerStore.currentTrack);
useKeyboard();
function toastClass(type) {
return (
{
+101
View File
@@ -0,0 +1,101 @@
<template>
<div v-if="show" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @click.self="$emit('close')">
<div class="bg-white rounded-2xl border-4 border-black p-6 w-full max-w-md mx-4 shadow-[8px_8px_0px_0px_#000]">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-black text-black">CREATE PLAYLIST</h2>
<button @click="$emit('close')" class="text-gray-500 hover:text-black text-2xl leading-none">&times;</button>
</div>
<p v-if="error" class="text-red-500 text-sm mb-4 font-medium">{{ error }}</p>
<div class="space-y-4">
<div>
<label class="block text-xs font-bold text-gray-600 mb-1 tracking-wider">NAME</label>
<input
v-model="name"
type="text"
placeholder="My awesome playlist"
class="w-full border-2 border-black rounded-lg px-4 py-2 focus:outline-none text-black"
/>
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1 tracking-wider">DESCRIPTION</label>
<textarea
v-model="description"
placeholder="Optional description"
rows="2"
class="w-full border-2 border-black rounded-lg px-4 py-2 focus:outline-none resize-none text-black"
/>
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1 tracking-wider">COVER IMAGE</label>
<input
type="file"
accept="image/*"
@change="onCoverChange"
class="w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:border-2 file:border-black file:rounded-lg file:font-bold file:text-sm file:bg-noctune-yellow file:cursor-pointer"
/>
</div>
<button
@click="handleCreate"
:disabled="creating"
class="w-full bg-noctune-teal border-2 border-black rounded-lg py-3 font-bold text-black hover:bg-teal-600 transition-colors disabled:opacity-50 cursor-pointer"
>
<span v-if="creating" class="inline-block w-4 h-4 border-2 border-black border-t-transparent rounded-full animate-spin mr-2" />
{{ creating ? 'Creating...' : 'CREATE PLAYLIST' }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import api from '../lib/api.js'
const props = defineProps({ show: Boolean })
const emit = defineEmits(['close', 'created'])
const name = ref('')
const description = ref('')
const coverFile = ref(null)
const creating = ref(false)
const error = ref('')
function onCoverChange(e) {
coverFile.value = e.target.files[0] || null
}
async function handleCreate() {
error.value = ''
if (!name.value?.trim()) {
error.value = 'Playlist name is required'
return
}
creating.value = true
try {
const formData = new FormData()
formData.append('name', name.value.trim())
if (description.value?.trim()) formData.append('description', description.value.trim())
if (coverFile.value) formData.append('playlistCover', coverFile.value)
const { data } = await api.post('/playlists', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
if (data.success) {
emit('created', data.data)
name.value = ''
description.value = ''
coverFile.value = null
}
} catch (err) {
error.value = err.response?.data?.message || 'Failed to create playlist'
} finally {
creating.value = false
}
}
</script>
+20 -1
View File
@@ -36,7 +36,13 @@
</div>
<div class="flex items-center gap-3 w-full max-w-lg">
<span class="text-xs text-gray-400 w-10 text-right">{{ playerStore.formattedProgress }}</span>
<input type="range" :value="playerStore.progress" @input="onSeek" min="0" :max="playerStore.duration || 1" step="0.1" class="flex-1 h-1 bg-gray-600 rounded-full appearance-none cursor-pointer range-player" />
<div class="relative flex-1" @mousemove="onSeekHover" @mouseleave="seekTooltipVal = null">
<div v-if="seekTooltipVal !== null" class="absolute -top-6 left-1/2 -translate-x-1/2 bg-black text-white text-xs px-2 py-1 rounded whitespace-nowrap z-10"
:style="{ left: `${(seekTooltipVal / (playerStore.duration || 1)) * 100}%` }">
{{ formatSeekTime(seekTooltipVal) }}
</div>
<input type="range" :value="playerStore.progress" @input="onSeek" min="0" :max="playerStore.duration || 1" step="0.1" class="w-full h-1 bg-gray-600 rounded-full appearance-none cursor-pointer range-player" />
</div>
<span class="text-xs text-gray-400 w-10">{{ playerStore.formattedDuration }}</span>
</div>
</div>
@@ -68,6 +74,19 @@ const authStore = useAuthStore()
const { requireAuth } = useAuthGate()
const isLiked = ref(false)
const seekTooltipVal = ref(null)
function formatSeekTime(seconds) {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
function onSeekHover(e) {
const rect = e.currentTarget.getBoundingClientRect()
const pct = (e.clientX - rect.left) / rect.width
seekTooltipVal.value = pct * playerStore.duration
}
function onTrackEnd() {
const next = queueStore.nextTrack()
+10 -10
View File
@@ -62,6 +62,8 @@
</div>
<div class="flex-1"></div>
<CreatePlaylistModal :show="showCreateModal" @close="showCreateModal = false" @created="onPlaylistCreated" />
</aside>
</template>
@@ -71,12 +73,14 @@ import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/authStore.js'
import api from '../lib/api.js'
import { useAuthGate } from '../composables/useAuthGate.js'
import CreatePlaylistModal from './CreatePlaylistModal.vue'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const playlists = ref([])
const showCreateModal = ref(false)
const { requireAuth } = useAuthGate()
@@ -94,18 +98,14 @@ async function fetchPlaylists() {
function openCreatePlaylist() {
requireAuth({
redirectTo: '/login',
onAuthenticated: async () => {
const name = prompt('Playlist name:')
if (!name?.trim()) return
try {
const { data } = await api.post('/playlists', { name: name.trim() })
if (data.success) {
playlists.value.push(data.data)
}
} catch {}
}
onAuthenticated: () => { showCreateModal.value = true }
})
}
function onPlaylistCreated(playlist) {
showCreateModal.value = false
playlists.value.unshift(playlist)
}
onMounted(fetchPlaylists)
</script>
+34
View File
@@ -0,0 +1,34 @@
import { onMounted, onUnmounted } from 'vue'
import { usePlayerStore } from '../stores/playerStore.js'
import { useQueueStore } from '../stores/queueStore.js'
export function useKeyboard() {
const playerStore = usePlayerStore()
const queueStore = useQueueStore()
function handler(e) {
if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return
switch (e.code) {
case 'Space':
e.preventDefault()
playerStore.togglePlay()
break
case 'ArrowLeft':
playerStore.seek(Math.max(0, playerStore.progress - 5))
break
case 'ArrowRight':
playerStore.seek(Math.min(playerStore.duration, playerStore.progress + 5))
break
case 'ArrowUp':
playerStore.setVolume(Math.min(100, playerStore.volume + 5))
break
case 'ArrowDown':
playerStore.setVolume(Math.max(0, playerStore.volume - 5))
break
}
}
onMounted(() => window.addEventListener('keydown', handler))
onUnmounted(() => window.removeEventListener('keydown', handler))
}
+2 -1
View File
@@ -6,6 +6,7 @@ import RegisterPage from '../views/RegisterPage.vue'
import LandingPage from '../views/LandingPage.vue'
import LibraryPage from '../views/LibraryPage.vue'
import PlaylistPage from '../views/PlaylistPage.vue'
import PlaylistDetail from '../views/PlaylistDetail.vue'
import ProfilePage from '../views/ProfilePage.vue'
import AdminPage from '../views/AdminPage.vue'
@@ -16,7 +17,7 @@ const routes = [
{ path: '/home', name: 'Home', component: LandingPage },
{ path: '/library', name: 'Library', component: LibraryPage },
{ path: '/playlist', name: 'Playlist', component: PlaylistPage },
{ path: '/playlist/:id', name: 'PlaylistDetail', component: PlaylistPage },
{ path: '/playlist/:id', name: 'PlaylistDetail', component: PlaylistDetail },
{ path: '/profile', name: 'Profile', component: ProfilePage },
{ path: '/admin/tracks', name: 'AdminTracks', component: AdminPage, meta: { admin: true } },
]
+19 -4
View File
@@ -22,17 +22,32 @@
TRENDING NOW
<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="loading" class="text-center py-12 text-gray-500">Loading tracks...</div>
<div v-else class="grid grid-cols-4 gap-4">
<div v-if="loading" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div v-for="i in 4" :key="i" class="animate-pulse rounded-2xl border-2 border-black overflow-hidden">
<div class="aspect-square bg-gray-300" />
<div class="p-3 space-y-2">
<div class="h-3 bg-gray-300 rounded w-3/4" />
<div class="h-2 bg-gray-300 rounded w-1/2" />
</div>
</div>
</div>
<div v-else-if="tracks.length === 0" class="text-center py-20 bg-white rounded-2xl border-2 border-black">
<svg class="w-24 h-24 mx-auto text-gray-300 mb-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55C7.79 13 6 14.79 6 17s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>
<p class="text-gray-500 mb-2 text-lg">No tracks yet</p>
<p class="text-gray-400 text-sm mb-4">Be the first to upload music!</p>
<router-link to="/admin/tracks" class="inline-block bg-noctune-orange border-2 border-black px-6 py-3 font-bold rounded-lg hover:bg-orange-500 transition-colors">UPLOAD TRACK</router-link>
</div>
<div v-else class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div v-for="track in tracks" :key="track._id"
@click="playTrack(track)"
class="bg-white rounded-2xl border-2 border-black overflow-hidden hover:scale-[1.02] transition-transform cursor-pointer group"
class="bg-white rounded-2xl overflow-hidden hover:scale-[1.02] transition-transform cursor-pointer group"
:class="isCurrentTrack(track) ? 'border-4 border-noctune-teal' : 'border-2 border-black'"
>
<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 inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
<div class="w-12 h-12 rounded-full flex items-center justify-center border-2 border-black opacity-0 group-hover:opacity-100 transition-opacity"
:class="isCurrentTrack(track) ? 'bg-white' : 'bg-noctune-yellow'">
:class="isCurrentTrack(track) && playerStore.isPlaying ? 'bg-white' : 'bg-noctune-yellow'">
<svg v-if="isCurrentTrack(track) && playerStore.isPlaying" class="w-6 h-6 text-black" fill="currentColor" viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
<svg v-else class="w-6 h-6 ml-1 text-black" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</div>
+11 -2
View File
@@ -19,7 +19,7 @@
<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) ? 'bg-white' : 'bg-noctune-yellow'">
: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>
@@ -37,7 +37,15 @@
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="text-center py-8 text-gray-500">Loading...</div>
<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>
@@ -49,6 +57,7 @@
<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">
+161
View File
@@ -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>
+60 -113
View File
@@ -1,143 +1,90 @@
<template>
<div class="p-8">
<div v-if="loading" class="text-center py-20 text-gray-500">Loading playlist...</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 items-center justify-between mb-6">
<h1 class="text-3xl font-black text-black">YOUR PLAYLISTS</h1>
<button @click="openCreate"
class="bg-noctune-teal border-2 border-black px-4 py-2 font-bold flex items-center gap-2 rounded-lg hover:bg-teal-600 transition-colors cursor-pointer">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path d="M12 4v16m8-8H4"/></svg>
NEW PLAYLIST
</button>
</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">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
PLAY
</button>
<div v-if="loading" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div v-for="i in 4" :key="i" class="animate-pulse bg-gray-200 rounded-2xl border-2 border-black h-64" />
</div>
<div v-else-if="playlists.length === 0" class="text-center py-20">
<svg class="w-24 h-24 mx-auto text-gray-300 mb-4" fill="currentColor" viewBox="0 0 24 24"><path d="M3 10h11v2H3v-2zm0-4h11v2H3V6zm0 8h7v2H3v-2zm13-1v8l6-4-6-4z"/></svg>
<p class="text-gray-500 mb-2 text-lg">No playlists yet</p>
<p class="text-gray-400 text-sm mb-4">Create your first playlist to start organizing your music</p>
<button @click="openCreate"
class="bg-noctune-yellow border-2 border-black px-6 py-3 font-bold rounded-lg hover:bg-yellow-400 transition-colors cursor-pointer">
CREATE YOUR FIRST PLAYLIST
</button>
</div>
<div v-else class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div v-for="pl in playlists" :key="pl._id"
@click="router.push(`/playlist/${pl._id}`)"
class="bg-white rounded-2xl border-2 border-black overflow-hidden hover:scale-[1.02] transition-transform cursor-pointer group"
>
<div class="aspect-square bg-gray-300 relative overflow-hidden">
<img v-if="pl.coverUrl" :src="formatUrl(pl.coverUrl)" class="w-full h-full object-cover" />
<div v-else class="w-full h-full flex items-center justify-center bg-gradient-to-br from-noctune-teal/20 to-noctune-yellow/20">
<svg class="w-20 h-16 text-gray-400" fill="currentColor" viewBox="0 0 24 24"><path d="M3 10h11v2H3v-2zm0-4h11v2H3V6zm0 8h7v2H3v-2zm13-1v8l6-4-6-4z"/></svg>
</div>
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors" />
</div>
<div class="p-3">
<p class="font-bold text-sm truncate text-black">{{ pl.name }}</p>
<p class="text-xs text-gray-500">{{ pl.tracks?.length || 0 }} songs</p>
</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"
>
<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">
<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>
<CreatePlaylistModal :show="showCreateModal" @close="showCreateModal = false" @created="onPlaylistCreated" />
</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 { useRouter } from 'vue-router'
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 CreatePlaylistModal from '../components/CreatePlaylistModal.vue'
const route = useRoute()
const playerStore = usePlayerStore()
const queueStore = useQueueStore()
const router = useRouter()
const authStore = useAuthStore()
const { requireAuth } = useAuthGate()
const playlist = ref(null)
const playlists = ref([])
const loading = ref(true)
const showCreateModal = ref(false)
function isCurrentTrack(track) {
return playerStore.currentTrack?._id === track._id
function openCreate() {
requireAuth({
redirectTo: '/login',
onAuthenticated: () => { showCreateModal.value = true }
})
}
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)
}
async function toggleLike(track) {
await authStore.toggleLike(track._id)
function onPlaylistCreated(playlist) {
showCreateModal.value = false
playlists.value.unshift(playlist)
}
onMounted(async () => {
const id = route.params.id
if (!authStore.isLoggedIn) {
loading.value = false
return
}
try {
if (id) {
const { data } = await api.get(`/playlists/${id}`)
if (data.success) playlist.value = data.data
} else {
const { data } = await api.get('/playlists')
if (data.success && data.data.length) {
const { data: detail } = await api.get(`/playlists/${data.data[0]._id}`)
if (detail.success) playlist.value = detail.data
} else {
playlist.value = { name: 'My Playlist', tracks: [] }
}
}
const { data } = await api.get('/playlists')
if (data.success) playlists.value = data.data
} catch {
playlist.value = null
// ignore
} finally {
loading.value = false
}