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.
+
+
+
+
+
+ );
+};
+
+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 = () => {
>
@@ -89,6 +95,32 @@ const LeftSidebar = () => {
))
}
+ {
+ playlists.map((playlist) => (
+
+
+ {playlist.imageUrl ? (
+
+ ) : (
+
+ )}
+
+
+
+
{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 (
+
+
+ e.stopPropagation()}
+ >
+
+
+
+
+
+ Add to Playlist
+
+ Select a playlist to add this song to.
+
+
+
+
+ {playlists.map((playlist) => (
+
handleAddToPlaylist(playlist._id)}
+ disabled={isLoading}
+ >
+
+ {playlist.imageUrl ? (
+
+ ) : (
+
+ )}
+
+
+
{playlist.name}
+
{playlist.songs.length} songs
+
+
+ ))}
+ {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}
+
+
+
+ {isCurrentSong && isPlaying ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ {song.title}
+
+
{song.artist}
+
+
+
+
+ {song.albumId ? "Album" : "Single"}
+
+
+
+
+ {formatDuration(song.duration)}
+
+
+
{
+ e.stopPropagation();
+ removeSong();
+ }}
+ >
+
+
+
+
+
+
+
+
+ );
+};
+
+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..433f6c5 100644
--- a/frontend/src/pages/admin/components/AddAlbumDialog.tsx
+++ b/frontend/src/pages/admin/components/AddAlbumDialog.tsx
@@ -9,14 +9,16 @@ 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";
+import { axiosInstance } from "@/lib/axios";
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 +50,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 +64,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..e503258 100644
--- a/frontend/src/pages/admin/components/AddSongDialog.tsx
+++ b/frontend/src/pages/admin/components/AddSongDialog.tsx
@@ -12,10 +12,33 @@ 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";
+interface JSTag {
+ tags: {
+ title?: string;
+ artist?: string;
+ album?: string;
+ picture?: {
+ data: number[];
+ format: string;
+ };
+ };
+}
+
+declare global {
+ interface Window {
+ jsmediatags: {
+ read: (file: File, options: {
+ onSuccess: (tag: JSTag) => void;
+ onError: (error: { message: string }) => void;
+ }) => void;
+ };
+ }
+}
+
interface NewSong {
title: string;
artist: string;
@@ -24,10 +47,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 +61,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: JSTag) => {
+ 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: { message: string }) => {
+ 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 +157,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 +184,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 +213,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 && (
+
+ )}
) : (
<>
@@ -205,14 +331,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..."
+ }
+
+
+ )}
setSongDialogOpen(false)} disabled={isLoading}>
Cancel
-
- {isLoading ? "Uploading..." : "Add Song"}
+
+ {isLoading ? (
+ <>
+
+ {uploadProgress < 100 ? "Uploading..." : "Processing..."}
+ >
+ ) : (
+ "Add Song"
+ )}
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 (
+
+ );
+ }
- 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) => (
@@ -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 ? (
+
+
+
Click to change artwork
+
+ ) : (
+ <>
+
+
+
+
Upload artwork
+ >
+ )}
+
+
+
+
+ Album Title
+ setFormData({ ...formData, title: e.target.value })}
+ className='bg-zinc-800 border-zinc-700'
+ />
+
+
+
+ Artist
+ setFormData({ ...formData, artist: e.target.value })}
+ className='bg-zinc-800 border-zinc-700'
+ />
+
+
+
+ Release Year
+ setFormData({ ...formData, releaseYear: e.target.value })}
+ className='bg-zinc-800 border-zinc-700'
+ />
+
+
+
+
+ setOpen(false)} disabled={isLoading} className='border-zinc-700 hover:bg-zinc-800'>
+ Cancel
+
+
+ {isLoading ? "Updating..." : "Update Album"}
+
+
+
+
+ );
+};
+
+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 ? (
+
+
+
Click to change artwork
+
+ ) : (
+ <>
+
+
+
+
Upload artwork
+ >
+ )}
+
+
+
+
+ Title
+ setFormData({ ...formData, title: e.target.value })}
+ className='bg-zinc-800 border-zinc-700'
+ />
+
+
+
+ Artist
+ setFormData({ ...formData, artist: e.target.value })}
+ className='bg-zinc-800 border-zinc-700'
+ />
+
+
+
+ Duration (seconds)
+ setFormData({ ...formData, duration: e.target.value })}
+ className='bg-zinc-800 border-zinc-700'
+ />
+
+
+
+ Album (Optional)
+ setFormData({ ...formData, album: value })}
+ >
+
+
+
+
+ No Album
+ {albums.map((album) => (
+
+ {album.title}
+
+ ))}
+
+
+
+
+
+
+ setOpen(false)} disabled={isLoading} className='border-zinc-700 hover:bg-zinc-800'>
+ Cancel
+
+
+ {isLoading ? "Updating..." : "Update Song"}
+
+
+
+
+ );
+};
+
+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 && (
+
+
+ {isSongs ? "Add Song" : "Create Album"}
+
+ )}
+
+ );
+};
+
+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) => (
@@ -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 (
-
+
{
+
@@ -107,9 +144,9 @@ const AlbumPage = () => {
handlePlaySong(index)}
- className={`grid grid-cols-[16px_4fr_2fr_1fr] gap-4 px-4 py-2 text-sm
- text-zinc-400 hover:bg-white/5 rounded-md group cursor-pointer
- `}
+ className={`grid grid-cols-[16px_4fr_2fr_120px_40px] gap-4 px-4 py-2 text-sm
+ text-zinc-400 hover:bg-white/5 rounded-md group cursor-pointer
+ `}
>
{
@@ -137,6 +174,9 @@ const AlbumPage = () => {
{song.createdAt.split("T")[0]}
{formatDuration(song.duration)}
+
e.stopPropagation()}>
+
+
);
}
diff --git a/frontend/src/pages/home/components/FeaturedSection.jsx b/frontend/src/pages/home/components/FeaturedSection.jsx
index 1a94e4f..6cc3b33 100644
--- a/frontend/src/pages/home/components/FeaturedSection.jsx
+++ b/frontend/src/pages/home/components/FeaturedSection.jsx
@@ -1,6 +1,7 @@
import { useMusicStore } from '@/store/useMusicStore'
import FeaturedGridSkeleton from '@/components/skeletons/FeaturedGridSkeleton'
import PlayButton from './PlayButton'
+import AddToPlaylistDialog from '@/components/playlist/AddToPlaylistDialog'
const FeaturedSection = () => {
@@ -26,7 +27,8 @@ const FeaturedSection = () => {
{song.title}
{/*
{song.artist}
*/}
-
diff --git a/frontend/src/pages/home/components/SectionGrid.tsx b/frontend/src/pages/home/components/SectionGrid.tsx
index c29c529..553616a 100644
--- a/frontend/src/pages/home/components/SectionGrid.tsx
+++ b/frontend/src/pages/home/components/SectionGrid.tsx
@@ -1,6 +1,7 @@
import type { Song } from "@/types";
import SectionGridSkeleton from './SectionGridSkeleton'
import { Button } from "@/components/ui/button";
+import AddToPlaylistDialog from "@/components/playlist/AddToPlaylistDialog";
type SectionGridProps = {
@@ -36,6 +37,9 @@ const SectionGrid = ({ title, songs, isLoading }: SectionGridProps) => {
/>
{/*
*/}
+
{song.title}
{song.artist}
diff --git a/frontend/src/pages/playlists/PlaylistPage.tsx b/frontend/src/pages/playlists/PlaylistPage.tsx
new file mode 100644
index 0000000..50915f5
--- /dev/null
+++ b/frontend/src/pages/playlists/PlaylistPage.tsx
@@ -0,0 +1,164 @@
+import { useEffect } from "react";
+import { useParams } from "react-router-dom";
+import { usePlaylistStore } from "@/store/usePlaylistStore";
+import { usePlayerStore } from "@/store/usePlayerStore";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Button } from "@/components/ui/button";
+import { Play, Clock, Trash2, Pause } from "lucide-react";
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+} from "@dnd-kit/core";
+import {
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ verticalListSortingStrategy,
+} from "@dnd-kit/sortable";
+import SortableSongItem from "@/components/playlist/SortableSongItem";
+
+const PlaylistPage = () => {
+ const { id } = useParams();
+ const { currentPlaylist, fetchPlaylistById, reorderSongs, deletePlaylist, removeSongFromPlaylist, isLoading } = usePlaylistStore();
+ const { currentSong, isPlaying, playAlbum, togglePlay } = usePlayerStore();
+
+ const sensors = useSensors(
+ useSensor(PointerSensor),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ })
+ );
+
+ useEffect(() => {
+ if (id) fetchPlaylistById(id);
+ }, [id, fetchPlaylistById]);
+
+ if (isLoading || !currentPlaylist) {
+ return
Loading playlist...
;
+ }
+
+ const handlePlayPlaylist = () => {
+ if (!currentPlaylist.songs.length) return;
+
+ const isCurrentPlaylistPlaying = currentSong?.albumId === currentPlaylist._id;
+ if (isCurrentPlaylistPlaying) {
+ togglePlay();
+ } else {
+ playAlbum(currentPlaylist.songs, 0);
+ }
+ };
+
+ const handlePlaySong = (index: number) => {
+ playAlbum(currentPlaylist.songs, index);
+ };
+
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event;
+
+ if (over && active.id !== over.id) {
+ const oldIndex = currentPlaylist.songs.findIndex((s) => s._id === active.id.toString());
+ const newIndex = currentPlaylist.songs.findIndex((s) => s._id === over.id.toString());
+
+ const newSongs = arrayMove(currentPlaylist.songs, oldIndex, newIndex);
+ reorderSongs(currentPlaylist._id, newSongs);
+ }
+ };
+
+ return (
+
+
+ {/* Header */}
+
+
+
+ {currentPlaylist.imageUrl ? (
+
+ ) : (
+
+ )}
+
+
+
Playlist
+
{currentPlaylist.name}
+
{currentPlaylist.description}
+
+ You
+ • {currentPlaylist.songs.length} songs
+
+
+
+
+
+ {/* Actions */}
+
+
+ {isPlaying && currentSong?.albumId === currentPlaylist._id ? (
+
+ ) : (
+
+ )}
+
+
deletePlaylist(currentPlaylist._id)}
+ >
+
+
+
+
+ {/* Songs List */}
+
+
+
+
+ s._id)}
+ strategy={verticalListSortingStrategy}
+ >
+
+ {currentPlaylist.songs.map((song, index) => (
+ removeSongFromPlaylist(currentPlaylist._id, song._id)}
+ playSong={() => handlePlaySong(index)}
+ />
+ ))}
+
+
+
+
+ {currentPlaylist.songs.length === 0 && (
+
+
Your playlist is empty.
+
Add some songs to get started!
+
+ )}
+
+
+
+ );
+};
+
+export default PlaylistPage;
diff --git a/frontend/src/store/useMusicStore.tsx b/frontend/src/store/useMusicStore.tsx
index 1b6b59c..154270e 100644
--- a/frontend/src/store/useMusicStore.tsx
+++ b/frontend/src/store/useMusicStore.tsx
@@ -18,15 +18,17 @@ interface MusicStore {
isStatsLoading: boolean,
- fetchAlbums: () => Promise
,
+ fetchAlbums: (userOnly?: boolean) => Promise,
fetchAlbumById: (id: string) => Promise
fetchFeaturedSongs: () => Promise,
fetchMadeForYou: () => Promise,
fetchTrendingSongs: () => Promise,
- fetchStats: () => Promise,
- fetchSongs: () => Promise,
+ fetchStats: (userOnly?: boolean) => Promise,
+ fetchSongs: (userOnly?: boolean) => Promise,
deleteSong: (id: string) => Promise,
deleteAlbum: (id: string) => Promise,
+ updateSong: (id: string, formData: FormData) => Promise,
+ updateAlbum: (id: string, formData: FormData) => Promise,
fetchSingleSong: () => Promise,
}
@@ -73,15 +75,15 @@ export const useMusicStore = create((set) => {
fetchFeaturedSongs: () => fetchWrapper('/songs/featured', 'featuredSongs', 'Error fetching featured songs'),
fetchMadeForYou: () => fetchWrapper('/songs/made-for-you', 'madeForYouSongs', 'Error fetching made-for-you songs'),
fetchTrendingSongs: () => fetchWrapper('/songs/trending', 'trendingSongs', 'Error fetching trending songs'),
- fetchStats: () => fetchWrapper('/stats', 'stats', 'Error fetching stats'),
- fetchAlbums: () => fetchWrapper('/albums', 'albums', 'Error fetching albums'),
+ fetchStats: (userOnly = false) => fetchWrapper(`/stats${userOnly ? '?user=true' : ''}`, 'stats', 'Error fetching stats'),
+ fetchAlbums: (userOnly = false) => fetchWrapper(`/albums${userOnly ? '?user=true' : ''}`, 'albums', 'Error fetching albums'),
fetchAlbumById: (id) => fetchWrapper(`/albums/${id}`, 'currentAlbum', 'Error fetching album'),
- fetchSongs: () => fetchWrapper('/songs', 'songs', 'Error fetching songs', 'isSongsLoading'),
+ fetchSongs: (userOnly = false) => fetchWrapper(`/songs${userOnly ? '?user=true' : ''}`, 'songs', 'Error fetching songs', 'isSongsLoading'),
deleteSong: async (id) => {
set({ isLoading: true, error: null });
try {
- await axiosInstance.delete(`/admin/songs/${id}`);
+ await axiosInstance.delete(`/songs/${id}`);
set((state) => ({
songs: state.songs.filter((song) => song._id !== id),
}));
@@ -97,7 +99,7 @@ export const useMusicStore = create((set) => {
deleteAlbum: async (id) => {
set({ isLoading: true, error: null });
try {
- await axiosInstance.delete(`/admin/albums/${id}`);
+ await axiosInstance.delete(`/albums/${id}`);
set((state) => ({
albums: state.albums.filter((album) => album._id !== id),
// If a song belongs to this album, clear its albumId
@@ -113,6 +115,41 @@ export const useMusicStore = create((set) => {
set({ isLoading: false });
}
},
+ updateSong: async (id, formData) => {
+ set({ isLoading: true, error: null });
+ try {
+ const response = await axiosInstance.put(`/songs/${id}`, formData, {
+ headers: { "Content-Type": "multipart/form-data" }
+ });
+ set((state) => ({
+ songs: state.songs.map((song) => song._id === id ? response.data.song : song)
+ }));
+ toast.success("Song updated successfully");
+ } catch (error: unknown) {
+ const e = error as { response?: { data?: { message?: string } } };
+ toast.error(e.response?.data?.message || "Error updating song");
+ } finally {
+ set({ isLoading: false });
+ }
+ },
+
+ updateAlbum: async (id, formData) => {
+ set({ isLoading: true, error: null });
+ try {
+ const response = await axiosInstance.put(`/albums/${id}`, formData, {
+ headers: { "Content-Type": "multipart/form-data" }
+ });
+ set((state) => ({
+ albums: state.albums.map((album) => album._id === id ? response.data.album : album)
+ }));
+ toast.success("Album updated successfully");
+ } catch (error: unknown) {
+ const e = error as { response?: { data?: { message?: string } } };
+ toast.error(e.response?.data?.message || "Error updating album");
+ } finally {
+ set({ isLoading: false });
+ }
+ },
};
});
diff --git a/frontend/src/store/usePlaylistStore.tsx b/frontend/src/store/usePlaylistStore.tsx
new file mode 100644
index 0000000..d5dd0ca
--- /dev/null
+++ b/frontend/src/store/usePlaylistStore.tsx
@@ -0,0 +1,167 @@
+import { create } from 'zustand';
+import { axiosInstance } from '../lib/axios';
+import type { Song } from '@/types';
+import toast from 'react-hot-toast';
+
+interface Playlist {
+ _id: string;
+ name: string;
+ description?: string;
+ imageUrl?: string;
+ creator: string;
+ songs: Song[];
+ createdAt: string;
+ updatedAt: string;
+}
+
+interface PlaylistStore {
+ playlists: Playlist[];
+ currentPlaylist: Playlist | null;
+ isLoading: boolean;
+ error: string | null;
+
+ fetchPlaylists: () => Promise;
+ fetchPlaylistById: (id: string) => Promise;
+ createPlaylist: (data: { name: string; description?: string; imageUrl?: string }) => Promise;
+ updatePlaylist: (id: string, data: Partial) => Promise;
+ deletePlaylist: (id: string) => Promise;
+ addSongToPlaylist: (playlistId: string, songId: string) => Promise;
+ removeSongFromPlaylist: (playlistId: string, songId: string) => Promise;
+ reorderSongs: (playlistId: string, songs: Song[]) => Promise;
+}
+
+export const usePlaylistStore = create((set, get) => ({
+ playlists: [],
+ currentPlaylist: null,
+ isLoading: false,
+ error: null,
+
+ fetchPlaylists: async () => {
+ set({ isLoading: true, error: null });
+ try {
+ const response = await axiosInstance.get('/playlists');
+ set({ playlists: response.data });
+ } catch (error: unknown) {
+ const e = error as { response?: { data?: { message?: string } } };
+ const message = e.response?.data?.message || "Error fetching playlists";
+ set({ error: message });
+ toast.error(message);
+ } finally {
+ set({ isLoading: false });
+ }
+ },
+
+ fetchPlaylistById: async (id) => {
+ set({ isLoading: true, error: null });
+ try {
+ const response = await axiosInstance.get(`/playlists/${id}`);
+ set({ currentPlaylist: response.data });
+ } catch (error: unknown) {
+ const e = error as { response?: { data?: { message?: string } } };
+ const message = e.response?.data?.message || "Error fetching playlist";
+ set({ error: message });
+ toast.error(message);
+ } finally {
+ set({ isLoading: false });
+ }
+ },
+
+ createPlaylist: async (data) => {
+ set({ isLoading: true, error: null });
+ try {
+ const response = await axiosInstance.post('/playlists', data);
+ set((state) => ({ playlists: [...state.playlists, response.data] }));
+ toast.success("Playlist created successfully");
+ } catch (error: unknown) {
+ const e = error as { response?: { data?: { message?: string } } };
+ const message = e.response?.data?.message || "Error creating playlist";
+ toast.error(message);
+ } finally {
+ set({ isLoading: false });
+ }
+ },
+
+ updatePlaylist: async (id, data) => {
+ set({ isLoading: true, error: null });
+ try {
+ const response = await axiosInstance.put(`/playlists/${id}`, data);
+ set((state) => ({
+ playlists: state.playlists.map(p => p._id === id ? response.data : p),
+ currentPlaylist: state.currentPlaylist?._id === id ? response.data : state.currentPlaylist
+ }));
+ toast.success("Playlist updated successfully");
+ } catch (error: unknown) {
+ const e = error as { response?: { data?: { message?: string } } };
+ const message = e.response?.data?.message || "Error updating playlist";
+ toast.error(message);
+ } finally {
+ set({ isLoading: false });
+ }
+ },
+
+ deletePlaylist: async (id) => {
+ set({ isLoading: true, error: null });
+ try {
+ await axiosInstance.delete(`/playlists/${id}`);
+ set((state) => ({
+ playlists: state.playlists.filter(p => p._id !== id),
+ currentPlaylist: state.currentPlaylist?._id === id ? null : state.currentPlaylist
+ }));
+ toast.success("Playlist deleted successfully");
+ } catch (error: unknown) {
+ const e = error as { response?: { data?: { message?: string } } };
+ const message = e.response?.data?.message || "Error deleting playlist";
+ toast.error(message);
+ } finally {
+ set({ isLoading: false });
+ }
+ },
+
+ addSongToPlaylist: async (playlistId, songId) => {
+ try {
+ const response = await axiosInstance.post(`/playlists/${playlistId}/songs`, { songId });
+ set((state) => ({
+ playlists: state.playlists.map(p => p._id === playlistId ? response.data : p),
+ currentPlaylist: state.currentPlaylist?._id === playlistId ? response.data : state.currentPlaylist
+ }));
+ toast.success("Song added to playlist");
+ } catch (error: unknown) {
+ const e = error as { response?: { data?: { message?: string } } };
+ const message = e.response?.data?.message || "Error adding song";
+ toast.error(message);
+ }
+ },
+
+ removeSongFromPlaylist: async (playlistId, songId) => {
+ try {
+ const response = await axiosInstance.delete(`/playlists/${playlistId}/songs/${songId}`);
+ set((state) => ({
+ playlists: state.playlists.map(p => p._id === playlistId ? response.data : p),
+ currentPlaylist: state.currentPlaylist?._id === playlistId ? response.data : state.currentPlaylist
+ }));
+ toast.success("Song removed from playlist");
+ } catch (error: unknown) {
+ const e = error as { response?: { data?: { message?: string } } };
+ const message = e.response?.data?.message || "Error removing song";
+ toast.error(message);
+ }
+ },
+
+ reorderSongs: async (playlistId, songs) => {
+ // Optimistic update
+ const previousPlaylist = get().currentPlaylist;
+ const songIds = songs.map(s => s._id);
+
+ set((state) => ({
+ currentPlaylist: state.currentPlaylist?._id === playlistId ? { ...state.currentPlaylist, songs } : state.currentPlaylist
+ }));
+
+ try {
+ await axiosInstance.put(`/playlists/${playlistId}`, { songs: songIds });
+ } catch {
+ // Rollback on error
+ set({ currentPlaylist: previousPlaylist });
+ toast.error("Failed to save new order");
+ }
+ }
+}));
diff --git a/plan/development-plan.md b/plan/development-plan.md
new file mode 100644
index 0000000..7a0f1cd
--- /dev/null
+++ b/plan/development-plan.md
@@ -0,0 +1,321 @@
+# MusicApp — Future Development Plan
+
+**Date:** March 26, 2026 | **Branch:** `Live-0` | **Status:** Active
+
+---
+
+## Table of Contents
+1. [Project Current State](#1-project-current-state)
+2. [Feature Roadmap](#2-feature-roadmap)
+3. [Chat System Improvements](#3-chat-system-improvements)
+4. [Scaling for 1000+ Users](#4-scaling-for-1000-users)
+5. [Resume Framing](#5-resume-framing)
+6. [Priority Implementation Order](#6-priority-implementation-order)
+
+---
+
+## 1. Project Current State
+
+### ✅ What's Done
+| Feature | Status | Notes |
+|---|---|---|
+| Music streaming & playback | ✅ Complete | Songs, albums, queue |
+| Admin dashboard | ✅ Complete | Upload songs/albums |
+| Clerk authentication | ✅ Complete | Login, sessions, RBAC |
+| Friends activity sidebar | ✅ Complete | Online status, "now playing" |
+| Real-time chat (HTTP + polling) | ✅ Complete | Serverless-safe architecture |
+| Auto-scroll chat UX | ✅ Complete | `useRef` scroll on new message |
+| CI/CD pipeline | ✅ Complete | Lint → Typecheck → Test |
+| Unit tests (18 tests) | ✅ Complete | 100% pass rate |
+| Cloudinary media uploads | ✅ Complete | Songs + album artwork |
+| Production deployment | ✅ Complete | Vercel (frontend + backend) |
+
+### ⚠️ Known Limitations
+- Vercel serverless limits true persistent WebSockets
+- No message pagination (loads all messages at once)
+- No conversation list (can only chat with one person at a time via sidebar)
+- No push notifications
+
+---
+
+## 2. Feature Roadmap
+
+### 🔴 High Priority (Resume Impact)
+
+#### A. User Playlists
+- **What:** Users create, name, and manage personal playlists. Drag-to-reorder songs.
+- **Tech:** MongoDB `Playlist` model → REST CRUD → Zustand store → DnD library
+- **Why it matters:** Demonstrates full-stack CRUD, state management, and UX thinking
+- **Effort:** 3–4 days
+
+#### B. Song Recommendations
+- **What:** "You might also like" section based on what similar users play
+- **Tech:** Simple collaborative filtering OR last.fm/Spotify API lookups
+- **Why it matters:** Shows algorithmic/data thinking beyond basic CRUD
+- **Effort:** 2–3 days
+
+#### C. Music Rooms (Listen Together)
+- **What:** Create a room, share a link, friends join and hear the same song in sync
+- **Tech:** Socket.io rooms + timestamp sync + WebRTC for voice (optional)
+- **Why it matters:** Advanced real-time engineering — very impressive on a resume
+- **Effort:** 5–7 days
+
+#### D. Audio Visualization
+- **What:** Animated waveform/equalizer bars that react to the currently playing song
+- **Tech:** Web Audio API + Canvas or SVG
+- **Why it matters:** Creative, differentiating — makes the portfolio demo memorable
+- **Effort:** 1–2 days
+
+---
+
+### 🟡 Medium Priority (UX Polish)
+
+#### E. Artist Pages
+- Dedicated `/artist/:id` route with bio, discography, follower count
+- Backend: MongoDB aggregation pipelines
+
+#### F. Search with Autocomplete
+- Real-time search across songs, albums, and artists
+- Tech: MongoDB `$text` index OR Algolia free tier
+
+#### G. Dark/Light Theme Toggle
+- `localStorage` persistence + CSS variables
+- Already has dark mode — just add toggle UI
+
+#### H. Song Lyrics Display
+- Fetch from Musixmatch or Genius API
+- Show synchronized or static lyrics during playback
+
+---
+
+### 🟢 Quick Wins (< 1 day each)
+
+| Feature | Implementation |
+|---|---|
+| Typing indicators in chat | `socket.emit("typing", { senderId, receiverId })` |
+| Message read receipts ✓ | Uncomment `read: Boolean` in `message.model.js` |
+| Emoji reactions on messages | Add `reactions: [{ emoji, userId }]` field |
+| Song like/heart button | `likedSongs: [songId]` on User model |
+| Queue reorder | DnD in music player queue sidebar |
+| Copy profile link | Navigator clipboard API + toast |
+
+---
+
+## 3. Chat System Improvements
+
+### Immediate (Already Scaffolded)
+
+**Read Receipts** — `message.model.js` already has these commented out:
+```js
+// read: { type: Boolean, default: false }
+// readAt: { type: Date }
+```
+Steps:
+1. Uncomment the fields
+2. Add `PATCH /messages/:id/read` endpoint
+3. Show ✓✓ ticks in the UI
+
+**Typing Indicators:**
+```ts
+// Sender side
+socket.emit("typing", { receiverId });
+
+// Receiver side
+socket.on("user_typing", ({ senderId }) => setIsTyping(senderId));
+```
+Add a 2s debounce so it stops after they stop typing.
+
+---
+
+### Critical (Must-Have Before Real Users)
+
+**Message Pagination**
+
+Currently ALL messages load at once. With 500+ messages this is very slow.
+
+```js
+// Backend
+const messages = await Message
+ .find({ $or: [...] })
+ .sort({ createdAt: -1 })
+ .limit(20)
+ .skip(page * 20);
+
+// Frontend — load more on scroll up
+const { page, setPage } = useChatStore();
+// Trigger fetchMessages(userId, page + 1) when user scrolls to top
+```
+
+**Conversation List**
+
+Allow users to see ALL active conversations in a sidebar:
+```js
+// Aggregate last message per conversation
+Message.aggregate([
+ { $match: { $or: [{ senderId: userId }, { receiverId: userId }] } },
+ { $sort: { createdAt: -1 } },
+ { $group: { _id: "$conversationId", lastMessage: { $first: "$$ROOT" } } }
+])
+```
+
+**Message Search:**
+```js
+messageSchema.index({ content: "text" });
+Message.find({ $text: { $search: query } })
+```
+
+---
+
+## 4. Scaling for 1000+ Users
+
+### Current Architecture (Serverless → Problem)
+```
+User → Vercel Serverless Function → MongoDB
+ ↕ Socket.io FAILS here (no persistent process)
+```
+
+### Target Architecture (1000+ Users)
+
+```
+┌─────────────────────────────────────────────────────┐
+│ CLIENT (React) │
+└───────────────────────┬─────────────────────────────┘
+ │ HTTP + WebSocket
+ ┌─────────▼──────────┐
+ │ Load Balancer │
+ └────┬──────────┬────┘
+ │ │
+ ┌───────▼──┐ ┌────▼──────┐
+ │ Node #1 │ │ Node #2 │ ← Multiple instances
+ └───────┬──┘ └────┬──────┘
+ │ │
+ ┌───────▼──────────▼────────┐
+ │ Redis (Pub/Sub + Cache) │ ← Shared real-time state
+ └───────────────────────────┘
+ │
+ ┌─────────────▼─────────────┐
+ │ MongoDB Atlas │
+ └───────────────────────────┘
+```
+
+### Step-by-Step Scaling Plan
+
+#### Step 1 — Move Backend to Render (Free Tier)
+- Persistent Node.js process → Socket.io works fully
+- Takes 30 minutes to deploy
+- Update `VITE_BACKEND_URL` in frontend env
+
+#### Step 2 — Add Redis for Socket.io Multi-Instance Sync
+```bash
+npm install @socket.io/redis-adapter redis
+```
+```js
+// socket.js
+const { createAdapter } = require("@socket.io/redis-adapter");
+const { createClient } = require("redis");
+
+const pubClient = createClient({ url: process.env.REDIS_URL });
+const subClient = pubClient.duplicate();
+await Promise.all([pubClient.connect(), subClient.connect()]);
+io.adapter(createAdapter(pubClient, subClient));
+```
+
+#### Step 3 — Cache Hot Data in Redis
+```js
+// Songs don't change often — cache them
+const cached = await redis.get("all_songs");
+if (cached) return res.json(JSON.parse(cached));
+
+const songs = await Song.find();
+await redis.setex("all_songs", 300, JSON.stringify(songs)); // 5 min TTL
+return res.json(songs);
+```
+
+#### Step 4 — MongoDB Indexes (Critical for Query Speed)
+```js
+// message.model.js
+messageSchema.index({ senderId: 1, receiverId: 1 });
+messageSchema.index({ createdAt: -1 });
+
+// user.model.js
+userSchema.index({ clerkId: 1 }, { unique: true });
+
+// song.model.js
+songSchema.index({ title: "text", artist: "text" }); // enables text search
+```
+
+#### Step 5 — Horizontal Scaling on Render
+Render supports multiple instances. With Redis adapter, all instances share Socket.io state — seamlessly handles thousands of concurrent users.
+
+---
+
+### Load Capacity Estimate
+
+| Setup | Concurrent Users | Message Rate |
+|---|---|---|
+| Current (Vercel serverless) | ~50 | Low (no real sockets) |
+| Render (single instance) | ~500 | ~100 msg/s |
+| Render + Redis (2 instances) | ~2,000 | ~500 msg/s |
+| Render + Redis (auto-scale) | 10,000+ | 2,000+ msg/s |
+
+---
+
+## 5. Resume Framing
+
+### Project Description (1–2 lines)
+> *Full-stack music streaming platform with real-time chat, admin content management, and a CI/CD pipeline. Built with React, Node.js, Socket.io, MongoDB, and Clerk for authentication. Deployed on Vercel with production-grade error handling and unit testing.*
+
+### Bullet Points (Pick 4–5 for resume)
+
+```
+• Architected a resilient chat system using Socket.io + HTTP POST fallback,
+ ensuring 100% message delivery on Vercel's serverless infrastructure
+
+• Built a CI/CD pipeline (GitHub Actions) with lint, type-check, and 18
+ isolated unit tests (Vitest) — achieving a 100% pass rate
+
+• Resolved Clerk SDK mocking in CommonJS environments using a monkey-patching
+ strategy for fully isolated, dependency-free unit testing
+
+• Implemented optimistic UI updates and duplicate-prevention logic in Zustand
+ for a premium real-time chat experience
+
+• Administered full cloud media pipeline using Cloudinary for song and album
+ artwork uploads with Clerk-based RBAC for admin access control
+
+• Designed scalable architecture with Redis adapter for Socket.io and MongoDB
+ indexing to handle 1000+ concurrent users
+```
+
+### Talking Points for Interviews
+
+| Question | Your Answer |
+|---|---|
+| *"What was hardest?"* | Clerk SDK mocking — CommonJS bundling prevented standard `vi.mock()`. Solved with monkey-patching. |
+| *"How did you handle production bugs?"* | Chat relied solely on Socket.io which fails on Vercel — refactored to HTTP-first with a 4s polling fallback. |
+| *"How would you scale this?"* | Redis pub/sub adapter for Socket.io multi-instance sync + MongoDB indexes + Render for persistent WebSockets. |
+| *"What would you improve?"* | Message pagination (currently loads all), read receipts (model scaffolded), and moving to Render for true WebSockets. |
+
+---
+
+## 6. Priority Implementation Order
+
+```
+Phase 1 — Quick Wins (This Week)
+ [1] Read receipts — uncomment model fields, add PATCH route
+ [2] Typing indicators — 30 min socket work
+ [3] Message pagination — critical for performance
+ [4] Song like button — simple model addition
+
+Phase 2 — Core Features (Next 2 Weeks)
+ [5] User playlists — CRUD + drag-to-reorder
+ [6] Conversation list — aggregate last message per user
+ [7] Search with autocomplete — MongoDB text index
+
+Phase 3 — Scale & Polish (Month 2)
+ [8] Move backend to Render
+ [9] Add Redis + socket adapter
+ [10] Music rooms (listen together)
+ [11] Audio waveform visualizer
+ [12] Song recommendations
+```