feat: add AddToPlaylistModal component for saving tracks to playlists
This commit is contained in:
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
+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
+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-C8FI4Eix.js"></script>
|
<script type="module" crossorigin src="/assets/index-sEdWZnAM.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-7ON3mYgU.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CuqcJYD7.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-noctune-cream">
|
<body class="bg-noctune-cream">
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<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-4">
|
||||||
|
<h2 class="text-xl font-black text-black">SAVE TO 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-3 font-medium">{{ error }}</p>
|
||||||
|
|
||||||
|
<div v-if="loading" class="space-y-3">
|
||||||
|
<div v-for="i in 3" :key="i" class="h-14 bg-gray-200 animate-pulse rounded-lg border-2 border-black" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="playlists.length === 0" class="text-center py-8">
|
||||||
|
<svg class="w-16 h-16 mx-auto text-gray-300 mb-3" 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">No playlists yet</p>
|
||||||
|
<button @click="openCreatePlaylist" class="bg-noctune-yellow border-2 border-black px-4 py-2 font-bold rounded-lg text-sm hover:bg-yellow-400 transition-colors cursor-pointer">
|
||||||
|
CREATE PLAYLIST
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="max-h-80 overflow-y-auto space-y-2">
|
||||||
|
<div v-for="pl in playlists" :key="pl._id"
|
||||||
|
@click="addToPlaylist(pl)"
|
||||||
|
class="flex items-center gap-3 px-4 py-3 rounded-lg border-2 border-black cursor-pointer hover:bg-noctune-teal/10 transition-colors"
|
||||||
|
:class="savingId === pl._id ? 'opacity-50 pointer-events-none' : ''"
|
||||||
|
>
|
||||||
|
<div class="w-12 h-12 rounded-lg overflow-hidden bg-gray-300 border border-black flex-shrink-0">
|
||||||
|
<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-6 h-5 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>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="font-bold text-sm text-black truncate">{{ pl.name }}</p>
|
||||||
|
<p class="text-xs text-gray-500">{{ pl.tracks?.length || 0 }} songs</p>
|
||||||
|
</div>
|
||||||
|
<svg v-if="savingId === pl._id" class="w-5 h-5 animate-spin text-noctune-teal" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
||||||
|
<svg v-else class="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CreatePlaylistModal :show="showCreateModal" @close="showCreateModal = false" @created="onPlaylistCreated" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, watch } from 'vue'
|
||||||
|
import { useAuthStore } from '../stores/authStore.js'
|
||||||
|
import { useUiStore } from '../stores/uiStore.js'
|
||||||
|
import api from '../lib/api.js'
|
||||||
|
import { formatUrl } from '../lib/utils.js'
|
||||||
|
import CreatePlaylistModal from './CreatePlaylistModal.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: Boolean,
|
||||||
|
trackId: { type: String, default: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close'])
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const uiStore = useUiStore()
|
||||||
|
|
||||||
|
const playlists = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const savingId = ref(null)
|
||||||
|
const error = ref('')
|
||||||
|
const showCreateModal = ref(false)
|
||||||
|
|
||||||
|
function openCreatePlaylist() {
|
||||||
|
showCreateModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPlaylistCreated(pl) {
|
||||||
|
showCreateModal.value = false
|
||||||
|
playlists.value.unshift(pl)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addToPlaylist(pl) {
|
||||||
|
if (!props.trackId) return
|
||||||
|
savingId.value = pl._id
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const { data } = await api.post(`/playlists/${pl._id}/tracks`, { trackId: props.trackId })
|
||||||
|
if (data.success) {
|
||||||
|
uiStore.showToast(`Saved to "${pl.name}"`, 'success')
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err.response?.data?.message || ''
|
||||||
|
if (msg.includes('already') || msg.includes('duplicate')) {
|
||||||
|
uiStore.showToast(`Already in "${pl.name}"`, 'info')
|
||||||
|
emit('close')
|
||||||
|
} else {
|
||||||
|
error.value = msg || 'Failed to save track'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
savingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPlaylists() {
|
||||||
|
if (!authStore.isLoggedIn) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/playlists')
|
||||||
|
if (data.success) playlists.value = data.data
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.show, (val) => {
|
||||||
|
if (val) fetchPlaylists()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -62,11 +62,16 @@
|
|||||||
<button @click.stop="toggleLike(track)" class="ml-2 flex-shrink-0">
|
<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>
|
<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>
|
</button>
|
||||||
|
<button @click.stop="openAddToPlaylist(track)" class="ml-1 flex-shrink-0 text-gray-400 hover:text-noctune-teal transition-colors">
|
||||||
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<AddToPlaylistModal :show="showAddModal" :track-id="trackToAdd?._id" @close="showAddModal = false" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -78,6 +83,7 @@ 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 { useAuthGate } from '../composables/useAuthGate.js'
|
||||||
|
import AddToPlaylistModal from '../components/AddToPlaylistModal.vue'
|
||||||
|
|
||||||
const playerStore = usePlayerStore()
|
const playerStore = usePlayerStore()
|
||||||
const queueStore = useQueueStore()
|
const queueStore = useQueueStore()
|
||||||
@@ -86,6 +92,16 @@ const { requireAuth } = useAuthGate()
|
|||||||
|
|
||||||
const tracks = ref([])
|
const tracks = ref([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
const trackToAdd = ref(null)
|
||||||
|
const showAddModal = ref(false)
|
||||||
|
|
||||||
|
function openAddToPlaylist(track) {
|
||||||
|
trackToAdd.value = track
|
||||||
|
requireAuth({
|
||||||
|
redirectTo: '/login',
|
||||||
|
onAuthenticated: () => { showAddModal.value = true }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function isCurrentTrack(track) {
|
function isCurrentTrack(track) {
|
||||||
return playerStore.currentTrack?._id === track._id
|
return playerStore.currentTrack?._id === track._id
|
||||||
|
|||||||
@@ -25,8 +25,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<p class="text-sm font-bold text-black truncate">{{ track.title }}</p>
|
<div class="flex items-center justify-between">
|
||||||
<p class="text-xs text-gray-500 truncate">{{ track.artist }}</p>
|
<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="openAddToPlaylist(track)" class="ml-2 flex-shrink-0 text-gray-400 hover:text-noctune-teal transition-colors">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -71,13 +78,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="w-40 text-center text-gray-600 text-sm truncate">{{ track.artist }}</span>
|
<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>
|
<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 transition-colors">
|
<button @click.stop="toggleLike(track)" class="w-10 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>
|
<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>
|
</button>
|
||||||
|
<button @click.stop="openAddToPlaylist(track)" class="w-8 flex justify-center transition-colors text-gray-400 hover:text-noctune-teal">
|
||||||
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<AddToPlaylistModal :show="showAddModal" :track-id="trackToAdd?._id" @close="showAddModal = false" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -89,6 +101,7 @@ 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 { useAuthGate } from '../composables/useAuthGate.js'
|
||||||
|
import AddToPlaylistModal from '../components/AddToPlaylistModal.vue'
|
||||||
|
|
||||||
const playerStore = usePlayerStore()
|
const playerStore = usePlayerStore()
|
||||||
const queueStore = useQueueStore()
|
const queueStore = useQueueStore()
|
||||||
@@ -99,6 +112,16 @@ const likedTracks = ref([])
|
|||||||
const allTracks = ref([])
|
const allTracks = ref([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const allLoading = ref(true)
|
const allLoading = ref(true)
|
||||||
|
const trackToAdd = ref(null)
|
||||||
|
const showAddModal = ref(false)
|
||||||
|
|
||||||
|
function openAddToPlaylist(track) {
|
||||||
|
trackToAdd.value = track
|
||||||
|
requireAuth({
|
||||||
|
redirectTo: '/login',
|
||||||
|
onAuthenticated: () => { showAddModal.value = true }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function isCurrentTrack(track) {
|
function isCurrentTrack(track) {
|
||||||
return playerStore.currentTrack?._id === track._id
|
return playerStore.currentTrack?._id === track._id
|
||||||
|
|||||||
@@ -82,9 +82,12 @@
|
|||||||
<span class="font-medium text-sm truncate" :class="isCurrentTrack(track) ? 'text-noctune-teal' : 'text-black'">{{ track.title }}</span>
|
<span class="font-medium text-sm truncate" :class="isCurrentTrack(track) ? 'text-noctune-teal' : 'text-black'">{{ track.title }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="w-48 text-center text-gray-600 text-sm truncate">{{ track.artist }}</span>
|
<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">
|
<button @click.stop="toggleLike(track)" class="w-10 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>
|
<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>
|
</button>
|
||||||
|
<button @click.stop="openAddToPlaylist(track)" class="w-8 flex justify-center transition-colors text-gray-400 hover:text-noctune-teal cursor-pointer">
|
||||||
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!playlist.tracks?.length" class="p-8 text-center text-gray-500">No tracks in this playlist yet</div>
|
<div v-if="!playlist.tracks?.length" class="p-8 text-center text-gray-500">No tracks in this playlist yet</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,6 +95,8 @@
|
|||||||
</template>
|
</template>
|
||||||
<div v-else class="text-center py-20 text-gray-500">Playlist not found</div>
|
<div v-else class="text-center py-20 text-gray-500">Playlist not found</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AddToPlaylistModal :show="showAddModal" :track-id="trackToAdd?._id" @close="showAddModal = false" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -103,6 +108,7 @@ 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 { useAuthGate } from '../composables/useAuthGate.js'
|
||||||
|
import AddToPlaylistModal from '../components/AddToPlaylistModal.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const playerStore = usePlayerStore()
|
const playerStore = usePlayerStore()
|
||||||
@@ -112,6 +118,16 @@ const { requireAuth } = useAuthGate()
|
|||||||
|
|
||||||
const playlist = ref(null)
|
const playlist = ref(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
const trackToAdd = ref(null)
|
||||||
|
const showAddModal = ref(false)
|
||||||
|
|
||||||
|
function openAddToPlaylist(track) {
|
||||||
|
trackToAdd.value = track
|
||||||
|
requireAuth({
|
||||||
|
redirectTo: '/login',
|
||||||
|
onAuthenticated: () => { showAddModal.value = true }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function isCurrentTrack(track) {
|
function isCurrentTrack(track) {
|
||||||
return playerStore.currentTrack?._id === track._id
|
return playerStore.currentTrack?._id === track._id
|
||||||
|
|||||||
Reference in New Issue
Block a user