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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+9
View File
File diff suppressed because one or more lines are too long
-9
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Noctune - Music That Hits Different</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-Ulrd01cQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B-WaF5DI.css">
<script type="module" crossorigin src="/assets/index-C8FI4Eix.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-7ON3mYgU.css">
</head>
<body class="bg-noctune-cream">
<div id="app"></div>
+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
}