feat: add AddToPlaylistModal component for saving tracks to playlists

This commit is contained in:
2026-05-26 20:38:06 +07:00
parent a109f5bab1
commit 9f4e5bc732
9 changed files with 192 additions and 16 deletions
File diff suppressed because one or more lines are too long
-9
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+9
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Noctune - Music That Hits Different</title> <title>Noctune - Music That Hits Different</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-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>
+121
View File
@@ -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">&times;</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>
+16
View File
@@ -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
+26 -3
View File
@@ -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
+17 -1
View File
@@ -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