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:
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+9
File diff suppressed because one or more lines are too long
Vendored
-9
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
{
|
||||
|
||||
@@ -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">×</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>
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 } },
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user