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">
|
<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>
|
||||||
|
|||||||
@@ -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 (
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
<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
@@ -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>
|
||||||
|
|||||||
@@ -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 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 } },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user