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"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Noctune - Music That Hits Different</title> <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"> <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> <script type="module" crossorigin src="/assets/index-C8FI4Eix.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B-WaF5DI.css"> <link rel="stylesheet" crossorigin href="/assets/index-7ON3mYgU.css">
</head> </head>
<body class="bg-noctune-cream"> <body class="bg-noctune-cream">
<div id="app"></div> <div id="app"></div>
+3
View File
@@ -46,6 +46,7 @@ import { usePlayerStore } from './stores/playerStore.js';
import AppHeader from './components/AppHeader.vue'; import AppHeader from './components/AppHeader.vue';
import Sidebar from './components/Sidebar.vue'; import Sidebar from './components/Sidebar.vue';
import PlayerBar from './components/PlayerBar.vue'; import PlayerBar from './components/PlayerBar.vue';
import { useKeyboard } from './composables/useKeyboard.js';
const route = useRoute(); const route = useRoute();
const authStore = useAuthStore(); const authStore = useAuthStore();
@@ -56,6 +57,8 @@ const isAuthPage = computed(() => route.meta?.guest === true)
const hasPlayer = computed(() => !!playerStore.currentTrack); const hasPlayer = computed(() => !!playerStore.currentTrack);
useKeyboard();
function toastClass(type) { function toastClass(type) {
return ( 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>
<div class="flex items-center gap-3 w-full max-w-lg"> <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> <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> <span class="text-xs text-gray-400 w-10">{{ playerStore.formattedDuration }}</span>
</div> </div>
</div> </div>
@@ -68,6 +74,19 @@ const authStore = useAuthStore()
const { requireAuth } = useAuthGate() const { requireAuth } = useAuthGate()
const isLiked = ref(false) 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() { function onTrackEnd() {
const next = queueStore.nextTrack() const next = queueStore.nextTrack()
+10 -10
View File
@@ -62,6 +62,8 @@
</div> </div>
<div class="flex-1"></div> <div class="flex-1"></div>
<CreatePlaylistModal :show="showCreateModal" @close="showCreateModal = false" @created="onPlaylistCreated" />
</aside> </aside>
</template> </template>
@@ -71,12 +73,14 @@ import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/authStore.js' import { useAuthStore } from '../stores/authStore.js'
import api from '../lib/api.js' import api from '../lib/api.js'
import { useAuthGate } from '../composables/useAuthGate.js' import { useAuthGate } from '../composables/useAuthGate.js'
import CreatePlaylistModal from './CreatePlaylistModal.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
const playlists = ref([]) const playlists = ref([])
const showCreateModal = ref(false)
const { requireAuth } = useAuthGate() const { requireAuth } = useAuthGate()
@@ -94,18 +98,14 @@ async function fetchPlaylists() {
function openCreatePlaylist() { function openCreatePlaylist() {
requireAuth({ requireAuth({
redirectTo: '/login', redirectTo: '/login',
onAuthenticated: async () => { onAuthenticated: () => { showCreateModal.value = true }
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 {}
}
}) })
} }
function onPlaylistCreated(playlist) {
showCreateModal.value = false
playlists.value.unshift(playlist)
}
onMounted(fetchPlaylists) onMounted(fetchPlaylists)
</script> </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 LandingPage from '../views/LandingPage.vue'
import LibraryPage from '../views/LibraryPage.vue' import LibraryPage from '../views/LibraryPage.vue'
import PlaylistPage from '../views/PlaylistPage.vue' import PlaylistPage from '../views/PlaylistPage.vue'
import PlaylistDetail from '../views/PlaylistDetail.vue'
import ProfilePage from '../views/ProfilePage.vue' import ProfilePage from '../views/ProfilePage.vue'
import AdminPage from '../views/AdminPage.vue' import AdminPage from '../views/AdminPage.vue'
@@ -16,7 +17,7 @@ const routes = [
{ path: '/home', name: 'Home', component: LandingPage }, { path: '/home', name: 'Home', component: LandingPage },
{ path: '/library', name: 'Library', component: LibraryPage }, { path: '/library', name: 'Library', component: LibraryPage },
{ path: '/playlist', name: 'Playlist', component: PlaylistPage }, { 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: '/profile', name: 'Profile', component: ProfilePage },
{ path: '/admin/tracks', name: 'AdminTracks', component: AdminPage, meta: { admin: true } }, { path: '/admin/tracks', name: 'AdminTracks', component: AdminPage, meta: { admin: true } },
] ]
+19 -4
View File
@@ -22,17 +22,32 @@
TRENDING NOW 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> <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> </h2>
<div v-if="loading" class="text-center py-12 text-gray-500">Loading tracks...</div> <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-else class="grid 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" <div v-for="track in tracks" :key="track._id"
@click="playTrack(track)" @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"> <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" /> <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="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" <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-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> <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> </div>
+11 -2
View File
@@ -19,7 +19,7 @@
<div class="aspect-square bg-gray-300 relative overflow-hidden"> <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" /> <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" <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-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> <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>
@@ -37,7 +37,15 @@
All Tracks 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> <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> </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 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"> <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="flex-1">TITLE</span>
@@ -49,6 +57,7 @@
<div v-for="(track, index) in allTracks" :key="track._id" <div v-for="(track, index) in allTracks" :key="track._id"
@click="playTrack(track, false, index)" @click="playTrack(track, false, index)"
class="px-6 py-3 flex items-center hover:bg-gray-50 transition-colors cursor-pointer" 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"> <div class="flex-1 flex items-center gap-3">
<span class="text-sm text-gray-400 w-6"> <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>
+54 -107
View File
@@ -1,143 +1,90 @@
<template> <template>
<div class="p-8"> <div class="p-8">
<div v-if="loading" class="text-center py-20 text-gray-500">Loading playlist...</div> <div class="flex items-center justify-between mb-6">
<template v-else-if="playlist"> <h1 class="text-3xl font-black text-black">YOUR PLAYLISTS</h1>
<div class="flex items-start gap-8 mb-8"> <button @click="openCreate"
<div class="w-56 h-56 rounded-2xl overflow-hidden border-4 border-black flex-shrink-0 bg-black"> 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">
<img v-if="playlist.coverUrl" :src="formatUrl(playlist.coverUrl)" class="w-full h-full object-cover" /> <svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path d="M12 4v16m8-8H4"/></svg>
<div v-else class="w-full h-full flex items-center justify-center"> NEW PLAYLIST
<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">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
PLAY
</button> </button>
</div> </div>
</div>
<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>
<div class="bg-white rounded-2xl border-4 border-black overflow-hidden"> <div v-else-if="playlists.length === 0" class="text-center py-20">
<div class="bg-noctune-teal px-6 py-3 flex items-center text-black font-bold border-b-4 border-black"> <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>
<span class="w-12 text-center">#</span> <p class="text-gray-500 mb-2 text-lg">No playlists yet</p>
<span class="flex-1 text-center">TITLE</span> <p class="text-gray-400 text-sm mb-4">Create your first playlist to start organizing your music</p>
<span class="w-48 text-center">ARTIST</span> <button @click="openCreate"
<span class="w-12 text-center"></span> 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>
<div class="divide-y divide-gray-200">
<div v-for="(track, index) in playlist.tracks" :key="track._id" <div v-else class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
@click="playTrack(index)" <div v-for="pl in playlists" :key="pl._id"
class="px-6 py-3 flex items-center hover:bg-gray-50 transition-colors cursor-pointer" @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"
> >
<span class="w-12 text-center"> <div class="aspect-square bg-gray-300 relative overflow-hidden">
<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> <img v-if="pl.coverUrl" :src="formatUrl(pl.coverUrl)" class="w-full h-full object-cover" />
<span v-else class="text-gray-500">{{ index + 1 }}</span> <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">
</span> <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 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> </div>
<span class="font-medium text-sm truncate" :class="isCurrentTrack(track) ? 'text-noctune-teal' : 'text-black'">{{ track.title }}</span> <div class="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors" />
</div> </div>
<span class="w-48 text-center text-gray-600 text-sm truncate">{{ track.artist }}</span> <div class="p-3">
<button @click.stop="toggleLike(track)" class="w-12 flex justify-center transition-colors"> <p class="font-bold text-sm truncate text-black">{{ pl.name }}</p>
<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> <p class="text-xs text-gray-500">{{ pl.tracks?.length || 0 }} songs</p>
</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>
</div> </div>
</template> </div>
<div v-else class="text-center py-20 text-gray-500">Playlist not found</div>
<CreatePlaylistModal :show="showCreateModal" @close="showCreateModal = false" @created="onPlaylistCreated" />
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRouter } from 'vue-router'
import { usePlayerStore } from '../stores/playerStore.js'
import { useQueueStore } from '../stores/queueStore.js'
import { useAuthStore } from '../stores/authStore.js' import { useAuthStore } from '../stores/authStore.js'
import api from '../lib/api.js' import api from '../lib/api.js'
import { formatUrl } from '../lib/utils.js' import { formatUrl } from '../lib/utils.js'
import { useAuthGate } from '../composables/useAuthGate.js'
import CreatePlaylistModal from '../components/CreatePlaylistModal.vue'
const route = useRoute() const router = useRouter()
const playerStore = usePlayerStore()
const queueStore = useQueueStore()
const authStore = useAuthStore() const authStore = useAuthStore()
const { requireAuth } = useAuthGate()
const playlist = ref(null) const playlists = ref([])
const loading = ref(true) const loading = ref(true)
const showCreateModal = ref(false)
function isCurrentTrack(track) { function openCreate() {
return playerStore.currentTrack?._id === track._id requireAuth({
redirectTo: '/login',
onAuthenticated: () => { showCreateModal.value = true }
})
} }
function isLiked(track) { function onPlaylistCreated(playlist) {
return authStore.likedTrackIds?.has(track._id) showCreateModal.value = false
} playlists.value.unshift(playlist)
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)
} }
onMounted(async () => { onMounted(async () => {
const id = route.params.id if (!authStore.isLoggedIn) {
loading.value = false
return
}
try { try {
if (id) {
const { data } = await api.get(`/playlists/${id}`)
if (data.success) playlist.value = data.data
} else {
const { data } = await api.get('/playlists') const { data } = await api.get('/playlists')
if (data.success && data.data.length) { if (data.success) playlists.value = data.data
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: [] }
}
}
} catch { } catch {
playlist.value = null // ignore
} finally { } finally {
loading.value = false loading.value = false
} }