Refactor Sidebar and enhance playlist functionality; update main.js for session restoration; add toggleLike feature in authStore; improve AdminPage and LandingPage UI; integrate liked tracks in LibraryPage and PlaylistPage; optimize track playback logic.
This commit is contained in:
+51
-31
@@ -1,10 +1,15 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-noctune-cream">
|
||||
<div v-if="!isAuthPage" class="flex flex-col min-h-screen">
|
||||
<AppHeader />
|
||||
<div class="flex flex-1">
|
||||
<Sidebar />
|
||||
<main class="flex-1 overflow-auto" :class="{ 'pb-20': hasPlayer }">
|
||||
<div v-if="!isAuthPage">
|
||||
<AppHeader class="sticky top-0 z-30" />
|
||||
<div class="flex">
|
||||
<Sidebar
|
||||
class="fixed left-0 top-[57px] bottom-0 w-64 border-r-4 border-black overflow-y-auto z-20"
|
||||
/>
|
||||
<main
|
||||
class="ml-64 flex-1 min-h-[calc(100vh-57px)]"
|
||||
:class="{ 'pb-20': hasPlayer }"
|
||||
>
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
@@ -12,13 +17,20 @@
|
||||
</div>
|
||||
<router-view v-else />
|
||||
<div class="fixed top-4 right-4 z-[100] space-y-2">
|
||||
<div v-for="toast in uiStore.toasts" :key="toast.id"
|
||||
<div
|
||||
v-for="toast in uiStore.toasts"
|
||||
:key="toast.id"
|
||||
class="px-4 py-3 rounded-lg border-2 border-black font-bold text-sm shadow-[4px_4px_0px_0px_#000] transition-all animate-slide-in"
|
||||
:class="toastClass(toast.type)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ toast.message }}</span>
|
||||
<button @click="uiStore.dismissToast(toast.id)" class="ml-2 opacity-60 hover:opacity-100">×</button>
|
||||
<button
|
||||
@click="uiStore.dismissToast(toast.id)"
|
||||
class="ml-2 opacity-60 hover:opacity-100"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -26,37 +38,39 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAuthStore } from './stores/authStore.js'
|
||||
import { useUiStore } from './stores/uiStore.js'
|
||||
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 { computed, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useAuthStore } from './stores/authStore.js';
|
||||
import { useUiStore } from './stores/uiStore.js';
|
||||
import { usePlayerStore } from './stores/playerStore.js';
|
||||
import AppHeader from './components/AppHeader.vue';
|
||||
import Sidebar from './components/Sidebar.vue';
|
||||
import PlayerBar from './components/PlayerBar.vue';
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const uiStore = useUiStore()
|
||||
const playerStore = usePlayerStore()
|
||||
const route = useRoute();
|
||||
const authStore = useAuthStore();
|
||||
const uiStore = useUiStore();
|
||||
const playerStore = usePlayerStore();
|
||||
|
||||
const isAuthPage = computed(() => {
|
||||
return route.meta?.guest === true || route.path === '/register'
|
||||
})
|
||||
return route.meta?.guest === true || route.path === '/register';
|
||||
});
|
||||
|
||||
const hasPlayer = computed(() => !!playerStore.currentTrack)
|
||||
const hasPlayer = computed(() => !!playerStore.currentTrack);
|
||||
|
||||
function toastClass(type) {
|
||||
return {
|
||||
info: 'bg-noctune-teal text-black',
|
||||
success: 'bg-green-400 text-black',
|
||||
error: 'bg-red-400 text-white',
|
||||
}[type] || 'bg-noctune-yellow text-black'
|
||||
return (
|
||||
{
|
||||
info: 'bg-noctune-teal text-black',
|
||||
success: 'bg-green-400 text-black',
|
||||
error: 'bg-red-400 text-white',
|
||||
}[type] || 'bg-noctune-yellow text-black'
|
||||
);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await authStore.checkSession()
|
||||
})
|
||||
await authStore.checkSession();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -64,7 +78,13 @@ onMounted(async () => {
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateX(100%); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
</div>
|
||||
|
||||
<div class="w-80 flex items-center justify-end gap-3">
|
||||
<button @click="toggleLike" class="text-gray-400 hover:text-red-400 transition-colors">
|
||||
<svg class="w-5 h-5" :class="isLiked ? 'text-red-400 fill-current' : ''" 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 @click="toggleLike" class="transition-colors">
|
||||
<svg class="w-5 h-5" :class="isLiked ? '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 class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/></svg>
|
||||
@@ -58,10 +58,12 @@
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { usePlayerStore } from '../stores/playerStore.js'
|
||||
import { useQueueStore } from '../stores/queueStore.js'
|
||||
import api from '../lib/api.js'
|
||||
import { useAuthStore } from '../stores/authStore.js'
|
||||
|
||||
const playerStore = usePlayerStore()
|
||||
const queueStore = useQueueStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const isLiked = ref(false)
|
||||
|
||||
function onTrackEnd() {
|
||||
@@ -106,32 +108,21 @@ function playPrev() {
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => playerStore.currentTrack?.id, async (id) => {
|
||||
if (!id) return
|
||||
try {
|
||||
const { data } = await api.get('/auth/me')
|
||||
if (data.success) {
|
||||
isLiked.value = data.data.likedTracks?.includes(id) || false
|
||||
}
|
||||
} catch {
|
||||
isLiked.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function toggleLike() {
|
||||
if (!playerStore.currentTrack) return
|
||||
const id = playerStore.currentTrack._id || playerStore.currentTrack.id
|
||||
try {
|
||||
if (isLiked.value) {
|
||||
await api.delete(`/tracks/${id}/like`)
|
||||
} else {
|
||||
await api.post(`/tracks/${id}/like`)
|
||||
}
|
||||
isLiked.value = !isLiked.value
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
await authStore.toggleLike(id)
|
||||
}
|
||||
|
||||
// Sync isLiked ref reactively from authStore
|
||||
watch(() => playerStore.currentTrack?._id, (id) => {
|
||||
isLiked.value = id ? authStore.likedTrackIds.has(id) : false
|
||||
}, { immediate: true })
|
||||
|
||||
watch(() => authStore.user?.likedTracks?.length, () => {
|
||||
const id = playerStore.currentTrack?._id
|
||||
if (id) isLiked.value = authStore.likedTrackIds.has(id)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
+36
-27
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<aside class="w-64 bg-noctune-cream border-r-4 border-black flex flex-col h-full">
|
||||
<aside class="bg-noctune-cream flex flex-col h-full">
|
||||
<nav class="p-4 space-y-2">
|
||||
<router-link to="/home"
|
||||
class="flex items-center gap-3 px-4 py-3 rounded-lg font-bold text-black hover:bg-noctune-teal/20 transition-colors"
|
||||
@@ -7,18 +7,43 @@
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"><path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>
|
||||
<span class="tracking-wide">HOME</span>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/library"
|
||||
class="flex items-center gap-3 px-4 py-3 rounded-lg font-bold text-black hover:bg-noctune-teal/20 transition-colors"
|
||||
:class="{ 'bg-noctune-teal text-black': isActive('/library') }">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"><path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z"/></svg>
|
||||
<span class="tracking-wide">LIBRARY</span>
|
||||
</router-link>
|
||||
<router-link to="/playlist"
|
||||
class="flex items-center gap-3 px-4 py-3 rounded-lg font-bold text-black hover:bg-noctune-teal/20 transition-colors"
|
||||
:class="{ 'bg-noctune-teal text-black': isActive('/playlist') }">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"><path d="M3 10h11v2H3v-2zm0-4h11v2H3V6zm0 8h7v2H3v-2zm13-1v8l6-4-6-4z"/></svg>
|
||||
<span class="tracking-wide">PLAYLIST</span>
|
||||
</router-link>
|
||||
|
||||
<div class="relative group">
|
||||
<router-link to="/playlist"
|
||||
class="flex items-center gap-3 px-4 py-3 rounded-lg font-bold text-black hover:bg-noctune-teal/20 transition-colors"
|
||||
:class="{ 'bg-noctune-teal text-black': isActive('/playlist') || isActivePrefix('/playlist/') }">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"><path d="M3 10h11v2H3v-2zm0-4h11v2H3V6zm0 8h7v2H3v-2zm13-1v8l6-4-6-4z"/></svg>
|
||||
<span class="tracking-wide">PLAYLIST</span>
|
||||
<svg class="w-4 h-4 ml-auto text-gray-500 group-hover:rotate-180 transition-transform" fill="currentColor" viewBox="0 0 24 24"><path d="M7 10l5 5 5-5z"/></svg>
|
||||
</router-link>
|
||||
|
||||
<div class="absolute left-0 top-full mt-1 w-56 bg-white border-2 border-black rounded-lg shadow-[4px_4px_0px_0px_#000] opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50 max-h-72 overflow-y-auto">
|
||||
<div v-if="playlists.length" class="p-2">
|
||||
<div v-for="pl in playlists" :key="pl._id"
|
||||
@click="router.push(`/playlist/${pl._id}`)"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer hover:bg-noctune-teal/20 transition-colors text-sm font-medium text-black"
|
||||
>
|
||||
<svg class="w-4 h-4 flex-shrink-0 text-gray-500" fill="currentColor" viewBox="0 0 24 24"><path d="M3 10h11v2H3v-2zm0-4h11v2H3V6zm0 8h7v2H3v-2zm13-1v8l6-4-6-4z"/></svg>
|
||||
<span class="truncate">{{ pl.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="p-4 text-center text-sm text-gray-500">No playlists yet</div>
|
||||
<div class="border-t-2 border-gray-200 p-2">
|
||||
<router-link to="/playlist"
|
||||
class="block px-3 py-2 rounded-lg text-sm font-bold text-noctune-teal hover:bg-noctune-teal/20 transition-colors text-center">
|
||||
Browse All Playlists
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<router-link v-if="authStore.isAdmin" to="/admin/tracks"
|
||||
class="flex items-center gap-3 px-4 py-3 rounded-lg font-bold text-black hover:bg-noctune-orange/20 transition-colors"
|
||||
:class="{ 'bg-noctune-orange text-black': isActive('/admin/tracks') }">
|
||||
@@ -36,18 +61,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-4 mt-4">
|
||||
<div v-if="playlists.length" class="space-y-1">
|
||||
<p class="text-xs font-bold text-gray-500 px-2 mb-2">YOUR PLAYLISTS</p>
|
||||
<div v-for="pl in playlists" :key="pl._id"
|
||||
@click="router.push(`/playlist/${pl._id}`)"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer hover:bg-noctune-teal/20 transition-colors text-sm text-black font-medium truncate"
|
||||
>
|
||||
<svg class="w-4 h-4 flex-shrink-0 text-gray-500" fill="currentColor" viewBox="0 0 24 24"><path d="M3 10h11v2H3v-2zm0-4h11v2H3V6zm0 8h7v2H3v-2zm13-1v8l6-4-6-4z"/></svg>
|
||||
{{ pl.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1"></div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -55,26 +69,23 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/authStore.js'
|
||||
import { useQueueStore } from '../stores/queueStore.js'
|
||||
import api from '../lib/api.js'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const queueStore = useQueueStore()
|
||||
|
||||
const playlists = ref([])
|
||||
|
||||
const isActive = (path) => route.path === path
|
||||
const isActivePrefix = (prefix) => route.path.startsWith(prefix)
|
||||
|
||||
async function fetchPlaylists() {
|
||||
if (!authStore.isLoggedIn) return
|
||||
try {
|
||||
const { data } = await api.get('/playlists')
|
||||
if (data.success) playlists.value = data.data
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function openCreatePlaylist() {
|
||||
@@ -85,9 +96,7 @@ async function openCreatePlaylist() {
|
||||
if (data.success) {
|
||||
playlists.value.push(data.data)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
onMounted(fetchPlaylists)
|
||||
|
||||
+16
-9
@@ -1,10 +1,17 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router/index.js'
|
||||
import './style.css'
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import App from './App.vue';
|
||||
import router from './router/index.js';
|
||||
import './style.css';
|
||||
import { useAuthStore } from './stores/authStore.js';
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
|
||||
// Restore session before mounting so route guards see logged-in state
|
||||
const authStore = useAuthStore(pinia);
|
||||
authStore.checkSession().finally(() => {
|
||||
app.mount('#app');
|
||||
});
|
||||
|
||||
+24
-1
@@ -9,6 +9,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const isLoggedIn = computed(() => !!user.value)
|
||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||
const isArtist = computed(() => user.value?.role === 'artist')
|
||||
const likedTrackIds = computed(() => new Set(user.value?.likedTracks || []))
|
||||
|
||||
async function checkSession() {
|
||||
try {
|
||||
@@ -71,5 +72,27 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
}
|
||||
|
||||
return { user, loading, isLoggedIn, isAdmin, isArtist, checkSession, login, register, logout, updateProfile }
|
||||
async function toggleLike(trackId) {
|
||||
const isLiked = likedTrackIds.value.has(trackId)
|
||||
try {
|
||||
if (isLiked) {
|
||||
await api.delete(`/tracks/${trackId}/like`)
|
||||
if (user.value) {
|
||||
user.value.likedTracks = user.value.likedTracks.filter((id) => id !== trackId)
|
||||
}
|
||||
} else {
|
||||
await api.post(`/tracks/${trackId}/like`)
|
||||
if (user.value) {
|
||||
user.value.likedTracks.push(trackId)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
user, loading, isLoggedIn, isAdmin, isArtist, likedTrackIds,
|
||||
checkSession, login, register, logout, updateProfile, toggleLike,
|
||||
}
|
||||
})
|
||||
|
||||
+153
-73
@@ -1,49 +1,122 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-noctune-cream flex flex-col">
|
||||
<AppHeader />
|
||||
<div class="flex flex-1">
|
||||
<Sidebar />
|
||||
<main class="flex-1 p-8 overflow-auto">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<h1 class="text-4xl font-black text-black mb-2">Upload Track</h1>
|
||||
<p class="text-gray-600 mb-8">Add a new track to the library</p>
|
||||
<p v-if="error" class="text-red-500 text-sm mb-4 font-medium">{{ error }}</p>
|
||||
<p v-if="success" class="text-green-600 text-sm mb-4 font-medium">{{ success }}</p>
|
||||
<p v-if="error" class="text-red-500 text-sm mb-4 font-medium">
|
||||
{{ error }}
|
||||
</p>
|
||||
<p v-if="success" class="text-green-600 text-sm mb-4 font-medium">
|
||||
{{ success }}
|
||||
</p>
|
||||
<div class="bg-white rounded-2xl border-4 border-black p-8 space-y-6">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-2 tracking-wider">TITLE</label>
|
||||
<input type="text" v-model="title" class="w-full border-2 border-black rounded-lg px-4 py-3 focus:outline-none text-black" placeholder="Track title" />
|
||||
<label
|
||||
class="block text-xs font-bold text-gray-600 mb-2 tracking-wider"
|
||||
>TITLE</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
v-model="title"
|
||||
class="w-full border-2 border-black rounded-lg px-4 py-3 focus:outline-none text-black"
|
||||
placeholder="Track title"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-2 tracking-wider">ARTIST</label>
|
||||
<input type="text" v-model="artist" class="w-full border-2 border-black rounded-lg px-4 py-3 focus:outline-none text-black" placeholder="Artist name" />
|
||||
<label
|
||||
class="block text-xs font-bold text-gray-600 mb-2 tracking-wider"
|
||||
>ARTIST</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
v-model="artist"
|
||||
class="w-full border-2 border-black rounded-lg px-4 py-3 focus:outline-none text-black"
|
||||
placeholder="Artist name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-2 tracking-wider">ALBUM</label>
|
||||
<input type="text" v-model="album" class="w-full border-2 border-black rounded-lg px-4 py-3 focus:outline-none text-black" placeholder="Album name" />
|
||||
<label
|
||||
class="block text-xs font-bold text-gray-600 mb-2 tracking-wider"
|
||||
>ALBUM</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
v-model="album"
|
||||
class="w-full border-2 border-black rounded-lg px-4 py-3 focus:outline-none text-black"
|
||||
placeholder="Album name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-2 tracking-wider">GENRE</label>
|
||||
<input type="text" v-model="genre" class="w-full border-2 border-black rounded-lg px-4 py-3 focus:outline-none text-black" placeholder="e.g. Pop, Rock, Jazz" />
|
||||
<label
|
||||
class="block text-xs font-bold text-gray-600 mb-2 tracking-wider"
|
||||
>GENRE</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
v-model="genre"
|
||||
class="w-full border-2 border-black rounded-lg px-4 py-3 focus:outline-none text-black"
|
||||
placeholder="e.g. Pop, Rock, Jazz"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-2 tracking-wider">COVER IMAGE</label>
|
||||
<div class="border-2 border-dashed border-black rounded-lg p-6 text-center cursor-pointer hover:bg-gray-50" @click="coverInput.click()">
|
||||
<img v-if="coverPreview" :src="coverPreview" class="w-32 h-32 object-cover mx-auto mb-2 rounded-lg border-2 border-black" />
|
||||
<label
|
||||
class="block text-xs font-bold text-gray-600 mb-2 tracking-wider"
|
||||
>COVER IMAGE</label
|
||||
>
|
||||
<div
|
||||
class="border-2 border-dashed border-black rounded-lg p-6 text-center cursor-pointer hover:bg-gray-50"
|
||||
@click="coverInput.click()"
|
||||
>
|
||||
<img
|
||||
v-if="coverPreview"
|
||||
:src="coverPreview"
|
||||
class="w-32 h-32 object-cover mx-auto mb-2 rounded-lg border-2 border-black"
|
||||
/>
|
||||
<p v-else class="text-gray-500">Click to upload cover image</p>
|
||||
<input ref="coverInput" type="file" accept="image/*" @change="onCoverChange" class="hidden" />
|
||||
<input
|
||||
ref="coverInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
@change="onCoverChange"
|
||||
class="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-2 tracking-wider">AUDIO FILE</label>
|
||||
<div class="border-2 border-dashed border-black rounded-lg p-6 text-center cursor-pointer hover:bg-gray-50" @click="audioInput.click()">
|
||||
<p v-if="audioFile" class="text-black font-medium">{{ audioFile.name }}</p>
|
||||
<p v-else class="text-gray-500">Click to upload audio file (MP3, WAV, FLAC)</p>
|
||||
<input ref="audioInput" type="file" accept=".mp3,.wav,.flac" @change="onAudioChange" class="hidden" />
|
||||
<label
|
||||
class="block text-xs font-bold text-gray-600 mb-2 tracking-wider"
|
||||
>AUDIO FILE</label
|
||||
>
|
||||
<div
|
||||
class="border-2 border-dashed border-black rounded-lg p-6 text-center cursor-pointer hover:bg-gray-50"
|
||||
@click="audioInput.click()"
|
||||
>
|
||||
<p v-if="audioFile" class="text-black font-medium">
|
||||
{{ audioFile.name }}
|
||||
</p>
|
||||
<p v-else class="text-gray-500">
|
||||
Click to upload audio file (MP3, WAV, FLAC)
|
||||
</p>
|
||||
<input
|
||||
ref="audioInput"
|
||||
type="file"
|
||||
accept=".mp3,.wav,.flac"
|
||||
@change="onAudioChange"
|
||||
class="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="handleUpload" :disabled="uploading" class="w-full bg-noctune-orange border-2 border-black rounded-lg py-4 font-bold text-white text-xl hover:bg-orange-600 transition-colors disabled:opacity-50">
|
||||
<span v-if="uploading" class="inline-block w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"></span>
|
||||
<button
|
||||
@click="handleUpload"
|
||||
:disabled="uploading"
|
||||
class="w-full bg-noctune-orange border-2 border-black rounded-lg py-4 font-bold text-white text-xl hover:bg-orange-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<span
|
||||
v-if="uploading"
|
||||
class="inline-block w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"
|
||||
></span>
|
||||
{{ uploading ? 'Uploading...' : 'UPLOAD TRACK' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -54,79 +127,86 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import Sidebar from '../components/Sidebar.vue'
|
||||
import api from '../lib/api.js'
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import AppHeader from '../components/AppHeader.vue';
|
||||
import Sidebar from '../components/Sidebar.vue';
|
||||
import api from '../lib/api.js';
|
||||
|
||||
const router = useRouter()
|
||||
const router = useRouter();
|
||||
|
||||
const title = ref('')
|
||||
const artist = ref('')
|
||||
const album = ref('')
|
||||
const genre = ref('')
|
||||
const coverFile = ref(null)
|
||||
const coverPreview = ref('')
|
||||
const audioFile = ref(null)
|
||||
const uploading = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
const title = ref('');
|
||||
const artist = ref('');
|
||||
const album = ref('');
|
||||
const genre = ref('');
|
||||
const coverFile = ref(null);
|
||||
const coverPreview = ref('');
|
||||
const audioFile = ref(null);
|
||||
const uploading = ref(false);
|
||||
const error = ref('');
|
||||
const success = ref('');
|
||||
|
||||
const coverInput = ref(null)
|
||||
const audioInput = ref(null)
|
||||
const coverInput = ref(null);
|
||||
const audioInput = ref(null);
|
||||
|
||||
function onCoverChange(e) {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
coverFile.value = file
|
||||
coverPreview.value = URL.createObjectURL(file)
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
coverFile.value = file;
|
||||
coverPreview.value = URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
function onAudioChange(e) {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
audioFile.value = file
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
audioFile.value = file;
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
error.value = '';
|
||||
success.value = '';
|
||||
|
||||
if (!title.value || !artist.value || !album.value || !genre.value || !coverFile.value || !audioFile.value) {
|
||||
error.value = 'All fields are required'
|
||||
return
|
||||
if (
|
||||
!title.value ||
|
||||
!artist.value ||
|
||||
!album.value ||
|
||||
!genre.value ||
|
||||
!coverFile.value ||
|
||||
!audioFile.value
|
||||
) {
|
||||
error.value = 'All fields are required';
|
||||
return;
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
uploading.value = true;
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('title', title.value)
|
||||
formData.append('artist', artist.value)
|
||||
formData.append('album', album.value)
|
||||
formData.append('genre', genre.value)
|
||||
formData.append('cover', coverFile.value)
|
||||
formData.append('audio', audioFile.value)
|
||||
const formData = new FormData();
|
||||
formData.append('title', title.value);
|
||||
formData.append('artist', artist.value);
|
||||
formData.append('album', album.value);
|
||||
formData.append('genre', genre.value);
|
||||
formData.append('cover', coverFile.value);
|
||||
formData.append('audio', audioFile.value);
|
||||
|
||||
const { data } = await api.post('/admin/tracks', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
success.value = 'Track uploaded successfully!'
|
||||
title.value = ''
|
||||
artist.value = ''
|
||||
album.value = ''
|
||||
genre.value = ''
|
||||
coverFile.value = null
|
||||
coverPreview.value = ''
|
||||
audioFile.value = null
|
||||
setTimeout(() => router.push('/home'), 1500)
|
||||
success.value = 'Track uploaded successfully!';
|
||||
title.value = '';
|
||||
artist.value = '';
|
||||
album.value = '';
|
||||
genre.value = '';
|
||||
coverFile.value = null;
|
||||
coverPreview.value = '';
|
||||
audioFile.value = null;
|
||||
setTimeout(() => router.push('/home'), 1500);
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.message || 'Upload failed'
|
||||
error.value = err.response?.data?.message || 'Upload failed';
|
||||
} finally {
|
||||
uploading.value = false
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
+62
-37
@@ -1,50 +1,57 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-noctune-cream flex flex-col">
|
||||
<div class="p-8">
|
||||
<div class="flex items-start gap-8 mb-12">
|
||||
<div class="flex-1">
|
||||
<h1 class="text-5xl font-black text-black leading-tight mb-2">MUSIC<br/>THAT HITS</h1>
|
||||
<div class="inline-block bg-noctune-orange px-4 py-2 mb-4 border-b-4 border-black">
|
||||
<span class="text-4xl font-black text-black">DIFFERENT.</span>
|
||||
</div>
|
||||
<p class="text-gray-600 mb-6">You Vibe. Your sound. Only on Noctune.</p>
|
||||
<div class="flex gap-4">
|
||||
<button @click="playFirstTrack" class="bg-noctune-yellow border-2 border-black px-6 py-3 font-bold hover:bg-yellow-400 transition-colors rounded-lg">PLAY NOW</button>
|
||||
<button class="bg-gray-200 border-2 border-black px-6 py-3 font-bold hover:bg-gray-300 transition-colors rounded-lg">EXPLORE</button>
|
||||
</div>
|
||||
<div class="p-8">
|
||||
<div class="flex items-start gap-8 mb-12">
|
||||
<div class="flex-1">
|
||||
<h1 class="text-5xl font-black text-black leading-tight mb-2">MUSIC<br/>THAT HITS</h1>
|
||||
<div class="inline-block bg-noctune-orange px-4 py-2 mb-4 border-b-4 border-black">
|
||||
<span class="text-4xl font-black text-black">DIFFERENT.</span>
|
||||
</div>
|
||||
<div class="w-80 h-80 rounded-2xl overflow-hidden border-4 border-black flex-shrink-0">
|
||||
<img src="https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop" alt="Music illustration" class="w-full h-full object-cover" />
|
||||
<p class="text-gray-600 mb-6">You Vibe. Your sound. Only on Noctune.</p>
|
||||
<div class="flex gap-4">
|
||||
<button @click="playFirstTrack" class="bg-noctune-yellow border-2 border-black px-6 py-3 font-bold hover:bg-yellow-400 transition-colors rounded-lg">PLAY NOW</button>
|
||||
<button class="bg-gray-200 border-2 border-black px-6 py-3 font-bold hover:bg-gray-300 transition-colors rounded-lg">EXPLORE</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-80 h-80 rounded-2xl overflow-hidden border-4 border-black flex-shrink-0">
|
||||
<img src="https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop" alt="Music illustration" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2 class="text-2xl font-black text-black mb-6 flex items-center">
|
||||
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-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"
|
||||
>
|
||||
<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 bg-noctune-yellow rounded-full flex items-center justify-center border-2 border-black opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<svg class="w-6 h-6 ml-1 text-black" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
</div>
|
||||
<section>
|
||||
<h2 class="text-2xl font-black text-black mb-6 flex items-center">
|
||||
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-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"
|
||||
>
|
||||
<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'">
|
||||
<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>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<p class="text-sm font-bold text-black truncate">{{ track.title }}</p>
|
||||
<p class="text-xs text-gray-500 truncate">{{ track.artist }}</p>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-bold text-black truncate">{{ track.title }}</p>
|
||||
<p class="text-xs text-gray-500 truncate">{{ track.artist }}</p>
|
||||
</div>
|
||||
<button @click.stop="toggleLike(track)" class="ml-2 flex-shrink-0">
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -52,10 +59,12 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { usePlayerStore } from '../stores/playerStore.js'
|
||||
import { useQueueStore } from '../stores/queueStore.js'
|
||||
import { useAuthStore } from '../stores/authStore.js'
|
||||
import api from '../lib/api.js'
|
||||
|
||||
const playerStore = usePlayerStore()
|
||||
const queueStore = useQueueStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const tracks = ref([])
|
||||
const loading = ref(true)
|
||||
@@ -65,7 +74,19 @@ function formatUrl(url) {
|
||||
return url.startsWith('http') ? url : `http://localhost:3001${url}`
|
||||
}
|
||||
|
||||
function isCurrentTrack(track) {
|
||||
return playerStore.currentTrack?._id === track._id
|
||||
}
|
||||
|
||||
function isLiked(track) {
|
||||
return authStore.likedTrackIds?.has(track._id)
|
||||
}
|
||||
|
||||
function playTrack(track) {
|
||||
if (isCurrentTrack(track)) {
|
||||
playerStore.togglePlay()
|
||||
return
|
||||
}
|
||||
const idx = tracks.value.findIndex((t) => t._id === track._id)
|
||||
queueStore.setQueue(tracks.value, idx)
|
||||
playerStore.setTrack(track)
|
||||
@@ -76,6 +97,10 @@ function playFirstTrack() {
|
||||
if (tracks.value.length) playTrack(tracks.value[0])
|
||||
}
|
||||
|
||||
async function toggleLike(track) {
|
||||
await authStore.toggleLike(track._id)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await api.get('/tracks')
|
||||
|
||||
+37
-30
@@ -9,13 +9,15 @@
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-4 gap-6">
|
||||
<div v-for="track in likedTracks" :key="track._id"
|
||||
@click="playTrack(track)"
|
||||
@click="playTrack(track, true)"
|
||||
class="group cursor-pointer bg-white rounded-2xl border-2 border-black overflow-hidden hover:scale-[1.02] transition-transform"
|
||||
>
|
||||
<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 bg-noctune-yellow rounded-full flex items-center justify-center border-2 border-black opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<svg class="w-5 h-5 ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
<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'">
|
||||
<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>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
@@ -41,20 +43,23 @@
|
||||
</div>
|
||||
<div class="divide-y divide-gray-200">
|
||||
<div v-for="(track, index) in allTracks" :key="track._id"
|
||||
@click="playTrack(track)"
|
||||
@click="playTrack(track, false, index)"
|
||||
class="px-6 py-3 flex items-center hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<div class="flex-1 flex items-center gap-3">
|
||||
<span class="text-sm text-gray-400 w-6">{{ index + 1 }}</span>
|
||||
<span class="text-sm text-gray-400 w-6">
|
||||
<svg v-if="isCurrentTrack(track) && playerStore.isPlaying" class="w-4 h-4 text-noctune-teal inline" fill="currentColor" viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
||||
<span v-else>{{ index + 1 }}</span>
|
||||
</span>
|
||||
<div class="w-10 h-10 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-black text-sm truncate">{{ track.title }}</span>
|
||||
<span class="font-medium text-sm truncate" :class="isCurrentTrack(track) ? 'text-noctune-teal' : 'text-black'">{{ track.title }}</span>
|
||||
</div>
|
||||
<span class="w-40 text-center text-gray-600 text-sm truncate">{{ track.artist }}</span>
|
||||
<span class="w-32 text-center text-gray-600 text-sm truncate">{{ track.album }}</span>
|
||||
<button @click.stop="toggleLike(track)" class="w-12 flex justify-center text-gray-400 hover:text-red-400 transition-colors">
|
||||
<svg class="w-5 h-5" :class="track._isLiked ? 'fill-red-400 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 @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>
|
||||
@@ -67,10 +72,12 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { usePlayerStore } from '../stores/playerStore.js'
|
||||
import { useQueueStore } from '../stores/queueStore.js'
|
||||
import { useAuthStore } from '../stores/authStore.js'
|
||||
import api from '../lib/api.js'
|
||||
|
||||
const playerStore = usePlayerStore()
|
||||
const queueStore = useQueueStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const likedTracks = ref([])
|
||||
const allTracks = ref([])
|
||||
@@ -82,26 +89,31 @@ function formatUrl(url) {
|
||||
return url.startsWith('http') ? url : `http://localhost:3001${url}`
|
||||
}
|
||||
|
||||
function playTrack(track) {
|
||||
const tracks = allTracks.value.length ? allTracks.value : likedTracks.value
|
||||
const idx = tracks.findIndex((t) => t._id === track._id)
|
||||
queueStore.setQueue(tracks, idx >= 0 ? idx : 0)
|
||||
function isCurrentTrack(track) {
|
||||
return playerStore.currentTrack?._id === track._id
|
||||
}
|
||||
|
||||
function isLiked(track) {
|
||||
return authStore.likedTrackIds?.has(track._id)
|
||||
}
|
||||
|
||||
function playTrack(track, fromLiked = false, idx) {
|
||||
if (isCurrentTrack(track)) {
|
||||
playerStore.togglePlay()
|
||||
return
|
||||
}
|
||||
const source = fromLiked ? likedTracks.value : allTracks.value
|
||||
const i = idx ?? source.findIndex((t) => t._id === track._id)
|
||||
queueStore.setQueue(source, i)
|
||||
playerStore.setTrack(track)
|
||||
playerStore.play()
|
||||
}
|
||||
|
||||
async function toggleLike(track) {
|
||||
try {
|
||||
if (track._isLiked) {
|
||||
await api.delete(`/tracks/${track._id}/like`)
|
||||
track._isLiked = false
|
||||
likedTracks.value = likedTracks.value.filter((t) => t._id !== track._id)
|
||||
} else {
|
||||
await api.post(`/tracks/${track._id}/like`)
|
||||
track._isLiked = true
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
const wasLiked = isLiked(track)
|
||||
await authStore.toggleLike(track._id)
|
||||
if (wasLiked) {
|
||||
likedTracks.value = likedTracks.value.filter((t) => t._id !== track._id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,8 +121,7 @@ onMounted(async () => {
|
||||
try {
|
||||
const { data: liked } = await api.get('/tracks/liked')
|
||||
if (liked.success) {
|
||||
likedTracks.value = liked.data.likedTracks || []
|
||||
likedTracks.value.forEach((t) => (t._isLiked = true))
|
||||
likedTracks.value = liked.data || []
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -120,11 +131,7 @@ onMounted(async () => {
|
||||
|
||||
try {
|
||||
const { data: all } = await api.get('/tracks')
|
||||
if (all.success) {
|
||||
allTracks.value = all.data
|
||||
const likedIds = new Set(likedTracks.value.map((t) => t._id))
|
||||
allTracks.value.forEach((t) => (t._isLiked = likedIds.has(t._id)))
|
||||
}
|
||||
if (all.success) allTracks.value = all.data
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
|
||||
+26
-24
@@ -52,16 +52,19 @@
|
||||
@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 text-gray-500">{{ index + 1 }}</span>
|
||||
<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-black text-sm truncate">{{ track.title }}</span>
|
||||
<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 text-gray-400 hover:text-red-400 transition-colors">
|
||||
<svg class="w-5 h-5" :class="track._isLiked ? 'fill-red-400 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 @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>
|
||||
@@ -77,11 +80,13 @@ 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'
|
||||
|
||||
const route = useRoute()
|
||||
const playerStore = usePlayerStore()
|
||||
const queueStore = useQueueStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const playlist = ref(null)
|
||||
const loading = ref(true)
|
||||
@@ -91,11 +96,24 @@ function formatUrl(url) {
|
||||
return url.startsWith('http') ? url : `http://localhost:3001${url}`
|
||||
}
|
||||
|
||||
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(tracks[index])
|
||||
playerStore.setTrack(track)
|
||||
playerStore.play()
|
||||
}
|
||||
|
||||
@@ -104,17 +122,7 @@ function playAll() {
|
||||
}
|
||||
|
||||
async function toggleLike(track) {
|
||||
try {
|
||||
if (track._isLiked) {
|
||||
await api.delete(`/tracks/${track._id}/like`)
|
||||
track._isLiked = false
|
||||
} else {
|
||||
await api.post(`/tracks/${track._id}/like`)
|
||||
track._isLiked = true
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
await authStore.toggleLike(track._id)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -122,18 +130,12 @@ onMounted(async () => {
|
||||
try {
|
||||
if (id) {
|
||||
const { data } = await api.get(`/playlists/${id}`)
|
||||
if (data.success) {
|
||||
playlist.value = data.data
|
||||
playlist.value.tracks?.forEach((t) => (t._isLiked = false))
|
||||
}
|
||||
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
|
||||
playlist.value.tracks?.forEach((t) => (t._isLiked = false))
|
||||
}
|
||||
if (detail.success) playlist.value = detail.data
|
||||
} else {
|
||||
playlist.value = { name: 'My Playlist', tracks: [] }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user