From 23c2221b2a8d947b32682db794a9552f07c67afe Mon Sep 17 00:00:00 2001 From: hey-Zayn Date: Thu, 26 Mar 2026 07:30:33 +0500 Subject: [PATCH] added new feature --- backend/src/controllers/album.controller.js | 156 ++++++- .../src/controllers/playlist.controller.js | 166 ++++++++ backend/src/controllers/song.controller.js | 381 +++++++++++++----- backend/src/controllers/stats.controller.js | 21 +- backend/src/index.js | 5 +- backend/src/models/album.model.js | 5 +- backend/src/models/playlist.model.js | 27 ++ backend/src/models/song.model.js | 4 + backend/src/routes/admin.route.js | 7 +- backend/src/routes/album.route.js | 9 +- backend/src/routes/playlist.route.js | 24 ++ backend/src/routes/songs.route.js | 10 +- backend/src/routes/stats.route.js | 2 +- frontend/package-lock.json | 56 +++ frontend/package.json | 3 + frontend/src/App.tsx | 4 +- frontend/src/Providers/AuthProvider.tsx | 56 +-- .../components/CreatePlaylistDialog.tsx | 83 ++++ .../layout/components/LeftSidebar.jsx | 42 +- .../layout/components/TopHeader.jsx | 18 +- .../playlist/AddToPlaylistDialog.tsx | 81 ++++ .../components/playlist/SortableSongItem.tsx | 102 +++++ frontend/src/components/ui/Topbar.jsx | 28 +- frontend/src/lib/utils.ts | 6 + frontend/src/pages/admin/AdminPage.tsx | 9 +- .../pages/admin/components/AddAlbumDialog.tsx | 6 +- .../pages/admin/components/AddSongDialog.tsx | 179 +++++++- .../admin/components/AlbumsTabContent.tsx | 13 +- .../pages/admin/components/AlbumsTable.tsx | 31 +- .../pages/admin/components/DashboardStats.tsx | 29 +- .../admin/components/EditAlbumDialog.tsx | 149 +++++++ .../pages/admin/components/EditSongDialog.tsx | 172 ++++++++ .../src/pages/admin/components/NoContent.tsx | 42 ++ .../admin/components/SongsTabContent.tsx | 13 +- .../src/pages/admin/components/SongsTable.tsx | 22 +- frontend/src/pages/album/AlbumPage.tsx | 50 ++- .../pages/home/components/FeaturedSection.jsx | 4 +- .../src/pages/home/components/SectionGrid.tsx | 4 + frontend/src/pages/playlists/PlaylistPage.tsx | 163 ++++++++ frontend/src/store/useMusicStore.tsx | 51 ++- frontend/src/store/usePlaylistStore.tsx | 160 ++++++++ plan/development-plan.md | 321 +++++++++++++++ 42 files changed, 2467 insertions(+), 247 deletions(-) create mode 100644 backend/src/controllers/playlist.controller.js create mode 100644 backend/src/models/playlist.model.js create mode 100644 backend/src/routes/playlist.route.js create mode 100644 frontend/src/components/layout/components/CreatePlaylistDialog.tsx create mode 100644 frontend/src/components/playlist/AddToPlaylistDialog.tsx create mode 100644 frontend/src/components/playlist/SortableSongItem.tsx create mode 100644 frontend/src/pages/admin/components/EditAlbumDialog.tsx create mode 100644 frontend/src/pages/admin/components/EditSongDialog.tsx create mode 100644 frontend/src/pages/admin/components/NoContent.tsx create mode 100644 frontend/src/pages/playlists/PlaylistPage.tsx create mode 100644 frontend/src/store/usePlaylistStore.tsx create mode 100644 plan/development-plan.md diff --git a/backend/src/controllers/album.controller.js b/backend/src/controllers/album.controller.js index 2837d40..883ad8b 100644 --- a/backend/src/controllers/album.controller.js +++ b/backend/src/controllers/album.controller.js @@ -1,8 +1,43 @@ -const Album = require("../models/album.model") +const Album = require("../models/album.model"); +const Song = require("../models/song.model"); +const cloudinary = require("../lib/cloudinary"); + +const getPublicId = (url) => { + if (!url) return null; + const parts = url.split("/"); + const fileName = parts.pop(); + const publicId = fileName.split(".")[0]; + return publicId; +}; + +const uploadToCloudinary = async (file) => { + try { + const result = await cloudinary.uploader.upload(file.tempFilePath, { + resource_type: "auto", + }); + return result.secure_url; + } catch (err) { + console.log("Error in uploadToCloudinary:", err); + throw new Error(err.message || "Cloudinary upload failed"); + } +}; const AllAlbums = async (req, res, next) => { try { - const albums = await Album.find(); + const filter = {}; + + // If user=true, only show their own albums + if (req.query.user === "true" && req.auth?.userId) { + filter.creator = req.auth.userId; + } else if (!req.query.all === "true") { + // Default behavior if not asking for "all" (and not an admin) could be to still filter by user + // or just allow discovery if intended. + // For now, let's keep it restricted to the creator if they are in a management context. + // But if it's for discovery (HomePage), they might not pass user=true. + // However, the dashboard ALWAYS passes user=true. + } + + const albums = await Album.find(filter); res.status(200).json({ success: true, message: "All Albums", @@ -32,7 +67,122 @@ const AllAlbumsById = async (req, res, next) => { } } +const createAlbum = async (req, res, next) => { + try { + if (!req.files || !req.files.imageFile) { + return res.status(400).json({ success: false, message: "Image file is required" }); + } + + const { title, artist, releaseYear } = req.body; + const creator = req.auth.userId; + + if (!title || !artist) { + return res.status(400).json({ success: false, message: "Title and artist are required" }); + } + + const imageUrl = await uploadToCloudinary(req.files.imageFile); + + const album = new Album({ + title, + artist, + imageUrl, + releaseYear: releaseYear || new Date().getFullYear(), + songs: [], + creator, + }); + + await album.save(); + res.status(201).json({ success: true, message: "Album created successfully", album }); + } catch (error) { + next(error); + } +}; + +const deleteAlbum = async (req, res, next) => { + try { + const { id } = req.params; + const userId = req.auth.userId; + + const album = await Album.findById(id).populate("songs"); + if (!album) { + return res.status(404).json({ success: false, message: "Album not found" }); + } + + if (album.creator && album.creator !== userId) { + const { clerkClient } = require('@clerk/express'); + const currentUser = await clerkClient.users.getUser(userId); + const primaryEmail = currentUser?.primaryEmailAddress?.emailAddress; + const isAdmin = process.env.ADMIN_EMAIL && primaryEmail && process.env.ADMIN_EMAIL === primaryEmail; + + if (!isAdmin) { + return res.status(403).json({ message: "Unauthorized to delete this album" }); + } + } + + // Delete songs and media + for (const song of album.songs) { + try { + if (song.audioUrl) await cloudinary.uploader.destroy(getPublicId(song.audioUrl), { resource_type: "video" }); + if (song.imageUrl) await cloudinary.uploader.destroy(getPublicId(song.imageUrl)); + } catch (err) { + console.error("Error deleting song media during album deletion:", err); + } + } + + if (album.imageUrl) { + try { + await cloudinary.uploader.destroy(getPublicId(album.imageUrl)); + } catch (err) { + console.error("Error deleting album image:", err); + } + } + + await Song.deleteMany({ albumId: id }); + await Album.findByIdAndDelete(id); + + res.status(200).json({ success: true, message: "Album deleted successfully" }); + } catch (error) { + next(error); + } +}; + +const updateAlbum = async (req, res, next) => { + try { + const { id } = req.params; + const { title, artist, releaseYear } = req.body; + const userId = req.auth.userId; + + const album = await Album.findById(id); + if (!album) return res.status(404).json({ success: false, message: "Album not found" }); + + if (album.creator && album.creator !== userId) { + return res.status(403).json({ message: "Unauthorized to update this album" }); + } + + const updatedData = { title, artist, releaseYear: releaseYear || new Date().getFullYear() }; + + if (req.files && req.files.imageFile) { + if (album.imageUrl) { + try { + await cloudinary.uploader.destroy(getPublicId(album.imageUrl)); + } catch (err) { + console.error("Cloudinary old album image deletion failed:", err); + } + } + updatedData.imageUrl = await uploadToCloudinary(req.files.imageFile); + } + + const updatedAlbum = await Album.findByIdAndUpdate(id, updatedData, { new: true }); + res.status(200).json({ success: true, message: "Album updated successfully", album: updatedAlbum }); + } catch (error) { + next(error); + } +}; + module.exports = { AllAlbums, - AllAlbumsById + AllAlbumsById, + createAlbum, + deleteAlbum, + updateAlbum, } \ No newline at end of file diff --git a/backend/src/controllers/playlist.controller.js b/backend/src/controllers/playlist.controller.js new file mode 100644 index 0000000..bba745d --- /dev/null +++ b/backend/src/controllers/playlist.controller.js @@ -0,0 +1,166 @@ +const Playlist = require("../models/playlist.model"); +const Song = require("../models/song.model"); + +const createPlaylist = async (req, res, next) => { + try { + const { name, description, imageUrl } = req.body; + const creator = req.auth.userId; + + if (!name) { + return res.status(400).json({ message: "Playlist name is required" }); + } + + const playlist = new Playlist({ + name, + description, + imageUrl, + creator, + songs: [], + }); + + await playlist.save(); + res.status(201).json(playlist); + } catch (error) { + next(error); + } +}; + +const getUserPlaylists = async (req, res, next) => { + try { + const userId = req.auth.userId; + const playlists = await Playlist.find({ creator: userId }).populate("songs"); + res.status(200).json(playlists); + } catch (error) { + next(error); + } +}; + +const getPlaylistById = async (req, res, next) => { + try { + const { id } = req.params; + const playlist = await Playlist.findById(id).populate("songs"); + + if (!playlist) { + return res.status(404).json({ message: "Playlist not found" }); + } + + res.status(200).json(playlist); + } catch (error) { + next(error); + } +}; + +const updatePlaylist = async (req, res, next) => { + try { + const { id } = req.params; + const { name, description, imageUrl, songs } = req.body; + const userId = req.auth.userId; + + const playlist = await Playlist.findById(id); + + if (!playlist) { + return res.status(404).json({ message: "Playlist not found" }); + } + + if (playlist.creator !== userId) { + return res.status(403).json({ message: "Unauthorized to update this playlist" }); + } + + if (name) playlist.name = name; + if (description !== undefined) playlist.description = description; + if (imageUrl) playlist.imageUrl = imageUrl; + if (songs) playlist.songs = songs; // This handles reordering + + await playlist.save(); + res.status(200).json(playlist); + } catch (error) { + next(error); + } +}; + +const deletePlaylist = async (req, res, next) => { + try { + const { id } = req.params; + const userId = req.auth.userId; + + const playlist = await Playlist.findById(id); + + if (!playlist) { + return res.status(404).json({ message: "Playlist not found" }); + } + + if (playlist.creator !== userId) { + return res.status(403).json({ message: "Unauthorized to delete this playlist" }); + } + + await Playlist.findByIdAndDelete(id); + res.status(200).json({ message: "Playlist deleted successfully" }); + } catch (error) { + next(error); + } +}; + +const addSongToPlaylist = async (req, res, next) => { + try { + const { id } = req.params; + const { songId } = req.body; + const userId = req.auth.userId; + + const playlist = await Playlist.findById(id); + + if (!playlist) { + return res.status(404).json({ message: "Playlist not found" }); + } + + if (playlist.creator !== userId) { + return res.status(403).json({ message: "Unauthorized to modify this playlist" }); + } + + if (playlist.songs.includes(songId)) { + return res.status(400).json({ message: "Song already in playlist" }); + } + + playlist.songs.push(songId); + await playlist.save(); + + const updatedPlaylist = await Playlist.findById(id).populate("songs"); + res.status(200).json(updatedPlaylist); + } catch (error) { + next(error); + } +}; + +const removeSongFromPlaylist = async (req, res, next) => { + try { + const { id, songId } = req.params; + const userId = req.auth.userId; + + const playlist = await Playlist.findById(id); + + if (!playlist) { + return res.status(404).json({ message: "Playlist not found" }); + } + + if (playlist.creator !== userId) { + return res.status(403).json({ message: "Unauthorized to modify this playlist" }); + } + + playlist.songs = playlist.songs.filter(s => s.toString() !== songId); + await playlist.save(); + + const updatedPlaylist = await Playlist.findById(id).populate("songs"); + res.status(200).json(updatedPlaylist); + } catch (error) { + next(error); + } +}; + +module.exports = { + createPlaylist, + getUserPlaylists, + getPlaylistById, + updatePlaylist, + deletePlaylist, + addSongToPlaylist, + removeSongFromPlaylist, +}; diff --git a/backend/src/controllers/song.controller.js b/backend/src/controllers/song.controller.js index 135861e..6579914 100644 --- a/backend/src/controllers/song.controller.js +++ b/backend/src/controllers/song.controller.js @@ -1,105 +1,276 @@ -const Song = require("../models/song.model"); - -const getRandomSongs = async (size) => { - return await Song.aggregate([ - { $sample: { size } }, - { - $project: { - _id: 1, - title: 1, - artist: 1, - imageUrl: 1, - audioUrl: 1, - } - } - ]); -}; - -const getAllSongs = async (req, res, next) => { - try { - const page = parseInt(req.query.page) || 1; - const limit = parseInt(req.query.limit) || 10; - const skip = (page - 1) * limit; - - const songs = await Song.find() - .sort({ createdAt: -1 }) - .skip(skip) - .limit(limit); - - const total = await Song.countDocuments(); - - res.status(200).json({ - success: true, - message: "Songs fetched successfully", - songs, - pagination: { - total, - page, - limit, - pages: Math.ceil(total / limit) - } - }); - } catch (error) { - next(error); - } -}; - -const getSingleSong = async (req, res, next) => { - try { - const songs = await getRandomSongs(1); - res.status(200).json({ - success: true, - message: "Song fetched successfully", - songs - }); - } catch (error) { - next(error); - } -}; - -const getFeaturedSongs = async (req, res, next) => { - try { - const songs = await getRandomSongs(8); - res.status(200).json({ - success: true, - message: "Featured songs fetched successfully", - songs - }); - } catch (error) { - next(error); - } -}; - -const getSongsForYou = async (req, res, next) => { - try { - const songs = await getRandomSongs(4); - res.status(200).json({ - success: true, - message: "Songs for you fetched successfully", - songs - }); - } catch (error) { - next(error); - } -}; - -const getTrendingSongs = async (req, res, next) => { - try { - const songs = await getRandomSongs(4); - res.status(200).json({ - success: true, - message: "Trending songs fetched successfully", - songs - }); - } catch (error) { - next(error); - } -}; - -module.exports = { - getAllSongs, - getFeaturedSongs, - getSongsForYou, - getTrendingSongs, - getSingleSong -}; \ No newline at end of file +const Song = require("../models/song.model"); +const Album = require("../models/album.model"); +const cloudinary = require("../lib/cloudinary"); + +const getPublicId = (url) => { + if (!url) return null; + const parts = url.split("/"); + const fileName = parts.pop(); + const publicId = fileName.split(".")[0]; + return publicId; +}; + +const uploadToCloudinary = async (file) => { + try { + const result = await cloudinary.uploader.upload(file.tempFilePath, { + resource_type: "auto", + }); + return result.secure_url; + } catch (err) { + console.log("Error in uploadToCloudinary:", err); + throw new Error(err.message || "Cloudinary upload failed"); + } +}; + +const getRandomSongs = async (size) => { + return await Song.aggregate([ + { $sample: { size } }, + { + $project: { + _id: 1, + title: 1, + artist: 1, + imageUrl: 1, + audioUrl: 1, + } + } + ]); +}; + +const getAllSongs = async (req, res, next) => { + try { + const page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 10; + const skip = (page - 1) * limit; + + const filter = {}; + if (req.query.user === "true" && req.auth?.userId) { + filter.creator = req.auth.userId; + } + + const songs = await Song.find(filter) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit); + + const total = await Song.countDocuments(filter); + + res.status(200).json({ + success: true, + message: "Songs fetched successfully", + songs, + pagination: { + total, + page, + limit, + pages: Math.ceil(total / limit) + } + }); + } catch (error) { + next(error); + } +}; + +const getSingleSong = async (req, res, next) => { + try { + const songs = await getRandomSongs(1); + res.status(200).json({ + success: true, + message: "Song fetched successfully", + songs + }); + } catch (error) { + next(error); + } +}; + +const getFeaturedSongs = async (req, res, next) => { + try { + const songs = await getRandomSongs(8); + res.status(200).json({ + success: true, + message: "Featured songs fetched successfully", + songs + }); + } catch (error) { + next(error); + } +}; + +const getSongsForYou = async (req, res, next) => { + try { + const songs = await getRandomSongs(4); + res.status(200).json({ + success: true, + message: "Songs for you fetched successfully", + songs + }); + } catch (error) { + next(error); + } +}; + +const getTrendingSongs = async (req, res, next) => { + try { + const songs = await getRandomSongs(4); + res.status(200).json({ + success: true, + message: "Trending songs fetched successfully", + songs + }); + } catch (error) { + next(error); + } +}; + +const createSong = async (req, res, next) => { + try { + if (!req.files || !req.files.audioFile || !req.files.imageFile) { + return res.status(400).json({ + success: false, + message: "Please upload both audio and image files" + }); + } + + const { title, artist, albumId, duration } = req.body; + const creator = req.auth.userId; + + if (!title) return res.status(400).json({ success: false, message: "Title is required" }); + if (!artist) return res.status(400).json({ success: false, message: "Artist is required" }); + if (!duration) return res.status(400).json({ success: false, message: "Duration is required" }); + + const audioUrl = await uploadToCloudinary(req.files.audioFile); + const imageUrl = await uploadToCloudinary(req.files.imageFile); + + const song = new Song({ + title, + artist, + audioUrl, + imageUrl, + duration: parseInt(duration), + albumId: albumId || null, + creator, + }); + + await song.save(); + + if (albumId) { + const album = await Album.findById(albumId); + if (!album || (album.creator && album.creator !== creator)) { + return res.status(403).json({ + success: false, + message: "Unauthorized or invalid album" + }); + } + + await Album.findByIdAndUpdate(albumId, { + $push: { songs: song._id }, + }, { new: true }); + } + + res.status(201).json({ + success: true, + message: "Song created successfully", + song + }); + } catch (error) { + next(error); + } +}; + +const deleteSong = async (req, res, next) => { + try { + const { id } = req.params; + const userId = req.auth.userId; + + const song = await Song.findById(id); + if (!song) { + return res.status(404).json({ message: "Song not found" }); + } + + // Check if user is creator or admin (admin check can be added if needed) + // For now, only creator can delete their song if they are not an admin + // Actually, the user rules say "everyone can upload", but usually we want some restriction on deletion. + // I'll allow the creator to delete. + if (song.creator && song.creator !== userId) { + // We can check admin status here too if we want to allow admins to delete anything + const { clerkClient } = require('@clerk/express'); + const currentUser = await clerkClient.users.getUser(userId); + const primaryEmail = currentUser?.primaryEmailAddress?.emailAddress; + const isAdmin = process.env.ADMIN_EMAIL && primaryEmail && process.env.ADMIN_EMAIL === primaryEmail; + + if (!isAdmin) { + return res.status(403).json({ message: "Unauthorized to delete this song" }); + } + } + + // Delete from Cloudinary + try { + if (song.audioUrl) { + await cloudinary.uploader.destroy(getPublicId(song.audioUrl), { resource_type: "video" }); + } + if (song.imageUrl) { + await cloudinary.uploader.destroy(getPublicId(song.imageUrl)); + } + } catch (err) { + console.error("Cloudinary deletion failed:", err); + } + + if (song.albumId) { + await Album.findByIdAndUpdate(song.albumId, { + $pull: { songs: song._id }, + }); + } + + await Song.findByIdAndDelete(id); + res.status(200).json({ success: true, message: "Song deleted successfully" }); + } catch (error) { + next(error); + } +}; + +const updateSong = async (req, res, next) => { + try { + const { id } = req.params; + const { title, artist, duration, albumId } = req.body; + const userId = req.auth.userId; + + const song = await Song.findById(id); + if (!song) return res.status(404).json({ message: "Song not found" }); + + // Only creator can update + if (song.creator && song.creator !== userId) { + return res.status(403).json({ message: "Unauthorized to update this song" }); + } + + const updatedData = { title, artist, duration: parseInt(duration), albumId: albumId || null }; + + if (req.files && req.files.imageFile) { + // Delete old image if exists + if (song.imageUrl) { + try { + await cloudinary.uploader.destroy(getPublicId(song.imageUrl)); + } catch (err) { + console.error("Cloudinary old image deletion failed:", err); + } + } + updatedData.imageUrl = await uploadToCloudinary(req.files.imageFile); + } + + const updatedSong = await Song.findByIdAndUpdate(id, updatedData, { new: true }); + res.status(200).json({ success: true, message: "Song updated successfully", song: updatedSong }); + } catch (error) { + next(error); + } +}; + +module.exports = { + getAllSongs, + getFeaturedSongs, + getSongsForYou, + getTrendingSongs, + getSingleSong, + createSong, + deleteSong, + updateSong, +}; \ No newline at end of file diff --git a/backend/src/controllers/stats.controller.js b/backend/src/controllers/stats.controller.js index 34628e8..0561f9d 100644 --- a/backend/src/controllers/stats.controller.js +++ b/backend/src/controllers/stats.controller.js @@ -6,18 +6,17 @@ const getStats = async (req, res, next) => { try { - const [totalSongs, totalUsers, totalAlbum] = await Promise.all([ - Song.countDocuments(), - User.countDocuments(), - Album.countDocuments(), + const filter = {}; + if (req.query.user === "true") { + filter.creator = req.auth.userId; + } + const [totalSongs, totalUsers, totalAlbums, uniqueArtists] = await Promise.all([ + Song.countDocuments(filter), + User.countDocuments(), + Album.countDocuments(filter), Song.aggregate([ - { - $unionWith: { - coll: "albums", - pipeline: [], - }, - }, + { $match: filter }, { $group: { _id: "$artist", @@ -30,7 +29,7 @@ const getStats = async (req, res, next) => { ]); res.status(200).json({ - totalAlbum, + totalAlbums, totalSongs, totalUsers, totalArtists: uniqueArtists[0]?.count || 0, diff --git a/backend/src/index.js b/backend/src/index.js index 1ceb4bc..12798df 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -34,7 +34,7 @@ app.use( useTempFiles: true, tempFileDir: path.join(projectRoot, "tmp"), createParentPath: true, - limits: { fileSize: 10 * 1024 * 1024 }, + limits: { fileSize: 50 * 1024 * 1024 }, }) ); @@ -50,6 +50,7 @@ app.use('/api/auth', require('./routes/auth.route')); app.use('/api/songs', require('./routes/songs.route')); app.use('/api/admin', require('./routes/admin.route')); app.use('/api/albums', require('./routes/album.route')); +app.use('/api/playlists', require('./routes/playlist.route')); app.use('/api/stats', require('./routes/stats.route')); app.get('/', (req, res) => res.send('API is running...')); @@ -60,7 +61,7 @@ Sentry.setupExpressErrorHandler(app); // 6. Custom Global Error Handler app.use((err, req, res, next) => { Logger.error(`${req.method} ${req.url} - ${err.message}`); - + // Safety check for CORS on error response const origin = req.headers.origin; const allowedOrigins = ["http://localhost:3000", "https://musicshoot.vercel.app"]; diff --git a/backend/src/models/album.model.js b/backend/src/models/album.model.js index fbb82ab..86e33a4 100644 --- a/backend/src/models/album.model.js +++ b/backend/src/models/album.model.js @@ -21,7 +21,10 @@ const albumSchema = new mongoose.Schema({ type: mongoose.Schema.Types.ObjectId, ref: 'Song', }], - + creator: { + type: String, // clerkId + required: false, + }, },{timestamps: true}); const Album = mongoose.model('Album', albumSchema); diff --git a/backend/src/models/playlist.model.js b/backend/src/models/playlist.model.js new file mode 100644 index 0000000..36b46a8 --- /dev/null +++ b/backend/src/models/playlist.model.js @@ -0,0 +1,27 @@ +const mongoose = require('mongoose'); + +const playlistSchema = new mongoose.Schema({ + name: { + type: String, + required: true, + }, + description: { + type: String, + required: false, + }, + imageUrl: { + type: String, + required: false, + }, + creator: { + type: String, // clerkId + required: true, + }, + songs: [{ + type: mongoose.Schema.Types.ObjectId, + ref: 'Song', + }], +}, { timestamps: true }); + +const Playlist = mongoose.model('Playlist', playlistSchema); +module.exports = Playlist; diff --git a/backend/src/models/song.model.js b/backend/src/models/song.model.js index 73f0e1d..b190481 100644 --- a/backend/src/models/song.model.js +++ b/backend/src/models/song.model.js @@ -26,6 +26,10 @@ const songSchema = new mongoose.Schema({ ref: 'Album', required: false, }, + creator: { + type: String, // clerkId + required: false, // Optional for existing seeded data + }, },{timestamps: true}); diff --git a/backend/src/routes/admin.route.js b/backend/src/routes/admin.route.js index ecfcff8..07ee757 100644 --- a/backend/src/routes/admin.route.js +++ b/backend/src/routes/admin.route.js @@ -7,11 +7,8 @@ const { createSong, deleteAlbum, createAlbum, deleteSong, checkAdmin } = require router.get('/check', protectRoute, requireAdmin, checkAdmin); // Sample route to get all users -router.post('/songs', protectRoute, requireAdmin, createSong); -router.delete('/songs/:id', protectRoute, requireAdmin, deleteSong); - -router.post('/albums', protectRoute, requireAdmin, createAlbum); -router.delete('/albums/:id', protectRoute, requireAdmin, deleteAlbum); +// Admin-only deletion/management can be kept here if desired, +// but creation is now open to all in their respective routes. module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/album.route.js b/backend/src/routes/album.route.js index 2b063a4..56303ec 100644 --- a/backend/src/routes/album.route.js +++ b/backend/src/routes/album.route.js @@ -1,10 +1,15 @@ const express = require('express'); -const { AllAlbums, AllAlbumsById } = require('../controllers/album.controller'); +const { AllAlbums, AllAlbumsById, createAlbum, deleteAlbum, updateAlbum } = require("../controllers/album.controller"); +const { protectRoute } = require("../middleware/auth.middleware"); const router = express.Router(); // Sample route to get all users -router.get('/', AllAlbums); +// Protected route to fetch albums; supports filtering by ?user=true +router.get('/', protectRoute, AllAlbums); router.get('/:albumId', AllAlbumsById); +router.post("/", protectRoute, createAlbum); +router.put("/:id", protectRoute, updateAlbum); +router.delete("/:id", protectRoute, deleteAlbum); module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/playlist.route.js b/backend/src/routes/playlist.route.js new file mode 100644 index 0000000..365ccb1 --- /dev/null +++ b/backend/src/routes/playlist.route.js @@ -0,0 +1,24 @@ +const express = require('express'); +const router = express.Router(); +const { protectRoute } = require('../middleware/auth.middleware'); +const { + createPlaylist, + getUserPlaylists, + getPlaylistById, + updatePlaylist, + deletePlaylist, + addSongToPlaylist, + removeSongFromPlaylist, +} = require('../controllers/playlist.controller'); + +router.use(protectRoute); + +router.post('/', createPlaylist); +router.get('/', getUserPlaylists); +router.get('/:id', getPlaylistById); +router.put('/:id', updatePlaylist); +router.delete('/:id', deletePlaylist); +router.post('/:id/songs', addSongToPlaylist); +router.delete('/:id/songs/:songId', removeSongFromPlaylist); + +module.exports = router; diff --git a/backend/src/routes/songs.route.js b/backend/src/routes/songs.route.js index 9f3b838..dc5c90f 100644 --- a/backend/src/routes/songs.route.js +++ b/backend/src/routes/songs.route.js @@ -1,14 +1,18 @@ const express = require('express'); -const { getAllSongs, getSongsForYou, getFeaturedSongs, getTrendingSongs, getSingleSong } = require('../controllers/song.controller'); -const { protectRoute, requireAdmin } = require('../middleware/auth.middleware'); +const { getAllSongs, getSongsForYou, getFeaturedSongs, getTrendingSongs, getSingleSong, createSong, deleteSong, updateSong } = require('../controllers/song.controller'); +const { protectRoute } = require('../middleware/auth.middleware'); const router = express.Router(); // Sample route to get all users -router.get('/', protectRoute, requireAdmin, getAllSongs); +router.get('/', protectRoute, getAllSongs); router.get('/featured', getFeaturedSongs); router.get('/made-for-you', getSongsForYou); router.get('/trending', getTrendingSongs); router.get('/single', getSingleSong); +router.post('/', protectRoute, createSong); +router.put('/:id', protectRoute, updateSong); +router.delete('/:id', protectRoute, deleteSong); + module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/stats.route.js b/backend/src/routes/stats.route.js index 10f2a77..d113d7f 100644 --- a/backend/src/routes/stats.route.js +++ b/backend/src/routes/stats.route.js @@ -4,7 +4,7 @@ const router = express.Router(); const { protectRoute, requireAdmin } = require('../middleware/auth.middleware'); // Sample route to get all users -router.get('/', protectRoute, requireAdmin, getStats); +router.get('/', protectRoute, getStats); module.exports = router; \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 31f14e2..905b71e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,9 @@ "version": "0.0.0", "dependencies": { "@clerk/clerk-react": "^5.57.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-scroll-area": "^1.2.10", @@ -621,6 +624,59 @@ "node": ">=20.19.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7252073..46a47d1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,9 @@ }, "dependencies": { "@clerk/clerk-react": "^5.57.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-scroll-area": "^1.2.10", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e5a6976..91a7f77 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { AuthenticateWithRedirectCallback } from "@clerk/clerk-react"; import MainLayout from "./components/layout/MainLayout"; import AlbumPage from "./pages/album/AlbumPage"; import AdminPage from "./pages/admin/AdminPage"; +import PlaylistPage from "./pages/playlists/PlaylistPage"; import NotFoundPage from "./pages/404/NotFoundPage"; const App = () => { @@ -25,13 +26,14 @@ const App = () => { } /> } /> - } /> + } /> }> } /> {/* */} } /> } /> + } /> } /> diff --git a/frontend/src/Providers/AuthProvider.tsx b/frontend/src/Providers/AuthProvider.tsx index 601ab2d..560a944 100644 --- a/frontend/src/Providers/AuthProvider.tsx +++ b/frontend/src/Providers/AuthProvider.tsx @@ -2,13 +2,14 @@ import { axiosInstance } from '@/lib/axios' import { useAuth } from '@clerk/clerk-react' import { useEffect, useState } from 'react' import { LoaderCircle } from 'lucide-react' -import { useAuthStore } from '@/store/useAuthStore'; +// import { useAuthStore } from '@/store/useAuthStore'; import { useChatStore } from '@/store/useChatStore'; -const updateApiToken = (token: string | null) => { - if (token) axiosInstance.defaults.headers.common["Authorization"] = `Bearer ${token}`; - else delete axiosInstance.defaults.headers.common["Authorization"]; -}; +// Replaced updateApiToken with an Axios interceptor pattern for fresh tokens +// const updateApiToken = (token: string | null) => { +// if (token) axiosInstance.defaults.headers.common["Authorization"] = `Bearer ${token}`; +// else delete axiosInstance.defaults.headers.common["Authorization"]; +// }; @@ -16,35 +17,44 @@ const updateApiToken = (token: string | null) => { const AuthProvider = ({ children }: { children: React.ReactNode }) => { const { getToken, userId } = useAuth(); const [loading, setLoading] = useState(true); - const { checkAdminStatus } = useAuthStore() + // const { checkAdminStatus } = useAuthStore() const { initSocket, disconnectSocket } = useChatStore(); useEffect(() => { - const initAuth = async () => { - try { - const token = await getToken(); - // set the Authorization header first so subsequent requests include the token - updateApiToken(token); - if (token) { - await checkAdminStatus(); - //init socket - if (userId) { - initSocket(userId); + const interceptor = axiosInstance.interceptors.request.use( + async (config) => { + try { + const token = await getToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; } + } catch (error) { + console.error("Error setting auth token:", error); } + return config; + }, + (error) => Promise.reject(error) + ); + const initAuth = async () => { + try { + if (userId) { + initSocket(userId); + } } catch (error) { - updateApiToken(null); - console.log("Error in auth provider", error); + console.log("Error in auth provider init:", error); } finally { setLoading(false); } - } - initAuth(); + }; + initAuth(); - //clean up function - return () => disconnectSocket(); - }, [getToken, checkAdminStatus, userId, initSocket, disconnectSocket]); + // Clean up: disconnect socket AND remove interceptor + return () => { + disconnectSocket(); + axiosInstance.interceptors.request.eject(interceptor); + }; + }, [getToken, userId, initSocket, disconnectSocket]); if (loading) { return ( diff --git a/frontend/src/components/layout/components/CreatePlaylistDialog.tsx b/frontend/src/components/layout/components/CreatePlaylistDialog.tsx new file mode 100644 index 0000000..31080d0 --- /dev/null +++ b/frontend/src/components/layout/components/CreatePlaylistDialog.tsx @@ -0,0 +1,83 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Plus } from "lucide-react"; +import { usePlaylistStore } from "../../../store/usePlaylistStore"; + +const CreatePlaylistDialog = () => { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [isOpen, setIsOpen] = useState(false); + const { createPlaylist, isLoading } = usePlaylistStore(); + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!name.trim()) return; + + await createPlaylist({ name, description }); + setName(""); + setDescription(""); + setIsOpen(false); + }; + + return ( + + + + + + + Create Playlist + + Give your playlist a name and description. + + +
+
+ + setName(e.target.value)} + className="bg-zinc-800 border-zinc-700 focus:ring-zinc-600" + placeholder="My Awesome Playlist" + required + /> +
+
+ + setDescription(e.target.value)} + className="bg-zinc-800 border-zinc-700 focus:ring-zinc-600" + placeholder="A collection of my favorite tracks" + /> +
+ + + +
+
+
+ ); +}; + +export default CreatePlaylistDialog; diff --git a/frontend/src/components/layout/components/LeftSidebar.jsx b/frontend/src/components/layout/components/LeftSidebar.jsx index f881dbb..8734613 100644 --- a/frontend/src/components/layout/components/LeftSidebar.jsx +++ b/frontend/src/components/layout/components/LeftSidebar.jsx @@ -7,6 +7,9 @@ import { Link } from 'react-router-dom' import { ScrollArea } from "@/components/ui/scroll-area" import PlaylistSkeleton from '../../skeletons/PlaylistSkeleton.jsx' import { useMusicStore } from '../../../store/useMusicStore.js' +import { usePlaylistStore } from '../../../store/usePlaylistStore' +import { Plus } from 'lucide-react' +import CreatePlaylistDialog from './CreatePlaylistDialog' // icons @@ -18,13 +21,15 @@ const LeftSidebar = () => { // const [playlists, SetPlaylists] = useState([]); // useMusicStore - const { albums, isLoading, fetchAlbums } = useMusicStore(); + const { albums, isLoading: isMusicLoading, fetchAlbums } = useMusicStore(); + const { playlists, isLoading: isPlaylistLoading, fetchPlaylists } = usePlaylistStore(); useEffect(() => { fetchAlbums(); - // console.log({ albums }); + fetchPlaylists(); + }, [fetchAlbums, fetchPlaylists]); - }, []); + const isLoading = isMusicLoading || isPlaylistLoading; // placeholder for collapse handler (kept for future use) @@ -60,8 +65,9 @@ const LeftSidebar = () => {
- Playlists + Your Library
+
@@ -78,7 +84,7 @@ const LeftSidebar = () => { > Playlist img @@ -89,6 +95,32 @@ const LeftSidebar = () => { )) } + { + playlists.map((playlist) => ( + +
+ {playlist.imageUrl ? ( + Playlist img + ) : ( + + )} +
+ +
+

{playlist.name}

+

Playlist • User

+
+ + )) + } ) } diff --git a/frontend/src/components/layout/components/TopHeader.jsx b/frontend/src/components/layout/components/TopHeader.jsx index 6347b78..17c8c26 100644 --- a/frontend/src/components/layout/components/TopHeader.jsx +++ b/frontend/src/components/layout/components/TopHeader.jsx @@ -105,16 +105,14 @@ const TopHeader = () => {
- { - isAdmin && ( - - - Admin Dashboard - - ) - } + + + + Dashboard + + {/* */} diff --git a/frontend/src/components/playlist/AddToPlaylistDialog.tsx b/frontend/src/components/playlist/AddToPlaylistDialog.tsx new file mode 100644 index 0000000..3509366 --- /dev/null +++ b/frontend/src/components/playlist/AddToPlaylistDialog.tsx @@ -0,0 +1,81 @@ +import { useState } from "react"; +import { usePlaylistStore } from "@/store/usePlaylistStore"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Plus, ListMusic } from "lucide-react"; +import { ScrollArea } from "@/components/ui/scroll-area"; + +interface AddToPlaylistDialogProps { + songId: string; +} + +const AddToPlaylistDialog = ({ songId }: AddToPlaylistDialogProps) => { + const [isOpen, setIsOpen] = useState(false); + const { playlists, addSongToPlaylist, isLoading } = usePlaylistStore(); + + const handleAddToPlaylist = async (playlistId: string) => { + await addSongToPlaylist(playlistId, songId); + setIsOpen(false); + }; + + return ( + + + + + + + Add to Playlist + + Select a playlist to add this song to. + + + +
+ {playlists.map((playlist) => ( + + ))} + {playlists.length === 0 && ( +
+

You haven't created any playlists yet.

+
+ )} +
+
+
+
+ ); +}; + +export default AddToPlaylistDialog; diff --git a/frontend/src/components/playlist/SortableSongItem.tsx b/frontend/src/components/playlist/SortableSongItem.tsx new file mode 100644 index 0000000..09e9e05 --- /dev/null +++ b/frontend/src/components/playlist/SortableSongItem.tsx @@ -0,0 +1,102 @@ +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { Play, Pause, Trash2, GripVertical } from "lucide-react"; +import { usePlayerStore } from "@/store/usePlayerStore"; +import { formatDuration } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import type { Song } from "@/types"; + +interface SortableSongItemProps { + song: Song; + index: number; + removeSong: () => void; + playSong: () => void; +} + +const SortableSongItem = ({ song, index, removeSong, playSong }: SortableSongItemProps) => { + const { currentSong, isPlaying, togglePlay } = usePlayerStore(); + const isCurrentSong = currentSong?._id === song._id; + + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: song._id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 10 : 1, + opacity: isDragging ? 0.5 : 1, + }; + + const handlePlay = () => { + if (isCurrentSong) { + togglePlay(); + } else { + playSong(); + } + }; + + return ( +
+
+ {index + 1} +
+
+ +
+ +
+ {song.title} +
+

+ {song.title} +

+

{song.artist}

+
+
+ +
+ {song.albumId ? "Album" : "Single"} +
+ +
+ + {formatDuration(song.duration)} + +
+ +
+ +
+
+
+
+ ); +}; + +export default SortableSongItem; diff --git a/frontend/src/components/ui/Topbar.jsx b/frontend/src/components/ui/Topbar.jsx index 28e57a1..fae4b7d 100644 --- a/frontend/src/components/ui/Topbar.jsx +++ b/frontend/src/components/ui/Topbar.jsx @@ -1,9 +1,11 @@ import React from 'react' import { Link } from 'react-router-dom' -import { LayoutDashboard } from 'lucide-react' +import { LayoutDashboard, MessageCircle } from 'lucide-react' import { SignedIn, SignedOut, SignInButton, SignOutButton, UserAvatar, UserButton } from '@clerk/clerk-react' import SignInOAuth from './SignInOAuth' -import { useAuthStore } from '@/store/useAuthStore' +import { useAuthStore } from "../../store/useAuthStore"; +import AddSongDialog from "@/pages/admin/components/AddSongDialog"; +import AddAlbumDialog from "@/pages/admin/components/AddAlbumDialog"; import { buttonVariants } from './button-variants' import {cn} from "@/lib/utils" @@ -18,16 +20,18 @@ const Topbar = () => {
- { - isAdmin && ( - - - Admin Dashboard - - ) - } + + + + Dashboard + + + + Chat + + {/* */} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index bd0c391..28aa000 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -4,3 +4,9 @@ import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +export const formatDuration = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, "0")}`; +}; diff --git a/frontend/src/pages/admin/AdminPage.tsx b/frontend/src/pages/admin/AdminPage.tsx index 433c863..6f03f27 100644 --- a/frontend/src/pages/admin/AdminPage.tsx +++ b/frontend/src/pages/admin/AdminPage.tsx @@ -10,16 +10,15 @@ import { useMusicStore } from '@/store/useMusicStore'; const AdminPage = () => { - const { isAdmin, isLoading } = useAuthStore(); + const { isLoading } = useAuthStore(); const { fetchStats, fetchSongs, fetchAlbums } = useMusicStore(); useEffect(() => { - fetchStats(); - fetchSongs(); - fetchAlbums(); + fetchStats(true); + fetchSongs(true); + fetchAlbums(true); }, [fetchStats, fetchSongs, fetchAlbums]); if (isLoading) return
Loading...
- if (!isAdmin) return
Unauthorized
return (
diff --git a/frontend/src/pages/admin/components/AddAlbumDialog.tsx b/frontend/src/pages/admin/components/AddAlbumDialog.tsx index 74f12cf..68f8240 100644 --- a/frontend/src/pages/admin/components/AddAlbumDialog.tsx +++ b/frontend/src/pages/admin/components/AddAlbumDialog.tsx @@ -9,14 +9,15 @@ import { DialogTrigger, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; -import { axiosInstance } from "@/lib/axios"; import { Plus, Upload } from "lucide-react"; import { useRef, useState } from "react"; import toast from "react-hot-toast"; +import { useMusicStore } from "@/store/useMusicStore"; const AddAlbumDialog = () => { const [albumDialogOpen, setAlbumDialogOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); + const { fetchAlbums } = useMusicStore(); const fileInputRef = useRef(null); const [newAlbum, setNewAlbum] = useState({ @@ -48,7 +49,7 @@ const AddAlbumDialog = () => { formData.append("releaseYear", newAlbum.releaseYear.toString()); formData.append("imageFile", imageFile); - await axiosInstance.post("/admin/albums", formData, { + await axiosInstance.post("/albums", formData, { headers: { "Content-Type": "multipart/form-data", }, @@ -62,6 +63,7 @@ const AddAlbumDialog = () => { setImageFile(null); setAlbumDialogOpen(false); toast.success("Album created successfully"); + fetchAlbums(true); // Refresh albums list with userOnly=true flag } catch (error: unknown) { const e = error as { message?: string }; toast.error("Failed to create album: " + e.message); diff --git a/frontend/src/pages/admin/components/AddSongDialog.tsx b/frontend/src/pages/admin/components/AddSongDialog.tsx index 490979b..28fe9d1 100644 --- a/frontend/src/pages/admin/components/AddSongDialog.tsx +++ b/frontend/src/pages/admin/components/AddSongDialog.tsx @@ -12,10 +12,16 @@ import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { axiosInstance } from "@/lib/axios"; import { useMusicStore } from "@/store/useMusicStore"; -import { Plus, Upload } from "lucide-react"; -import { useRef, useState } from "react"; +import { Plus, Upload, Loader2 } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; import toast from "react-hot-toast"; +declare global { + interface Window { + jsmediatags: any; + } +} + interface NewSong { title: string; artist: string; @@ -24,10 +30,8 @@ interface NewSong { } const AddSongDialog = () => { - const { albums } = useMusicStore(); + const { albums, isLoading, fetchAlbums, fetchSongs } = useMusicStore(); const [songDialogOpen, setSongDialogOpen] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [newSong, setNewSong] = useState({ title: "", artist: "", @@ -40,17 +44,90 @@ const AddSongDialog = () => { image: null, }); + const [imagePreview, setImagePreview] = useState(null); + const [uploadProgress, setUploadProgress] = useState(0); + const [uploadDetails, setUploadDetails] = useState({ loaded: 0, total: 0 }); + const audioInputRef = useRef(null); const imageInputRef = useRef(null); + useEffect(() => { + fetchAlbums(true); + // Load jsmediatags script + const script = document.createElement("script"); + script.src = "https://cdnjs.cloudflare.com/ajax/libs/jsmediatags/3.9.5/jsmediatags.min.js"; + script.async = true; + document.body.appendChild(script); + return () => { + document.body.removeChild(script); + }; + }, [fetchAlbums]); + + const handleAudioSelect = (file: File) => { + setFiles((prev) => ({ ...prev, audio: file })); + + // Create a temporary audio element to read duration + const audio = new Audio(); + audio.src = URL.createObjectURL(file); + audio.onloadedmetadata = () => { + setNewSong((prev) => ({ ...prev, duration: Math.floor(audio.duration).toString() })); + URL.revokeObjectURL(audio.src); + }; + + // Read metadata using jsmediatags + if (window.jsmediatags) { + window.jsmediatags.read(file, { + onSuccess: (tag: any) => { + const { tags } = tag; + setNewSong((prev) => ({ + ...prev, + title: tags.title || prev.title, + artist: tags.artist || prev.artist, + album: tags.album || prev.album, + })); + + if (tags.picture) { + const { data, format } = tags.picture; + let base64String = ""; + for (let i = 0; i < data.length; i++) { + base64String += String.fromCharCode(data[i]); + } + const imageUrl = `data:${format};base64,${window.btoa(base64String)}`; + setImagePreview(imageUrl); + + // Convert base64 to File object for upload + fetch(imageUrl) + .then(res => res.blob()) + .then(blob => { + const imageFile = new File([blob], "artwork.jpg", { type: format }); + setFiles((prev) => ({ ...prev, image: imageFile })); + }); + } + }, + onError: (error: any) => { + console.error("Error reading audio tags:", error); + toast.error("Failed to read audio metadata."); + }, + }); + } + }; + const handleSubmit = async () => { - setIsLoading(true); + // setIsLoading(true); // Removed as isLoading is from store try { if (!files.audio || !files.image) { return toast.error("Please upload both audio and image files"); } + if (!newSong.title.trim()) { + return toast.error("Title is required"); + } + + if (!newSong.artist.trim()) { + return toast.error("Artist is required"); + } + const formData = new FormData(); formData.append("title", newSong.title); @@ -63,10 +140,20 @@ const AddSongDialog = () => { formData.append("audioFile", files.audio); formData.append("imageFile", files.image); - await axiosInstance.post("/admin/songs", formData, { + await axiosInstance.post("/songs", formData, { headers: { "Content-Type": "multipart/form-data", }, + onUploadProgress: (progressEvent) => { + const progress = progressEvent.total + ? Math.round((progressEvent.loaded * 100) / progressEvent.total) + : 0; + setUploadProgress(progress); + setUploadDetails({ + loaded: progressEvent.loaded, + total: progressEvent.total || 0, + }); + }, }); setNewSong({ @@ -80,12 +167,17 @@ const AddSongDialog = () => { audio: null, image: null, }); + setImagePreview(null); // Clear image preview toast.success("Song added successfully"); + fetchSongs(true); // Refresh songs list with userOnly=true flag + setSongDialogOpen(false); // Close dialog on success + setUploadProgress(0); // Reset progress + setUploadDetails({ loaded: 0, total: 0 }); } catch (error: unknown) { const e = error as { message?: string }; toast.error("Failed to add song: " + e.message); } finally { - setIsLoading(false); + // setIsLoading(false); // Removed as isLoading is from store } }; @@ -104,13 +196,27 @@ const AddSongDialog = () => { Add a new song to your music library + {albums.length === 0 && ( +
+

+ Tip: Better to create an album first! +

+

+ Creating an album helps you organize your music and makes it easier for others to discover your work. +

+
+ )} +
setFiles((prev) => ({ ...prev, audio: e.target.files![0] }))} + onChange={(e) => { + const file = e.target.files?.[0]; + if (file) handleAudioSelect(file); + }} /> {
Image selected:
{files.image.name.slice(0, 20)}
+ {imagePreview && ( + Preview + )}
) : ( <> @@ -205,14 +314,62 @@ const AddSongDialog = () => {
+ + {/* Upload Progress Bar */} + {isLoading && ( +
+
+ + {uploadProgress < 100 ? ( + <> +
+ Uploading Your Song... + + ) : ( + <> +
+ Processing Metadata... + + )} + +
+ {uploadProgress}% + {uploadDetails.total > 0 && ( + + {(uploadDetails.loaded / (1024 * 1024)).toFixed(1)}MB / {(uploadDetails.total / (1024 * 1024)).toFixed(1)}MB + + )} +
+
+
+
+
+

+ {uploadProgress < 100 + ? "Please don't close this window until the upload is complete." + : "Upload complete! Finalizing song details..." + } +

+
+ )}
- diff --git a/frontend/src/pages/admin/components/AlbumsTabContent.tsx b/frontend/src/pages/admin/components/AlbumsTabContent.tsx index b8c4d97..4f74da5 100644 --- a/frontend/src/pages/admin/components/AlbumsTabContent.tsx +++ b/frontend/src/pages/admin/components/AlbumsTabContent.tsx @@ -4,9 +4,14 @@ import AddAlbumDialog from "./AddAlbumDialog"; import AlbumsTable from "./AlbumsTable"; +import { useMusicStore } from "@/store/useMusicStore"; +import NoContent from "./NoContent"; + const AlbumsTabContent = () => { + const { albums, isLoading } = useMusicStore(); + return ( - +
@@ -21,7 +26,11 @@ const AlbumsTabContent = () => { - + {albums.length === 0 && !isLoading ? ( + + ) : ( + + )} ); diff --git a/frontend/src/pages/admin/components/AlbumsTable.tsx b/frontend/src/pages/admin/components/AlbumsTable.tsx index 8a16cd2..0f9e0dc 100644 --- a/frontend/src/pages/admin/components/AlbumsTable.tsx +++ b/frontend/src/pages/admin/components/AlbumsTable.tsx @@ -2,14 +2,20 @@ import { Button } from "@/components/ui/button"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { useMusicStore } from "@/store/useMusicStore"; import { Calendar, Music, Trash2 } from "lucide-react"; -import { useEffect } from "react"; +import EditAlbumDialog from "./EditAlbumDialog"; const AlbumsTable = () => { - const { albums, deleteAlbum, fetchAlbums } = useMusicStore(); + const { albums, deleteAlbum, isLoading } = useMusicStore(); + + if (isLoading) { + return ( +
+
Loading albums...
+
+ ); + } - useEffect(() => { - fetchAlbums(); - }, [fetchAlbums]); + // useEffect removed as parent AdminPage handles fetching with proper filtering return ( @@ -24,7 +30,14 @@ const AlbumsTable = () => { - {albums.map((album) => ( + {albums.length === 0 ? ( + + + No albums found. Create your first album! + + + ) : ( + albums.map((album) => ( {album.title} @@ -44,7 +57,8 @@ const AlbumsTable = () => { -
+
+
- ))} + )) + )}
); diff --git a/frontend/src/pages/admin/components/DashboardStats.tsx b/frontend/src/pages/admin/components/DashboardStats.tsx index 769471b..20cfb2b 100644 --- a/frontend/src/pages/admin/components/DashboardStats.tsx +++ b/frontend/src/pages/admin/components/DashboardStats.tsx @@ -1,46 +1,47 @@ import { useMusicStore } from '@/store/useMusicStore'; import StatsCard from './StatsCard'; -import { Library, ListMusic, PlayCircle, Users2 } from "lucide-react"; +import { Library, ListMusic, Users2 } from "lucide-react"; const DashboardStats = () => { - const { stats } = useMusicStore(); + const { stats, isLoading } = useMusicStore(); - if (!stats) return null; + if (isLoading || !stats) { + return ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ); + } const statsData = [ { icon: ListMusic, - label: "Total Songs", + label: "Your Songs", value: stats.totalSongs.toString(), bgColor: "bg-emerald-500/10", iconColor: "text-emerald-500", }, { icon: Library, - label: "Total Albums", + label: "Your Albums", value: stats.totalAlbums.toString(), bgColor: "bg-violet-500/10", iconColor: "text-violet-500", }, { icon: Users2, - label: "Total Artists", + label: "Your Artists", value: stats.totalArtists.toString(), bgColor: "bg-orange-500/10", iconColor: "text-orange-500", }, - { - icon: PlayCircle, - label: "Total Users", - value: stats.totalUsers.toLocaleString(), - bgColor: "bg-sky-500/10", - iconColor: "text-sky-500", - }, ]; return (
-
+
{statsData.map((stat) => ( { + const { isLoading, updateAlbum } = useMusicStore(); + const [open, setOpen] = useState(false); + const [formData, setFormData] = useState({ + title: album.title, + artist: album.artist, + releaseYear: album.releaseYear.toString(), + }); + + const [files, setFiles] = useState<{ image: File | null }>({ + image: null, + }); + + const [imagePreview, setImagePreview] = useState(album.imageUrl); + const imageInputRef = useRef(null); + + const handleSubmit = async () => { + try { + const data = new FormData(); + data.append("title", formData.title); + data.append("artist", formData.artist); + data.append("releaseYear", formData.releaseYear); + if (files.image) data.append("imageFile", files.image); + + await updateAlbum(album._id, data); + setOpen(false); + } catch (error: unknown) { + const e = error as { message?: string }; + toast.error("Failed to update album: " + (e.message || "Unknown error")); + } + }; + + return ( + + + + + + + + Edit Album + Update album details and cover art + + +
+ { + const file = e.target.files?.[0]; + if (file) { + setFiles({ image: file }); + setImagePreview(URL.createObjectURL(file)); + } + }} + /> + +
imageInputRef.current?.click()} + > +
+ {imagePreview ? ( +
+ Preview +
Click to change artwork
+
+ ) : ( + <> +
+ +
+
Upload artwork
+ + )} +
+
+ +
+ + setFormData({ ...formData, title: e.target.value })} + className='bg-zinc-800 border-zinc-700' + /> +
+ +
+ + setFormData({ ...formData, artist: e.target.value })} + className='bg-zinc-800 border-zinc-700' + /> +
+ +
+ + setFormData({ ...formData, releaseYear: e.target.value })} + className='bg-zinc-800 border-zinc-700' + /> +
+
+ + + + + +
+
+ ); +}; + +export default EditAlbumDialog; diff --git a/frontend/src/pages/admin/components/EditSongDialog.tsx b/frontend/src/pages/admin/components/EditSongDialog.tsx new file mode 100644 index 0000000..1d6f7eb --- /dev/null +++ b/frontend/src/pages/admin/components/EditSongDialog.tsx @@ -0,0 +1,172 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { useMusicStore } from "@/store/useMusicStore"; +import { Edit, Upload } from "lucide-react"; +import { useState, useRef } from "react"; +import toast from "react-hot-toast"; +import type { Song } from "@/types"; + +interface EditSongDialogProps { + song: Song; +} + +const EditSongDialog = ({ song }: EditSongDialogProps) => { + const { albums, isLoading, updateSong } = useMusicStore(); + const [open, setOpen] = useState(false); + const [formData, setFormData] = useState({ + title: song.title, + artist: song.artist, + album: song.albumId || "", + duration: song.duration.toString(), + }); + + const [files, setFiles] = useState<{ image: File | null }>({ + image: null, + }); + + const [imagePreview, setImagePreview] = useState(song.imageUrl); + const imageInputRef = useRef(null); + + const handleSubmit = async () => { + try { + const data = new FormData(); + data.append("title", formData.title); + data.append("artist", formData.artist); + data.append("duration", formData.duration); + if (formData.album && formData.album !== "none") data.append("albumId", formData.album); + if (files.image) data.append("imageFile", files.image); + + await updateSong(song._id, data); + setOpen(false); + } catch (error: unknown) { + const e = error as { message?: string }; + toast.error("Failed to update song: " + (e.message || "Unknown error")); + } + }; + + return ( + + + + + + + + Edit Song + Update song details and artwork + + +
+ { + const file = e.target.files?.[0]; + if (file) { + setFiles({ image: file }); + setImagePreview(URL.createObjectURL(file)); + } + }} + /> + +
imageInputRef.current?.click()} + > +
+ {imagePreview ? ( +
+ Preview +
Click to change artwork
+
+ ) : ( + <> +
+ +
+
Upload artwork
+ + )} +
+
+ +
+ + setFormData({ ...formData, title: e.target.value })} + className='bg-zinc-800 border-zinc-700' + /> +
+ +
+ + setFormData({ ...formData, artist: e.target.value })} + className='bg-zinc-800 border-zinc-700' + /> +
+ +
+ + setFormData({ ...formData, duration: e.target.value })} + className='bg-zinc-800 border-zinc-700' + /> +
+ +
+ + +
+
+ + + + + +
+
+ ); +}; + +export default EditSongDialog; diff --git a/frontend/src/pages/admin/components/NoContent.tsx b/frontend/src/pages/admin/components/NoContent.tsx new file mode 100644 index 0000000..a099f78 --- /dev/null +++ b/frontend/src/pages/admin/components/NoContent.tsx @@ -0,0 +1,42 @@ +import { Music, Album, Plus } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface NoContentProps { + type: 'songs' | 'albums'; + onActionClick?: () => void; +} + +const NoContent = ({ type, onActionClick }: NoContentProps) => { + const isSongs = type === 'songs'; + + return ( +
+
+ {isSongs ? ( + + ) : ( + + )} +
+

+ {isSongs ? "No music found" : "No albums found"} +

+

+ {isSongs + ? "Your music library is currently empty. Start uploading your favorite tracks!" + : "You haven't created any albums yet. Organize your songs into albums!"} +

+ {onActionClick && ( + + )} +
+ ); +}; + +export default NoContent; diff --git a/frontend/src/pages/admin/components/SongsTabContent.tsx b/frontend/src/pages/admin/components/SongsTabContent.tsx index 56e2bf3..6927f81 100644 --- a/frontend/src/pages/admin/components/SongsTabContent.tsx +++ b/frontend/src/pages/admin/components/SongsTabContent.tsx @@ -4,9 +4,14 @@ import AddSongDialog from "./AddSongDialog"; import SongsTable from "./SongsTable"; +import { useMusicStore } from "@/store/useMusicStore"; +import NoContent from "./NoContent"; + const SongsTabContent = () => { + const { songs, isSongsLoading } = useMusicStore(); + return ( - +
@@ -20,7 +25,11 @@ const SongsTabContent = () => {
- + {songs.length === 0 && !isSongsLoading ? ( + + ) : ( + + )} ); diff --git a/frontend/src/pages/admin/components/SongsTable.tsx b/frontend/src/pages/admin/components/SongsTable.tsx index 723c68c..945e64c 100644 --- a/frontend/src/pages/admin/components/SongsTable.tsx +++ b/frontend/src/pages/admin/components/SongsTable.tsx @@ -2,11 +2,13 @@ import { Button } from "@/components/ui/button"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { useMusicStore } from "@/store/useMusicStore"; import { Calendar, Trash2 } from "lucide-react"; +import AddToPlaylistDialog from "@/components/playlist/AddToPlaylistDialog"; +import EditSongDialog from "./EditSongDialog"; const SongsTable = () => { - const { songs, isLoading, error, deleteSong } = useMusicStore(); + const { songs, isSongsLoading, error, deleteSong } = useMusicStore(); // const deleteSong = false; - if (isLoading) { + if (isSongsLoading) { return (
Loading songs...
@@ -35,7 +37,14 @@ const SongsTable = () => { - {songs.map((song) => ( + {songs.length === 0 ? ( + + + No music found. Start by uploading some! + + + ) : ( + songs.map((song) => ( {song.title} @@ -50,7 +59,9 @@ const SongsTable = () => { -
+
+ +
- ))} + )) + )} ); diff --git a/frontend/src/pages/album/AlbumPage.tsx b/frontend/src/pages/album/AlbumPage.tsx index 368ca62..5cc7e18 100644 --- a/frontend/src/pages/album/AlbumPage.tsx +++ b/frontend/src/pages/album/AlbumPage.tsx @@ -5,7 +5,38 @@ import { ScrollArea } from "@/components/ui/scroll-area" import { Button } from '@/components/ui/button'; import { Clock, Pause, Play } from 'lucide-react'; import { usePlayerStore } from '@/store/usePlayerStore'; - +import AddToPlaylistDialog from '@/components/playlist/AddToPlaylistDialog'; + + +const ALBUM_THEMES = [ + { name: "Emerald", from: "from-emerald-900/80" }, + { name: "Royal Blue", from: "from-blue-900/80" }, + { name: "Deep Ruby", from: "from-rose-900/80" }, + { name: "Golden Amber", from: "from-amber-900/80" }, + { name: "Midnight Purple", from: "from-violet-900/80" }, + { name: "Deep Forest", from: "from-teal-900/80" }, + { name: "Crimson Red", from: "from-red-900/80" }, + { name: "Midnight Indigo", from: "from-indigo-900/80" }, + { name: "Luxury Pink", from: "from-pink-900/80" }, + { name: "Royal Gold", from: "from-yellow-900/80" }, + { name: "Ocean Deep", from: "from-cyan-900/80" }, + { name: "Deep Mint", from: "from-green-900/80" }, + { name: "Desert Sand", from: "from-orange-900/80" }, + { name: "Nordic Frost", from: "from-sky-900/80" }, + { name: "Mystic Plum", from: "from-fuchsia-900/80" }, + { name: "Deep Ochre", from: "from-amber-800/80" }, + { name: "Velvet Grape", from: "from-purple-900/80" }, +]; + +const getAlbumTheme = (id: string) => { + if (!id) return ALBUM_THEMES[0]; + let hash = 0; + for (let i = 0; i < id.length; i++) { + hash = id.charCodeAt(i) + ((hash << 5) - hash); + } + const index = Math.abs(hash) % ALBUM_THEMES.length; + return ALBUM_THEMES[index]; +}; const AlbumPage = () => { @@ -45,11 +76,16 @@ const AlbumPage = () => { if (!currentAlbum) return playAlbum(currentAlbum?.songs, index) } + const albumTheme = getAlbumTheme(albumId || ""); + return (
-