├── sc.png ├── client ├── vercel.json ├── public │ ├── cover.jpg │ ├── cover.png │ ├── cat_chill.png │ ├── cat_pop.png │ ├── cat_rock.png │ ├── favicon.ico │ ├── cat_hiphop.png │ ├── cat_podcast.png │ ├── cat_romance.png │ ├── gradient_bg.jpg │ ├── decided_cover.jpg │ └── vite.svg ├── src │ ├── api │ │ └── index.js │ ├── theme │ │ ├── motionVariants.js │ │ └── index.js │ ├── utils │ │ └── index.js │ ├── pages │ │ ├── ErrorPage.jsx │ │ ├── HomePage.jsx │ │ ├── LibraryPage.jsx │ │ ├── ArtistesPage.jsx │ │ ├── PlaylistsPage.jsx │ │ ├── FavoritesPage.jsx │ │ ├── ArtistePage.jsx │ │ ├── LoginPage.jsx │ │ ├── RegisterPage.jsx │ │ ├── PlaylistPage.jsx │ │ ├── EditPlaylistPage.jsx │ │ └── CreatePlaylistPage.jsx │ ├── redux │ │ ├── slices │ │ │ ├── modalSlice.js │ │ │ ├── userSlice.js │ │ │ └── playerSlice.js │ │ └── store.js │ ├── components │ │ ├── Search.jsx │ │ ├── MusicPlayer │ │ │ ├── TrackDetails.jsx │ │ │ ├── VolumeControl.jsx │ │ │ ├── PlayingBar.jsx │ │ │ ├── PlayControls.jsx │ │ │ └── index.jsx │ │ ├── ArtisteCard.jsx │ │ ├── PlaylistSong.jsx │ │ ├── LoadingSkeleton.jsx │ │ ├── PlaylistCard.jsx │ │ ├── HomeHero.jsx │ │ ├── LoginModal.jsx │ │ ├── CreatePlaylistCard.jsx │ │ ├── Categories.jsx │ │ ├── Artistes.jsx │ │ ├── HorizontalMusicCard.jsx │ │ ├── SmallSection.jsx │ │ ├── TopCharts.jsx │ │ ├── ArtisteSong.jsx │ │ ├── SongCard.jsx │ │ └── Navbar.jsx │ ├── layouts │ │ ├── HomeLayout.jsx │ │ └── AuthLayout.jsx │ ├── main.jsx │ ├── index.css │ ├── router │ │ └── index.jsx │ └── assets │ │ └── react.svg ├── vite.config.js ├── .eslintrc.cjs ├── index.html └── package.json ├── .gitignore ├── server ├── vercel.json ├── config │ └── dbConnection.js ├── routes │ ├── artisteRoutes.js │ ├── userRoutes.js │ ├── playlistRoutes.js │ └── songRoutes.js ├── models │ ├── Artiste.js │ ├── User.js │ ├── Playlist.js │ └── Song.js ├── package.json ├── middleware │ └── validateToken.js ├── index.js └── controllers │ ├── artistController.js │ ├── userController.js │ ├── songController.js │ └── playlistController.js └── README.md /sc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ikennaezef/beatbox_music_app/HEAD/sc.png -------------------------------------------------------------------------------- /client/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "routes": [{ "src": "/[^.]+", "dest": "/", "status": 200 }] 3 | } 4 | -------------------------------------------------------------------------------- /client/public/cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ikennaezef/beatbox_music_app/HEAD/client/public/cover.jpg -------------------------------------------------------------------------------- /client/public/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ikennaezef/beatbox_music_app/HEAD/client/public/cover.png -------------------------------------------------------------------------------- /client/public/cat_chill.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ikennaezef/beatbox_music_app/HEAD/client/public/cat_chill.png -------------------------------------------------------------------------------- /client/public/cat_pop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ikennaezef/beatbox_music_app/HEAD/client/public/cat_pop.png -------------------------------------------------------------------------------- /client/public/cat_rock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ikennaezef/beatbox_music_app/HEAD/client/public/cat_rock.png -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ikennaezef/beatbox_music_app/HEAD/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/cat_hiphop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ikennaezef/beatbox_music_app/HEAD/client/public/cat_hiphop.png -------------------------------------------------------------------------------- /client/public/cat_podcast.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ikennaezef/beatbox_music_app/HEAD/client/public/cat_podcast.png -------------------------------------------------------------------------------- /client/public/cat_romance.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ikennaezef/beatbox_music_app/HEAD/client/public/cat_romance.png -------------------------------------------------------------------------------- /client/public/gradient_bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ikennaezef/beatbox_music_app/HEAD/client/public/gradient_bg.jpg -------------------------------------------------------------------------------- /client/public/decided_cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ikennaezef/beatbox_music_app/HEAD/client/public/decided_cover.jpg -------------------------------------------------------------------------------- /client/src/api/index.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | export const client = axios.create({ 4 | baseURL: "https://beatbox-music-backend.vercel.app/api/", 5 | }); 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | .env 4 | 5 | 6 | .vscode/* 7 | !.vscode/extensions.json 8 | .idea 9 | .DS_Store 10 | *.suo 11 | *.ntvs* 12 | *.njsproj 13 | *.sln 14 | *.sw? -------------------------------------------------------------------------------- /client/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /server/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "builds": [ 4 | { 5 | "src": "*.js", 6 | "use": "@vercel/node" 7 | } 8 | ], 9 | "routes": [ 10 | { 11 | "src": "/(.*)", 12 | "dest": "/" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /server/config/dbConnection.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | export const connectDb = async () => { 4 | try { 5 | const connect = await mongoose.connect(process.env.MONGO_CONNECTION_STRING); 6 | console.log("DATABASE CONNECTED", connect.connection.name); 7 | } catch (error) { 8 | console.log(error); 9 | process.exit(1); 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /server/routes/artisteRoutes.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { 3 | getArtiste, 4 | getArtistes, 5 | getTopArtistes, 6 | } from "../controllers/artistController.js"; 7 | 8 | const router = express.Router(); 9 | 10 | router.get("/all", getArtistes); 11 | router.get("/top", getTopArtistes); 12 | router.get("/:id", getArtiste); 13 | 14 | export { router as artisteRouter }; 15 | -------------------------------------------------------------------------------- /client/src/theme/motionVariants.js: -------------------------------------------------------------------------------- 1 | export const appear = { 2 | initial: { 3 | opacity: 0, 4 | }, 5 | animate: { 6 | opacity: 1, 7 | transition: { duration: 1, delay: 0.5 }, 8 | }, 9 | }; 10 | 11 | export const fadeInUp = { 12 | initial: { 13 | y: 30, 14 | opacity: 0, 15 | }, 16 | animate: { 17 | y: 0, 18 | opacity: 1, 19 | transition: { duration: 1, type: "easeOut" }, 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /client/src/utils/index.js: -------------------------------------------------------------------------------- 1 | export const convertToMins = (value) => { 2 | const mins = Math.floor(value / 60); 3 | const secs = Math.round(value - mins * 60, 2); 4 | const formattedSeconds = secs < 10 ? "0" + secs : secs; 5 | return `${mins}:${formattedSeconds}`; 6 | }; 7 | 8 | export const truncateText = (text, length) => { 9 | if (text.length > length) { 10 | return text.slice(0, length) + "..."; 11 | } else return text; 12 | }; 13 | -------------------------------------------------------------------------------- /client/src/pages/ErrorPage.jsx: -------------------------------------------------------------------------------- 1 | import { Box, Flex, Heading, Text } from "@chakra-ui/react"; 2 | 3 | const ErrorPage = () => { 4 | return ( 5 | 6 | 7 | 404 8 | An error occured. Page Not Found! 9 | 10 | 11 | ); 12 | }; 13 | 14 | export default ErrorPage; 15 | -------------------------------------------------------------------------------- /server/models/Artiste.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | const ArtisteSchema = new mongoose.Schema({ 4 | name: { 5 | type: String, 6 | required: true, 7 | }, 8 | image: { 9 | type: String, 10 | required: true, 11 | }, 12 | type: { 13 | type: String, 14 | require: true, 15 | default: "Artiste", 16 | }, 17 | bio: { 18 | type: String, 19 | }, 20 | }); 21 | 22 | const Artiste = mongoose.model("Artiste", ArtisteSchema); 23 | export default Artiste; 24 | -------------------------------------------------------------------------------- /server/routes/userRoutes.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { 3 | getUserFavoriteSongs, 4 | loginUser, 5 | registerUser, 6 | } from "../controllers/userController.js"; 7 | import { verifyToken } from "../middleware/validateToken.js"; 8 | 9 | const router = express.Router(); 10 | 11 | router.post("/login", loginUser); 12 | router.post("/register", registerUser); 13 | router.get("/favorites", verifyToken, getUserFavoriteSongs); 14 | 15 | export { router as userRouter }; 16 | -------------------------------------------------------------------------------- /server/models/User.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | const UserSchema = new mongoose.Schema({ 4 | username: { 5 | type: String, 6 | required: true, 7 | unique: true, 8 | }, 9 | password: { 10 | type: String, 11 | required: true, 12 | }, 13 | favorites: { 14 | type: Array, 15 | default: [], 16 | }, 17 | playlists: { 18 | type: Array, 19 | default: [], 20 | }, 21 | }); 22 | 23 | const User = mongoose.model("User", UserSchema); 24 | export default User; 25 | -------------------------------------------------------------------------------- /client/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { browser: true, es2020: true }, 3 | extends: [ 4 | "eslint:recommended", 5 | "plugin:react/recommended", 6 | "plugin:react/jsx-runtime", 7 | "plugin:react-hooks/recommended", 8 | ], 9 | parserOptions: { ecmaVersion: "latest", sourceType: "module" }, 10 | settings: { react: { version: "18.2" } }, 11 | plugins: ["react-refresh"], 12 | rules: { 13 | "react-refresh/only-export-components": "warn", 14 | "react/prop-types": "off", 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /client/src/redux/slices/modalSlice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | 3 | const initialState = { 4 | message: "Please login to save songs to your favorites.", 5 | }; 6 | 7 | export const modalSlice = createSlice({ 8 | name: "modal", 9 | initialState, 10 | reducers: { 11 | setModalMessage: (state, action) => { 12 | state.message = action.payload; 13 | }, 14 | }, 15 | }); 16 | 17 | export const { setModalMessage } = modalSlice.actions; 18 | 19 | export default modalSlice.reducer; 20 | -------------------------------------------------------------------------------- /server/routes/playlistRoutes.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { 3 | createPlaylist, 4 | editPlaylist, 5 | getPlaylist, 6 | getPlaylists, 7 | } from "../controllers/playlistController.js"; 8 | import { verifyToken } from "../middleware/validateToken.js"; 9 | 10 | const router = express.Router(); 11 | 12 | router.get("/", getPlaylists); 13 | router.get("/:id", getPlaylist); 14 | router.post("/create", verifyToken, createPlaylist); 15 | router.patch("/:id", verifyToken, editPlaylist); 16 | 17 | export { router as playlistRouter }; 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BeatBox Music 🎵 2 | 3 | ### ✨ [Live Demo](https://beatbox-music.vercel.app) 4 | 5 | ## Overview 6 | 7 | BeatBox is a music app where users can listen to music, save music to their accounts and also create awesome playlists. 8 | 9 | ## Technologies Used 10 | 11 | Built on the MERN Stack with `NodeJs` `ReactJs` `Express` `Chakra UI` `Redux Toolkit` and `MongoDB` for Database Management and storage. 12 | 13 | ## Author 14 | 15 | 👤 **Ikenna Eze**
16 | Leave a ⭐️ If you like this project! 17 | 18 | - Website: https://ikennaezef.netlify.app 19 | 20 | ## Screenshot 21 | 22 | ![Home](./sc.png) 23 | -------------------------------------------------------------------------------- /server/routes/songRoutes.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { 3 | getAroundYou, 4 | getNewReleases, 5 | getRandom, 6 | getSongs, 7 | getTopSongs, 8 | likeSong, 9 | } from "../controllers/songController.js"; 10 | import { verifyToken } from "../middleware/validateToken.js"; 11 | 12 | const router = express.Router(); 13 | 14 | router.get("/", getSongs); 15 | router.get("/top", getTopSongs); 16 | router.get("/releases", getNewReleases); 17 | router.get("/random", getRandom); 18 | router.get("/popular", getAroundYou); 19 | router.patch("/like/:id", verifyToken, likeSong); 20 | 21 | export { router as songsRouter }; 22 | -------------------------------------------------------------------------------- /client/src/theme/index.js: -------------------------------------------------------------------------------- 1 | import { extendTheme } from "@chakra-ui/react"; 2 | 3 | const colors = { 4 | zinc: { 5 | 100: "#f4f4f5", 6 | 200: "#e4e4e7", 7 | 300: "#d4d4d8", 8 | 400: "#a1a1aa", 9 | 500: "#71717a", 10 | 600: "#52525b", 11 | 700: "#3f3f46", 12 | 800: "#27272a", 13 | 900: "#18181b", 14 | 950: "#09090b", 15 | }, 16 | accent: { 17 | main: "#EE4950", 18 | light: "#ef6067", 19 | transparent: "#f77e8464", 20 | }, 21 | }; 22 | 23 | const fonts = { 24 | heading: `'Inter', sans-serif`, 25 | body: `'Inter', sans-serif`, 26 | }; 27 | 28 | const theme = extendTheme({ colors, fonts }); 29 | 30 | export default theme; 31 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "description": "Backend for BeatBox Music", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1", 9 | "dev": "nodemon index.js", 10 | "start": "node index.js" 11 | }, 12 | "keywords": [], 13 | "author": "Ikenna Eze", 14 | "license": "ISC", 15 | "dependencies": { 16 | "bcrypt": "^5.1.0", 17 | "cors": "^2.8.5", 18 | "dotenv": "^16.0.3", 19 | "express": "^4.18.2", 20 | "jsonwebtoken": "^9.0.0", 21 | "mongoose": "^7.2.0" 22 | }, 23 | "devDependencies": { 24 | "nodemon": "^2.0.22" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /client/src/components/Search.jsx: -------------------------------------------------------------------------------- 1 | import { Box, Input, InputGroup, InputRightElement } from "@chakra-ui/react"; 2 | import { BsSearch } from "react-icons/bs"; 3 | 4 | const Search = () => { 5 | return ( 6 | 7 | 8 | 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | }; 24 | 25 | export default Search; 26 | -------------------------------------------------------------------------------- /client/src/redux/slices/userSlice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | 3 | const initialState = { 4 | user: null, 5 | token: null, 6 | }; 7 | 8 | export const userSlice = createSlice({ 9 | name: "user", 10 | initialState, 11 | reducers: { 12 | loginUser: (state, action) => { 13 | state.user = action.payload.user; 14 | state.token = action.payload.token; 15 | }, 16 | 17 | logoutUser: (state) => { 18 | state.user = null; 19 | state.token = null; 20 | }, 21 | setUser: (state, action) => { 22 | state.user = action.payload; 23 | }, 24 | }, 25 | }); 26 | 27 | export const { loginUser, logoutUser, setUser } = userSlice.actions; 28 | 29 | export default userSlice.reducer; 30 | -------------------------------------------------------------------------------- /server/models/Playlist.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | const PlaylistSchema = new mongoose.Schema( 4 | { 5 | title: { 6 | type: String, 7 | required: true, 8 | }, 9 | description: { 10 | type: String, 11 | }, 12 | userId: { 13 | type: String, 14 | required: true, 15 | }, 16 | userName: { 17 | type: String, 18 | required: true, 19 | }, 20 | songs: { 21 | type: Array, 22 | default: [], 23 | }, 24 | isPrivate: { 25 | type: Boolean, 26 | required: true, 27 | default: false, 28 | }, 29 | type: { 30 | type: String, 31 | required: true, 32 | default: "Playlist", 33 | }, 34 | }, 35 | { timestamps: true } 36 | ); 37 | 38 | const Playlist = mongoose.model("Playlist", PlaylistSchema); 39 | export default Playlist; 40 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 | BeatBox Music 16 | 17 | 18 |
19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /server/middleware/validateToken.js: -------------------------------------------------------------------------------- 1 | import jwt from "jsonwebtoken"; 2 | 3 | export const verifyToken = async (req, res, next) => { 4 | try { 5 | let token = req.header("Authorization") || req.header("authorization"); 6 | 7 | if (!token) { 8 | return res.status(403).send("Authorization missing!"); 9 | } 10 | 11 | if (token && token.startsWith("Bearer")) { 12 | token = token.split(" ")[1]; 13 | 14 | jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => { 15 | if (err) { 16 | return res.status(401).send("Invalid auth token"); 17 | } 18 | req.user = decoded.user; 19 | next(); 20 | }); 21 | 22 | if (!token) { 23 | res.status(401).send("Authorization token missing"); 24 | } 25 | } 26 | } catch (error) { 27 | res.status(500).json({ message: error.message }); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import dotenv from "dotenv"; 3 | import cors from "cors"; 4 | import { connectDb } from "./config/dbConnection.js"; 5 | import { songsRouter } from "./routes/songRoutes.js"; 6 | import { userRouter } from "./routes/userRoutes.js"; 7 | import { artisteRouter } from "./routes/artisteRoutes.js"; 8 | import { playlistRouter } from "./routes/playlistRoutes.js"; 9 | 10 | dotenv.config(); 11 | 12 | const app = express(); 13 | app.use(express.json()); 14 | app.use(cors()); 15 | 16 | connectDb(); 17 | 18 | app.use("/api/songs/", songsRouter); 19 | app.use("/api/users/", userRouter); 20 | app.use("/api/artistes/", artisteRouter); 21 | app.use("/api/playlists/", playlistRouter); 22 | 23 | const port = process.env.PORT || 6000; 24 | 25 | app.listen(port, async () => { 26 | console.log(`SERVER RUNNING ON PORT ${port}`); 27 | }); 28 | -------------------------------------------------------------------------------- /client/src/components/MusicPlayer/TrackDetails.jsx: -------------------------------------------------------------------------------- 1 | import { Flex, Image, Text } from "@chakra-ui/react"; 2 | 3 | const TrackDetails = ({ track }) => { 4 | return ( 5 | 6 | {track?.title} 14 | 15 | 19 | {track?.title} 20 | 21 | 26 | {track?.artistes.join(", ")} 27 | 28 | 29 | 30 | ); 31 | }; 32 | 33 | export default TrackDetails; 34 | -------------------------------------------------------------------------------- /server/models/Song.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | const SongSchema = new mongoose.Schema({ 4 | title: { 5 | type: String, 6 | required: true, 7 | }, 8 | duration: { 9 | type: String, 10 | required: true, 11 | }, 12 | coverImage: { 13 | type: String, 14 | required: true, 15 | default: 16 | "https://firebasestorage.googleapis.com/v0/b/socialstream-ba300.appspot.com/o/music_app_files%2Fplaylist_cover.jpg?alt=media&token=546adcad-e9c3-402f-8a57-b7ba252100ec", 17 | }, 18 | artistes: { 19 | type: Array, 20 | default: [], 21 | }, 22 | artistIds: [ 23 | { 24 | type: mongoose.Schema.Types.ObjectId, 25 | ref: "Artiste", 26 | }, 27 | ], 28 | likes: { 29 | type: Map, 30 | of: Boolean, 31 | }, 32 | songUrl: { 33 | type: String, 34 | required: true, 35 | }, 36 | type: { 37 | type: String, 38 | required: true, 39 | default: "Song", 40 | }, 41 | }); 42 | 43 | const Song = mongoose.model("Song", SongSchema); 44 | export default Song; 45 | -------------------------------------------------------------------------------- /client/src/redux/store.js: -------------------------------------------------------------------------------- 1 | import { combineReducers, configureStore } from "@reduxjs/toolkit"; 2 | import { 3 | persistReducer, 4 | FLUSH, 5 | PAUSE, 6 | PERSIST, 7 | PURGE, 8 | REGISTER, 9 | REHYDRATE, 10 | } from "redux-persist"; 11 | import storage from "redux-persist/lib/storage"; 12 | import playerReducer from "./slices/playerSlice"; 13 | import userReducer from "./slices/userSlice"; 14 | import modalReducer from "./slices/modalSlice"; 15 | 16 | const persistConfig = { key: "root", storage, version: 1 }; 17 | const reducers = combineReducers({ 18 | player: playerReducer, 19 | user: userReducer, 20 | modal: modalReducer, 21 | }); 22 | const persistedReducers = persistReducer(persistConfig, reducers); 23 | export const store = configureStore({ 24 | reducer: persistedReducers, 25 | middleware: (getDefaultMiddleware) => 26 | getDefaultMiddleware({ 27 | serializableCheck: { 28 | ignoreActions: [FLUSH, PAUSE, PERSIST, PURGE, REGISTER, REHYDRATE], 29 | }, 30 | }), 31 | }); 32 | -------------------------------------------------------------------------------- /client/src/components/MusicPlayer/VolumeControl.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Flex, 4 | Slider, 5 | SliderFilledTrack, 6 | SliderThumb, 7 | SliderTrack, 8 | } from "@chakra-ui/react"; 9 | import { BsFillVolumeMuteFill, BsFillVolumeUpFill } from "react-icons/bs"; 10 | 11 | const VolumeControl = ({ onToggle, onChange, volume }) => { 12 | return ( 13 | 14 | 23 | 24 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | ); 36 | }; 37 | 38 | export default VolumeControl; 39 | -------------------------------------------------------------------------------- /client/src/layouts/HomeLayout.jsx: -------------------------------------------------------------------------------- 1 | import Navbar from "../components/Navbar"; 2 | import { Outlet, useLocation } from "react-router-dom"; 3 | import { Grid, GridItem } from "@chakra-ui/react"; 4 | import { MusicPlayer } from "../components/MusicPlayer/index.jsx"; 5 | import { useSelector } from "react-redux"; 6 | import { useEffect } from "react"; 7 | 8 | const HomeLayout = () => { 9 | const { currentTrack } = useSelector((state) => state.player); 10 | const { pathname } = useLocation(); 11 | 12 | useEffect(() => { 13 | window.scrollTo(0, 0); 14 | }, [pathname]); 15 | 16 | return ( 17 | <> 18 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | {currentTrack && } 31 | 32 | 33 | ); 34 | }; 35 | 36 | export default HomeLayout; 37 | -------------------------------------------------------------------------------- /client/src/main.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import "./index.css"; 4 | import { router } from "./router"; 5 | import { Provider } from "react-redux"; 6 | import { RouterProvider } from "react-router-dom"; 7 | import { ChakraProvider } from "@chakra-ui/react"; 8 | import theme from "./theme/index.js"; 9 | import { store } from "./redux/store.js"; 10 | import { PersistGate } from "redux-persist/integration/react"; 11 | import { persistStore } from "redux-persist"; 12 | 13 | ReactDOM.createRoot(document.getElementById("root")).render( 14 | 15 | 16 | 17 | 27 | 28 | 29 | 30 | 31 | 32 | ); 33 | -------------------------------------------------------------------------------- /client/src/pages/HomePage.jsx: -------------------------------------------------------------------------------- 1 | import HomeHero from "../components/HomeHero"; 2 | import SmallSection from "../components/SmallSection"; 3 | import TopCharts from "../components/TopCharts"; 4 | import Categories from "../components/Categories"; 5 | import Search from "../components/Search"; 6 | import { Grid, GridItem, Hide } from "@chakra-ui/react"; 7 | import Artistes from "../components/Artistes"; 8 | 9 | const HomePage = () => { 10 | return ( 11 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | }; 33 | 34 | export default HomePage; 35 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "music_app", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "lint": "eslint src --ext js,jsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "author": "Ikenna Eze", 13 | "license": "ISC", 14 | "dependencies": { 15 | "@chakra-ui/react": "^2.6.1", 16 | "@emotion/react": "^11.11.0", 17 | "@emotion/styled": "^11.11.0", 18 | "@reduxjs/toolkit": "^1.9.5", 19 | "axios": "^1.4.0", 20 | "framer-motion": "^10.12.14", 21 | "react": "^18.2.0", 22 | "react-dom": "^18.2.0", 23 | "react-icons": "^4.8.0", 24 | "react-redux": "^8.0.5", 25 | "react-router-dom": "^6.11.2", 26 | "redux-persist": "^6.0.0" 27 | }, 28 | "devDependencies": { 29 | "@types/react": "^18.0.28", 30 | "@types/react-dom": "^18.0.11", 31 | "@vitejs/plugin-react": "^4.0.0", 32 | "eslint": "^8.38.0", 33 | "eslint-plugin-react": "^7.32.2", 34 | "eslint-plugin-react-hooks": "^4.6.0", 35 | "eslint-plugin-react-refresh": "^0.3.4", 36 | "vite": "^4.3.2" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /client/src/components/ArtisteCard.jsx: -------------------------------------------------------------------------------- 1 | import { Flex, Image, Text } from "@chakra-ui/react"; 2 | import { Link } from "react-router-dom"; 3 | 4 | const ArtisteCard = ({ artiste }) => { 5 | return ( 6 | 7 | 13 | 20 | {artiste?.name} 29 | 30 | 35 | {artiste?.name} 36 | 37 | 38 | 39 | ); 40 | }; 41 | 42 | export default ArtisteCard; 43 | -------------------------------------------------------------------------------- /client/src/components/PlaylistSong.jsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Flex, Image, Text } from "@chakra-ui/react"; 2 | import { BiPlusCircle } from "react-icons/bi"; 3 | import { BsCheckCircle } from "react-icons/bs"; 4 | 5 | const PlaylistSong = ({ song, isAdded, onToggleAdd }) => { 6 | return ( 7 | 13 | 14 | {song?.title} 20 | 21 | {song?.title} 22 | 23 | {song?.artistes.join(", ")} 24 | 25 | 26 | 27 | 37 | 38 | ); 39 | }; 40 | 41 | export default PlaylistSong; 42 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"); 2 | 3 | body, 4 | html { 5 | font-family: "Inter", sans-serif; 6 | /* background-color: #212121; */ 7 | color: #e3e3e3; 8 | position: relative; 9 | } 10 | 11 | h1, 12 | h2, 13 | h3, 14 | h4, 15 | h5, 16 | h6, 17 | p, 18 | span { 19 | color: #e3e3e3; 20 | } 21 | 22 | body::-webkit-scrollbar { 23 | width: 3px; 24 | height: 3px; 25 | background: #333; 26 | margin: 1rem; 27 | } 28 | 29 | body::-webkit-scrollbar-thumb { 30 | width: 5px; 31 | border-radius: 5px; 32 | background: #636262; 33 | margin: 1rem; 34 | } 35 | 36 | .scrollbar_style::-webkit-scrollbar { 37 | display: none; 38 | width: 3px; 39 | height: 3px; 40 | background: transparent; 41 | margin: 1rem; 42 | } 43 | 44 | .scrollbar_style::-webkit-scrollbar-thumb { 45 | width: 5px; 46 | border-radius: 5px; 47 | background: #3a3939; 48 | margin: 1rem; 49 | } 50 | 51 | .spin { 52 | animation: spin 1s linear infinite; 53 | } 54 | 55 | @keyframes spin { 56 | from { 57 | transform: rotate(0deg); 58 | } 59 | to { 60 | transform: rotate(360deg); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /client/src/layouts/AuthLayout.jsx: -------------------------------------------------------------------------------- 1 | import { Box, Flex, Heading } from "@chakra-ui/react"; 2 | import { useEffect } from "react"; 3 | import { BiMusic } from "react-icons/bi"; 4 | import { useSelector } from "react-redux"; 5 | import { Outlet, useNavigate } from "react-router-dom"; 6 | 7 | const AuthLayout = () => { 8 | const { user } = useSelector((state) => state.user); 9 | const navigate = useNavigate(); 10 | 11 | useEffect(() => { 12 | if (user) { 13 | navigate("/home"); 14 | } 15 | }, [user]); 16 | return ( 17 |
18 | 25 | 26 | 27 | 31 | BeatBox 32 | 33 | 34 | 35 | 36 | 37 | 38 |
39 | ); 40 | }; 41 | 42 | export default AuthLayout; 43 | -------------------------------------------------------------------------------- /client/src/components/MusicPlayer/PlayingBar.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Flex, 4 | Slider, 5 | SliderFilledTrack, 6 | SliderThumb, 7 | SliderTrack, 8 | Text, 9 | } from "@chakra-ui/react"; 10 | import { BsSoundwave } from "react-icons/bs"; 11 | import { convertToMins } from "../../utils"; 12 | 13 | const PlayingBar = ({ time, track, onSeek, trackRef }) => { 14 | return ( 15 | 16 | 17 | {trackRef ? convertToMins(trackRef.currentTime) : "0:00"} 18 | 19 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | {track?.duration.split(".").join(":")} 36 | 37 | 38 | ); 39 | }; 40 | 41 | export default PlayingBar; 42 | -------------------------------------------------------------------------------- /client/src/components/LoadingSkeleton.jsx: -------------------------------------------------------------------------------- 1 | import { Box, Flex, Skeleton, SkeletonText } from "@chakra-ui/react"; 2 | 3 | const LoadingSkeleton = () => { 4 | return ( 5 | 6 | 7 | 13 | 14 | 22 | 30 | 31 | 32 | 33 | 40 | 48 | 49 | 50 | ); 51 | }; 52 | 53 | export default LoadingSkeleton; 54 | -------------------------------------------------------------------------------- /client/src/components/PlaylistCard.jsx: -------------------------------------------------------------------------------- 1 | import { Box, Heading, Image, Text } from "@chakra-ui/react"; 2 | import { motion } from "framer-motion"; 3 | import { Link } from "react-router-dom"; 4 | import { fadeInUp } from "../theme/motionVariants"; 5 | 6 | const PlaylistCard = ({ playlist }) => { 7 | return ( 8 | 9 | 20 | {playlist?.title} 30 | 31 | 36 | {playlist?.title} 37 | 38 | 42 | {playlist?.userName} 43 | 44 | 45 | 46 | 47 | ); 48 | }; 49 | 50 | export default PlaylistCard; 51 | -------------------------------------------------------------------------------- /client/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/controllers/artistController.js: -------------------------------------------------------------------------------- 1 | import Artiste from "../models/Artiste.js"; 2 | import Song from "../models/Song.js"; 3 | 4 | //@desc Get all the artistes 5 | //@route GET /api/artistes/all 6 | //@access public 7 | const getArtistes = async (req, res) => { 8 | const artistes = await Artiste.find(); 9 | 10 | if (!artistes) { 11 | res.status(400).json({ message: "Artistes not found!" }); 12 | } 13 | 14 | res.status(200).json(artistes); 15 | }; 16 | 17 | //@desc Get the top artistes 18 | //@route GET /api/artistes/top 19 | //@access public 20 | const getTopArtistes = async (req, res) => { 21 | const artistes = await Artiste.find(); 22 | 23 | if (!artistes) { 24 | res.status(400).json({ message: "Artistes not found!" }); 25 | } 26 | 27 | const result = artistes.slice(1, 11); 28 | 29 | res.status(200).json(result); 30 | }; 31 | 32 | //@desc Get details for an artiste 33 | //@route GET /api/artistes/:id 34 | //@access public 35 | const getArtiste = async (req, res) => { 36 | const { id } = req.params; 37 | 38 | const artiste = await Artiste.findById(id); 39 | if (!artiste) { 40 | return res.status(404).json({ message: "Artiste not found!" }); 41 | } 42 | 43 | const artisteSongs = await Song.find({ artistIds: id }); 44 | if (!artisteSongs) { 45 | return res.status(400).json({ message: "An error occured!" }); 46 | } 47 | 48 | res.status(200).json({ ...artiste._doc, songs: artisteSongs }); 49 | }; 50 | 51 | export { getArtiste, getArtistes, getTopArtistes }; 52 | -------------------------------------------------------------------------------- /client/src/components/HomeHero.jsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Flex, Heading, Text } from "@chakra-ui/react"; 2 | import { motion } from "framer-motion"; 3 | import { appear } from "../theme/motionVariants"; 4 | import { Link } from "react-router-dom"; 5 | 6 | const HomeHero = () => { 7 | return ( 8 | 18 | 28 | 29 | 34 | Amazing Playlists 35 | 36 | 37 | Listen to the best playlists curated by us and our users. 38 | 39 | 40 | 49 | 50 | 51 | 52 | 53 | ); 54 | }; 55 | 56 | export default HomeHero; 57 | -------------------------------------------------------------------------------- /client/src/components/LoginModal.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | AlertDialog, 4 | AlertDialogBody, 5 | AlertDialogCloseButton, 6 | AlertDialogContent, 7 | AlertDialogFooter, 8 | AlertDialogHeader, 9 | AlertDialogOverlay, 10 | Button, 11 | } from "@chakra-ui/react"; 12 | import { useSelector } from "react-redux"; 13 | import { useNavigate } from "react-router-dom"; 14 | 15 | const LoginModal = React.forwardRef((props, ref) => { 16 | const navigate = useNavigate(); 17 | const { message } = useSelector((state) => state.modal); 18 | return ( 19 | 25 | 26 | 27 | 28 | 29 | Not Logged In 30 | 31 | 32 | 33 | {message} 34 | 35 | 36 | 42 | 49 | 50 | 51 | 52 | ); 53 | }); 54 | 55 | LoginModal.displayName = "LoginModal"; 56 | 57 | export default LoginModal; 58 | -------------------------------------------------------------------------------- /client/src/components/CreatePlaylistCard.jsx: -------------------------------------------------------------------------------- 1 | import { Button, Flex, Text, useDisclosure } from "@chakra-ui/react"; 2 | import { useRef } from "react"; 3 | import { BiPlus } from "react-icons/bi"; 4 | import LoginModal from "./LoginModal"; 5 | import { useDispatch, useSelector } from "react-redux"; 6 | import { useNavigate } from "react-router-dom"; 7 | import { setModalMessage } from "../redux/slices/modalSlice"; 8 | 9 | const CreatePlaylistCard = () => { 10 | const { isOpen, onOpen, onClose } = useDisclosure(); 11 | const modalRef = useRef(); 12 | const navigate = useNavigate(); 13 | 14 | const { user } = useSelector((state) => state.user); 15 | const dispatch = useDispatch(); 16 | 17 | const handleCreatePlaylist = () => { 18 | if (user) { 19 | navigate("/playlists/create"); 20 | } else { 21 | dispatch(setModalMessage("Please login to create a playlist.")); 22 | onOpen(); 23 | } 24 | }; 25 | 26 | return ( 27 | <> 28 | 29 | 36 | 47 | 48 | Create a playlist 49 | 50 | 51 | 52 | ); 53 | }; 54 | 55 | export default CreatePlaylistCard; 56 | -------------------------------------------------------------------------------- /client/src/components/Categories.jsx: -------------------------------------------------------------------------------- 1 | import { Box, Flex, Heading, Image, SimpleGrid } from "@chakra-ui/react"; 2 | 3 | const categories = [ 4 | { 5 | id: 1, 6 | title: "Pop", 7 | image: "/cat_pop.png", 8 | }, 9 | { 10 | id: 2, 11 | title: "Chill", 12 | image: "/cat_chill.png", 13 | }, 14 | { 15 | id: 3, 16 | title: "Podcast", 17 | image: "/cat_podcast.png", 18 | }, 19 | { 20 | id: 4, 21 | title: "Romance", 22 | image: "/cat_romance.png", 23 | }, 24 | { 25 | id: 5, 26 | title: "Hip Hop", 27 | image: "/cat_hiphop.png", 28 | }, 29 | { 30 | id: 6, 31 | title: "Rock", 32 | image: "/cat_rock.png", 33 | }, 34 | ]; 35 | 36 | const Categories = () => { 37 | return ( 38 | 39 | 40 | Categories 41 | 42 | 43 | 44 | {categories.map((cat) => ( 45 | 53 | {cat.title} 64 | 72 | 73 | {cat.title} 74 | 75 | 76 | 77 | ))} 78 | 79 | 80 | ); 81 | }; 82 | 83 | export default Categories; 84 | -------------------------------------------------------------------------------- /client/src/pages/LibraryPage.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import SongCard from "../components/SongCard"; 3 | import { AiOutlineLoading } from "react-icons/ai"; 4 | import { Box, Flex, Grid, Heading, Text } from "@chakra-ui/react"; 5 | import { client } from "../api"; 6 | 7 | const LibraryPage = () => { 8 | const [songs, setSongs] = useState([]); 9 | const [loading, setLoading] = useState(true); 10 | const [error, setError] = useState(false); 11 | 12 | const fetchSongs = async () => { 13 | setLoading(true); 14 | setError(false); 15 | await client 16 | .get("/songs") 17 | .then((res) => { 18 | setSongs(res.data); 19 | setLoading(false); 20 | }) 21 | .catch(() => { 22 | setError(true); 23 | setLoading(false); 24 | }); 25 | }; 26 | 27 | useEffect(() => { 28 | fetchSongs(); 29 | }, []); 30 | 31 | return ( 32 | 38 | 39 | 43 | Library 44 | 45 | 46 | Discover interesting songs 47 | 48 | 49 | {loading && songs.length < 1 && ( 50 | 51 | 52 | 53 | )} 54 | 62 | {songs.map((song) => ( 63 | 64 | ))} 65 | 66 | {error && ( 67 | 68 | Sorry, an error occured 69 | 70 | )} 71 | 72 | ); 73 | }; 74 | 75 | export default LibraryPage; 76 | -------------------------------------------------------------------------------- /client/src/pages/ArtistesPage.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import ArtisteCard from "../components/ArtisteCard"; 3 | import { AiOutlineLoading } from "react-icons/ai"; 4 | import { Box, Flex, Grid, Heading, Text } from "@chakra-ui/react"; 5 | import { client } from "../api"; 6 | 7 | const ArtistesPage = () => { 8 | const [artistes, setArtistes] = useState([]); 9 | const [loading, setLoading] = useState(true); 10 | const [error, setError] = useState(false); 11 | 12 | const fetchArtistes = async () => { 13 | setLoading(true); 14 | setError(false); 15 | await client 16 | .get("/artistes/all") 17 | .then((res) => { 18 | setArtistes(res.data); 19 | setLoading(false); 20 | }) 21 | .catch(() => { 22 | setError(true); 23 | setLoading(false); 24 | }); 25 | }; 26 | 27 | useEffect(() => { 28 | fetchArtistes(); 29 | }, []); 30 | 31 | return ( 32 | 38 | 39 | 43 | Artistes 44 | 45 | 46 | Discover new artistes 47 | 48 | 49 | {loading && artistes.length < 1 && ( 50 | 51 | 52 | 53 | )} 54 | 62 | {artistes.map((artiste) => ( 63 | 64 | ))} 65 | 66 | {error && ( 67 | 68 | Sorry, an error occured 69 | 70 | )} 71 | 72 | ); 73 | }; 74 | 75 | export default ArtistesPage; 76 | -------------------------------------------------------------------------------- /client/src/components/Artistes.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { Box, Button, Flex, Heading, Text } from "@chakra-ui/react"; 3 | import { AiOutlineLoading } from "react-icons/ai"; 4 | import ArtisteCard from "./ArtisteCard"; 5 | import { client } from "../api"; 6 | import { Link } from "react-router-dom"; 7 | 8 | const Artistes = () => { 9 | const [artistes, setArtistes] = useState([]); 10 | const [loading, setLoading] = useState(true); 11 | const [error, setError] = useState(false); 12 | 13 | const fetchArtistes = async () => { 14 | setLoading(true); 15 | setError(false); 16 | await client 17 | .get("/artistes/top") 18 | .then((res) => { 19 | setArtistes(res.data); 20 | setLoading(false); 21 | }) 22 | .catch(() => { 23 | setError(true); 24 | setLoading(false); 25 | }); 26 | }; 27 | 28 | useEffect(() => { 29 | fetchArtistes(); 30 | }, []); 31 | 32 | return ( 33 | 34 | 35 | 36 | You May Like 37 | 38 | 39 | 46 | 47 | 48 | 49 | {loading ? ( 50 | 51 | 52 | 53 | ) : error ? ( 54 | 55 | Sorry, an error occured 56 | 57 | ) : ( 58 | 66 | {artistes?.map((artiste) => ( 67 | 68 | ))} 69 | 70 | )} 71 | 72 | ); 73 | }; 74 | 75 | export default Artistes; 76 | -------------------------------------------------------------------------------- /client/src/router/index.jsx: -------------------------------------------------------------------------------- 1 | import { Navigate, createBrowserRouter } from "react-router-dom"; 2 | import HomePage from "../pages/HomePage"; 3 | import HomeLayout from "../layouts/HomeLayout"; 4 | import LibraryPage from "../pages/LibraryPage"; 5 | import PlaylistsPage from "../pages/PlaylistsPage"; 6 | import ErrorPage from "../pages/ErrorPage"; 7 | import AuthLayout from "../layouts/AuthLayout"; 8 | import LoginPage from "../pages/LoginPage"; 9 | import RegisterPage from "../pages/RegisterPage"; 10 | import ArtistePage from "../pages/ArtistePage"; 11 | import ArtistesPage from "../pages/ArtistesPage"; 12 | import FavoritesPage from "../pages/FavoritesPage"; 13 | import PlaylistPage from "../pages/PlaylistPage"; 14 | import CreatePlaylistPage from "../pages/CreatePlaylistPage"; 15 | import EditPlaylistPage from "../pages/EditPlaylistPage"; 16 | 17 | export const router = createBrowserRouter([ 18 | { 19 | path: "/", 20 | element: , 21 | errorElement: , 22 | children: [ 23 | { index: true, element: }, 24 | { 25 | path: "/home", 26 | element: , 27 | }, 28 | { 29 | path: "library", 30 | element: , 31 | }, 32 | { 33 | path: "playlists", 34 | element: , 35 | }, 36 | { 37 | path: "playlists/:id", 38 | element: , 39 | }, 40 | { 41 | path: "playlists/create", 42 | element: , 43 | }, 44 | { 45 | path: "playlists/edit/:id", 46 | element: , 47 | }, 48 | { 49 | path: "artistes", 50 | element: , 51 | }, 52 | { 53 | path: "artiste/:id", 54 | element: , 55 | }, 56 | { 57 | path: "favorites", 58 | element: , 59 | }, 60 | ], 61 | }, 62 | { 63 | path: "/auth", 64 | element: , 65 | errorElement: , 66 | children: [ 67 | { 68 | path: "login", 69 | element: , 70 | }, 71 | { 72 | path: "register", 73 | element: , 74 | }, 75 | ], 76 | }, 77 | ]); 78 | -------------------------------------------------------------------------------- /client/src/components/HorizontalMusicCard.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Button, 4 | Flex, 5 | Heading, 6 | Hide, 7 | Image, 8 | Text, 9 | } from "@chakra-ui/react"; 10 | import { AiFillHeart, AiOutlineHeart } from "react-icons/ai"; 11 | import { motion } from "framer-motion"; 12 | import { fadeInUp } from "../theme/motionVariants"; 13 | import { useSelector } from "react-redux"; 14 | 15 | const HorizontalMusicCard = ({ song, onPlay }) => { 16 | const { currentTrack } = useSelector((state) => state.player); 17 | const { user } = useSelector((state) => state.user); 18 | 19 | return ( 20 | onPlay(song)} 23 | as={motion.div} 24 | initial="initial" 25 | animate="animate" 26 | variants={fadeInUp} 27 | bg="zinc.900" 28 | p={2} 29 | w="full" 30 | rounded="base"> 31 | 32 | 33 | album 41 | 42 | 50 | {song?.title} 51 | 52 | 53 | {song?.artistes?.join(", ")} 54 | 55 | 56 | 57 | 58 | 59 | 60 | {song?.duration?.split(".")?.join(":")} 61 | 62 | 63 | 70 | 71 | 72 | 73 | ); 74 | }; 75 | 76 | export default HorizontalMusicCard; 77 | -------------------------------------------------------------------------------- /client/src/components/SmallSection.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { AiFillPlayCircle, AiOutlineLoading } from "react-icons/ai"; 3 | import SongCard from "./SongCard"; 4 | import { Box, Button, Flex, Heading, Text } from "@chakra-ui/react"; 5 | import { client } from "../api"; 6 | import { Link } from "react-router-dom"; 7 | 8 | const SmallSection = ({ title, endpoint }) => { 9 | const [loading, setLoading] = useState(false); 10 | const [data, setData] = useState([]); 11 | const [error, setError] = useState(false); 12 | 13 | const fetchData = async () => { 14 | setError(false); 15 | setLoading(true); 16 | await client 17 | .get(`${endpoint}`) 18 | .then((res) => { 19 | setData(res.data); 20 | setLoading(false); 21 | }) 22 | .catch(() => { 23 | setError(true); 24 | setLoading(false); 25 | }); 26 | }; 27 | 28 | useEffect(() => { 29 | fetchData(); 30 | }, []); 31 | 32 | return ( 33 | 34 | 35 | 36 | 37 | {title} 38 | 39 | 40 | 41 | 42 | 43 | 44 | 51 | 52 | 53 | {loading ? ( 54 | 55 | 56 | 57 | ) : error ? ( 58 | 59 | Sorry, an error occured 60 | 61 | ) : ( 62 | 69 | {data?.map((song) => ( 70 | 71 | ))} 72 | 73 | )} 74 | 75 | ); 76 | }; 77 | 78 | export default SmallSection; 79 | -------------------------------------------------------------------------------- /client/src/redux/slices/playerSlice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | 3 | const initialState = { 4 | currentTrack: null, 5 | isPlaying: false, 6 | currentIndex: 0, 7 | trackList: [], 8 | repeatStatus: "OFF", 9 | }; 10 | 11 | export const playerSlice = createSlice({ 12 | name: "player", 13 | initialState, 14 | reducers: { 15 | resetPlayer: (state) => { 16 | state.currentTrack = null; 17 | state.isPlaying = false; 18 | state.currentIndex = 0; 19 | state.trackList = []; 20 | state.repeatStatus = "OFF"; 21 | }, 22 | setCurrentTrack: (state, action) => { 23 | state.currentTrack = action.payload; 24 | }, 25 | setPlaying: (state, action) => { 26 | state.isPlaying = action.payload; 27 | }, 28 | playTrack: (state, action) => { 29 | state.currentTrack = action.payload; 30 | state.isPlaying = true; 31 | }, 32 | setTrackList: (state, action) => { 33 | state.trackList = action.payload.list; 34 | state.currentIndex = action.payload.index ? action.payload.index : 0; 35 | }, 36 | nextTrack: (state) => { 37 | if (state.currentIndex >= state.trackList.length - 1) { 38 | state.currentIndex = 0; 39 | state.currentTrack = state.trackList[0]; 40 | } else { 41 | state.currentTrack = state.trackList[state.currentIndex + 1]; 42 | state.currentIndex += 1; 43 | } 44 | }, 45 | prevTrack: (state) => { 46 | if (state.currentIndex == 0) { 47 | state.currentIndex = state.trackList.length - 1; 48 | state.currentTrack = state.trackList[state.trackList.length - 1]; 49 | } else { 50 | state.currentTrack = state.trackList[state.currentIndex - 1]; 51 | state.currentIndex -= 1; 52 | } 53 | }, 54 | toggleRepeat: (state) => { 55 | switch (state.repeatStatus) { 56 | case "OFF": 57 | state.repeatStatus = "TRACKLIST"; 58 | break; 59 | case "TRACKLIST": 60 | state.repeatStatus = "SINGLE"; 61 | break; 62 | case "SINGLE": 63 | state.repeatStatus = "OFF"; 64 | break; 65 | default: 66 | break; 67 | } 68 | }, 69 | }, 70 | }); 71 | 72 | export const { 73 | resetPlayer, 74 | setCurrentTrack, 75 | setPlaying, 76 | playTrack, 77 | setTrackList, 78 | nextTrack, 79 | prevTrack, 80 | toggleRepeat, 81 | } = playerSlice.actions; 82 | 83 | export default playerSlice.reducer; 84 | -------------------------------------------------------------------------------- /client/src/components/TopCharts.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import HorizontalMusicCard from "./HorizontalMusicCard"; 3 | import { Box, Flex, Heading, Text } from "@chakra-ui/react"; 4 | import { motion } from "framer-motion"; 5 | import { appear } from "../theme/motionVariants"; 6 | import { client } from "../api"; 7 | import { AiOutlineLoading } from "react-icons/ai"; 8 | import { useDispatch } from "react-redux"; 9 | import { playTrack, setTrackList } from "../redux/slices/playerSlice"; 10 | 11 | const TopCharts = () => { 12 | const dispatch = useDispatch(); 13 | const [loading, setLoading] = useState(true); 14 | const [data, setData] = useState([]); 15 | const [error, setError] = useState(false); 16 | 17 | const fetchData = async () => { 18 | setLoading(true); 19 | setError(false); 20 | await client 21 | .get("/songs/top") 22 | .then((res) => { 23 | setData(res.data); 24 | setLoading(false); 25 | }) 26 | .catch(() => { 27 | setError(true); 28 | setLoading(false); 29 | }); 30 | }; 31 | 32 | useEffect(() => { 33 | fetchData(); 34 | }, []); 35 | 36 | const handlePlaySong = (song) => { 37 | const index = data?.findIndex((s) => s._id == song._id); 38 | 39 | dispatch(setTrackList({ list: data, index })); 40 | dispatch(playTrack(song)); 41 | }; 42 | 43 | return ( 44 | 52 | 53 | Top Charts 54 | 55 | {loading ? ( 56 | 57 | 58 | 59 | ) : ( 60 | 61 | {data?.map((song, i) => ( 62 | 63 | {1 + i} 64 | 69 | 70 | ))} 71 | 72 | )} 73 | 74 | {error && ( 75 | 76 | An error occured while fetching top charts. 77 | 78 | )} 79 | 80 | ); 81 | }; 82 | 83 | export default TopCharts; 84 | -------------------------------------------------------------------------------- /client/src/pages/PlaylistsPage.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import PlaylistCard from "../components/PlaylistCard"; 3 | import { Box, Flex, Grid, Heading, Text } from "@chakra-ui/react"; 4 | import CreatePlaylistCard from "../components/CreatePlaylistCard"; 5 | import { client } from "../api"; 6 | import { AiOutlineLoading } from "react-icons/ai"; 7 | import { MdErrorOutline } from "react-icons/md"; 8 | 9 | const PlaylistsPage = () => { 10 | const [loading, setLoading] = useState(false); 11 | const [error, setError] = useState(false); 12 | const [playlists, setPlaylists] = useState([]); 13 | 14 | const fetchPlaylists = async () => { 15 | setLoading(true); 16 | setError(false); 17 | await client 18 | .get("/playlists") 19 | .then((res) => { 20 | setLoading(false); 21 | setPlaylists(res.data); 22 | }) 23 | .catch(() => { 24 | setLoading(false); 25 | setError(true); 26 | }); 27 | }; 28 | 29 | useEffect(() => { 30 | fetchPlaylists(); 31 | }, []); 32 | 33 | if (error) { 34 | return ( 35 | 36 | 37 | 38 | 39 | An error occured while fetching playlists. 40 | 41 | 42 | 43 | ); 44 | } 45 | 46 | return ( 47 | 48 | 49 | 54 | Playlists 55 | 56 | 57 | Here are some playlists curated by users. 58 | 59 | 60 | {loading && playlists.length < 1 && ( 61 | 62 | 63 | 64 | )} 65 | {!loading && !error && ( 66 | 75 | 76 | {playlists.map((playlist) => ( 77 | 78 | ))} 79 | 80 | )} 81 | 82 | ); 83 | }; 84 | 85 | export default PlaylistsPage; 86 | -------------------------------------------------------------------------------- /client/src/components/MusicPlayer/PlayControls.jsx: -------------------------------------------------------------------------------- 1 | import { Button, Flex, Hide } from "@chakra-ui/react"; 2 | import { AiFillPauseCircle, AiFillPlayCircle } from "react-icons/ai"; 3 | import { 4 | TbArrowsShuffle, 5 | TbPlayerTrackNextFilled, 6 | TbPlayerTrackPrevFilled, 7 | TbRepeat, 8 | TbRepeatOff, 9 | TbRepeatOnce, 10 | } from "react-icons/tb"; 11 | import { useDispatch } from "react-redux"; 12 | import { toggleRepeat } from "../../redux/slices/playerSlice"; 13 | 14 | const PlayControls = ({ 15 | onNext, 16 | onPrevious, 17 | onPlay, 18 | isPlaying, 19 | repeatStatus, 20 | }) => { 21 | const dispatch = useDispatch(); 22 | return ( 23 | 28 | 29 | 37 | 38 | 50 | 61 | 72 | 73 | 88 | 89 | 90 | ); 91 | }; 92 | 93 | export default PlayControls; 94 | -------------------------------------------------------------------------------- /server/controllers/userController.js: -------------------------------------------------------------------------------- 1 | import bcrypt from "bcrypt"; 2 | import jwt from "jsonwebtoken"; 3 | import User from "../models/User.js"; 4 | import Song from "../models/Song.js"; 5 | 6 | //@desc Login a user 7 | //@route POST /api/auth/login 8 | //@access public 9 | const loginUser = async (req, res) => { 10 | const { username, password } = req.body; 11 | 12 | if (!username || !password) { 13 | return res.status(400).json({ message: "All fields are required!" }); 14 | } 15 | 16 | const user = await User.findOne({ username }); 17 | 18 | if (!user) { 19 | return res.status(404).json({ message: "User not found!" }); 20 | } 21 | 22 | const passwordMatch = await bcrypt.compare(password, user.password); 23 | if (!passwordMatch) { 24 | return res.status(400).json({ message: "Incorrect username or password!" }); 25 | } 26 | 27 | const accessToken = jwt.sign( 28 | { 29 | user: { 30 | id: user.id, 31 | username: user.username, 32 | }, 33 | }, 34 | process.env.JWT_SECRET 35 | ); 36 | 37 | const returnedUser = { 38 | id: user.id, 39 | username: user.username, 40 | favorites: user.favorites, 41 | playlists: user.playlists, 42 | }; 43 | 44 | res.status(200).json({ user: returnedUser, token: accessToken }); 45 | }; 46 | 47 | //@desc Login a user 48 | //@route POST /api/auth/register 49 | //@access public 50 | const registerUser = async (req, res) => { 51 | const { username, password } = req.body; 52 | 53 | if (!username || !password) { 54 | return res.status(400).json({ message: "All fields are required" }); 55 | } 56 | 57 | const duplicateUsername = await User.findOne({ username }); 58 | if (duplicateUsername) { 59 | return res.status(400).json({ message: "Username already exists!" }); 60 | } 61 | 62 | const hashedPassword = await bcrypt.hash(password, 10); 63 | 64 | const newUser = await User.create({ username, password: hashedPassword }); 65 | if (!newUser) { 66 | return res.status(400).json({ message: "User not created!" }); 67 | } 68 | 69 | const accessToken = jwt.sign( 70 | { 71 | user: { 72 | id: newUser.id, 73 | username: newUser.username, 74 | }, 75 | }, 76 | process.env.JWT_SECRET 77 | ); 78 | 79 | const returnedUser = { 80 | id: newUser.id, 81 | username: newUser.username, 82 | favorites: newUser.favorites, 83 | playlists: newUser.playlists, 84 | }; 85 | 86 | res.status(200).json({ user: returnedUser, token: accessToken }); 87 | }; 88 | 89 | //@desc Get a user's favorite songs 90 | //@route GET /api/songs/user/favorites 91 | //@access private 92 | const getUserFavoriteSongs = async (req, res) => { 93 | const { id } = req.user; 94 | const user = await User.findById(id); 95 | 96 | if (!user) { 97 | return res.status(404).json({ message: "User not found!" }); 98 | } 99 | 100 | const userFavorites = await Promise.all( 101 | user.favorites.map((id) => Song.findById(id)) 102 | ); 103 | 104 | if (!userFavorites) { 105 | return res.status(404).json({ message: "Not found!" }); 106 | } 107 | 108 | res.status(200).json(userFavorites); 109 | }; 110 | 111 | export { loginUser, registerUser, getUserFavoriteSongs }; 112 | -------------------------------------------------------------------------------- /client/src/pages/FavoritesPage.jsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Divider, Flex, Heading, Text } from "@chakra-ui/react"; 2 | import { useEffect, useState } from "react"; 3 | import { useDispatch, useSelector } from "react-redux"; 4 | import { Link } from "react-router-dom"; 5 | import { client } from "../api"; 6 | import ArtisteSong from "../components/ArtisteSong"; 7 | import { playTrack, setTrackList } from "../redux/slices/playerSlice"; 8 | import { AiOutlineLoading } from "react-icons/ai"; 9 | 10 | const FavoritesPage = () => { 11 | const [favorites, setFavorites] = useState([]); 12 | const [loading, setLoading] = useState(false); 13 | const [error, setError] = useState(false); 14 | const { user, token } = useSelector((state) => state.user); 15 | const dispatch = useDispatch(); 16 | 17 | const fetchFavorites = async () => { 18 | setLoading(true); 19 | setError(false); 20 | await client 21 | .get("/users/favorites", { 22 | headers: { 23 | Authorization: `Bearer ${token}`, 24 | }, 25 | }) 26 | .then((res) => { 27 | setLoading(false); 28 | setFavorites(res.data); 29 | }) 30 | .catch(() => { 31 | setLoading(false); 32 | setError(true); 33 | }); 34 | }; 35 | 36 | useEffect(() => { 37 | token && fetchFavorites(); 38 | }, []); 39 | 40 | const onPlay = (song) => { 41 | const index = favorites?.findIndex((s) => s._id == song._id); 42 | 43 | dispatch(setTrackList({ list: favorites, index })); 44 | dispatch(playTrack(song)); 45 | }; 46 | 47 | if (!user) { 48 | return ( 49 | 50 | 51 | 52 | Please login to see your favorites 53 | 54 | 55 | 58 | 59 | 60 | 61 | ); 62 | } 63 | 64 | return ( 65 | 71 | 72 | 76 | Favorites 77 | 78 | 79 | Your favorite songs 80 | 81 | 82 | 83 | {loading && favorites.length < 1 && ( 84 | 85 | 86 | 87 | )} 88 | {error && ( 89 | 90 | Sorry, an error occured 91 | 92 | )} 93 | 94 | {favorites?.map((song) => ( 95 | 96 | ))} 97 | 98 | {!loading && !error && favorites.length < 1 && ( 99 | {"You haven't liked any songs yet..."} 100 | )} 101 | 102 | ); 103 | }; 104 | 105 | export default FavoritesPage; 106 | -------------------------------------------------------------------------------- /server/controllers/songController.js: -------------------------------------------------------------------------------- 1 | import Song from "../models/Song.js"; 2 | import User from "../models/User.js"; 3 | 4 | //@desc Get all the songs 5 | //@route GET /api/songs 6 | //@access public 7 | const getSongs = async (req, res) => { 8 | const songs = await Song.find({}); 9 | 10 | if (!songs) { 11 | return res.status(400).json({ message: "An error occured!" }); 12 | } 13 | const shuffledSongs = songs.sort(() => (Math.random() > 0.5 ? 1 : -1)); 14 | 15 | res.status(200).json(shuffledSongs); 16 | }; 17 | 18 | //@desc Get the top songs 19 | //@route GET /api/songs/top 20 | //@access public 21 | const getTopSongs = async (req, res) => { 22 | try { 23 | const results = await Song.aggregate([ 24 | { 25 | $project: { 26 | title: 1, 27 | duration: 1, 28 | coverImage: 1, 29 | artistes: 1, 30 | songUrl: 1, 31 | artistIds: 1, 32 | type: 1, 33 | likes: { 34 | $size: { 35 | $objectToArray: "$likes", 36 | }, 37 | }, 38 | }, 39 | }, 40 | { $sort: { likes: -1 } }, 41 | { $limit: 8 }, 42 | ]); 43 | res.status(200).json(results); 44 | } catch (error) { 45 | res.status(400).json({ message: error.message }); 46 | } 47 | }; 48 | 49 | //@desc Get the new releases 50 | //@route GET /api/songs/releases 51 | //@access public 52 | const getNewReleases = async (req, res) => { 53 | const songs = await Song.find({}); 54 | 55 | const result = songs.slice(-11, -1); 56 | const shuffledSongs = result.sort(() => (Math.random() > 0.5 ? 1 : -1)); 57 | 58 | res.status(200).json(shuffledSongs); 59 | }; 60 | 61 | //@desc Get random songs 62 | //@route GET /api/songs/random 63 | //@access public 64 | const getRandom = async (req, res) => { 65 | const songs = await Song.find({}); 66 | 67 | const shuffledSongs = songs.sort(() => (Math.random() > 0.5 ? 1 : -1)); 68 | const result = shuffledSongs.slice(-11, -1); 69 | 70 | res.status(200).json(result); 71 | }; 72 | 73 | //@desc Get the popular songs around you 74 | //@route GET /api/songs/popular 75 | //@access public 76 | const getAroundYou = async (req, res) => { 77 | const songs = await Song.find({}); 78 | 79 | const result = songs.slice(0, 11); 80 | const shuffledSongs = result.sort(() => (Math.random() > 0.5 ? 1 : -1)); 81 | 82 | res.status(200).json(shuffledSongs); 83 | }; 84 | 85 | //@desc Like or unlike a song 86 | //@route PATCH /api/songs/like/:id 87 | //@access private 88 | const likeSong = async (req, res) => { 89 | try { 90 | const { id } = req.params; 91 | const userId = req.user.id; 92 | const song = await Song.findById(id); 93 | const user = await User.findById(userId); 94 | 95 | if (!user) { 96 | return res.json(404).json({ message: "User not found!" }); 97 | } 98 | if (!song) { 99 | return res.json(404).json({ message: "Song not found!" }); 100 | } 101 | 102 | const isLiked = song.likes.get(userId); 103 | 104 | if (isLiked) { 105 | song.likes.delete(userId); 106 | user.favorites = user.favorites.filter((songId) => songId !== id); 107 | } else { 108 | song.likes.set(userId, true); 109 | user.favorites.push(id); 110 | } 111 | 112 | const savedSong = await song.save(); 113 | const savedUser = await user.save(); 114 | 115 | if (!savedSong || !savedUser) { 116 | return res.status(400).json({ message: "An error occured" }); 117 | } 118 | 119 | const returnUser = { 120 | id: savedUser.id, 121 | username: savedUser.username, 122 | favorites: savedUser.favorites, 123 | playlists: savedUser.playlists, 124 | }; 125 | 126 | res.status(200).json(returnUser); 127 | } catch (error) { 128 | return res.status(409).json({ message: error.message }); 129 | } 130 | }; 131 | 132 | export { 133 | getSongs, 134 | getTopSongs, 135 | getNewReleases, 136 | getRandom, 137 | getAroundYou, 138 | likeSong, 139 | }; 140 | -------------------------------------------------------------------------------- /client/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/controllers/playlistController.js: -------------------------------------------------------------------------------- 1 | import Playlist from "../models/Playlist.js"; 2 | import Song from "../models/Song.js"; 3 | import User from "../models/User.js"; 4 | 5 | //@desc Get all the playlists 6 | //@route GET /api/playlists 7 | //@access public 8 | const getPlaylists = async (req, res) => { 9 | const playlists = await Playlist.find({}); 10 | 11 | if (!playlists) { 12 | return res.status(400).json({ message: "An error occured" }); 13 | } 14 | 15 | res.status(200).json(playlists); 16 | }; 17 | 18 | //@desc Create a playlist 19 | //@route POST /api/playlists/create 20 | //@access private 21 | const createPlaylist = async (req, res) => { 22 | const { id, username } = req.user; 23 | const { title, description, isPrivate, songIds } = req.body; 24 | const user = await User.findById(id); 25 | 26 | if (!title || !songIds) { 27 | return res.status(400).json({ message: "All fields are required!" }); 28 | } 29 | 30 | if (!user) { 31 | return res.status(404).json({ message: "User not found!" }); 32 | } 33 | 34 | await Promise.all( 35 | songIds.map(async (id) => { 36 | const songExists = await Song.findById(id); 37 | if (!songExists) { 38 | return res.status(404).json({ message: "Song not found" }); 39 | } 40 | }) 41 | ); 42 | 43 | const newPlaylist = await Playlist.create({ 44 | title, 45 | description, 46 | userId: id, 47 | userName: username, 48 | songs: songIds, 49 | isPrivate, 50 | }); 51 | 52 | if (!newPlaylist) { 53 | return res.status(400).json({ message: "An error occured!" }); 54 | } 55 | 56 | user.playlists.push(newPlaylist.id); 57 | await user.save(); 58 | 59 | res.status(201).json(newPlaylist); 60 | }; 61 | 62 | //@desc Get a playlists' details 63 | //@route GET /api/playlists/:id 64 | //@access public 65 | const getPlaylist = async (req, res) => { 66 | try { 67 | const { id } = req.params; 68 | 69 | const playlist = await Playlist.findById(id); 70 | 71 | if (!playlist) { 72 | return res.status(404).json({ message: "Playlist not found!" }); 73 | } 74 | 75 | let songs = []; 76 | 77 | await Promise.all( 78 | playlist.songs.map(async (songId) => { 79 | const playlistSong = await Song.findById(songId); 80 | if (!playlistSong) { 81 | return res.status(404).json({ message: "Song not found" }); 82 | } else { 83 | songs.push(playlistSong); 84 | } 85 | }) 86 | ); 87 | 88 | res.status(200).json({ ...playlist.toObject(), songs }); 89 | } catch (error) { 90 | res.status(400).json({ message: error.message }); 91 | } 92 | }; 93 | 94 | //@desc Add or remove a song from a playlist 95 | //@route PATCH /api/playlists/:id 96 | //@access private 97 | const editPlaylist = async (req, res) => { 98 | const { id } = req.params; 99 | const userId = req.user.id; 100 | const { title, description, songIds } = req.body; 101 | const playlist = await Playlist.findById(id); 102 | 103 | if (!title || !songIds) { 104 | return res.status(400).json({ message: "All fields are required!" }); 105 | } 106 | 107 | if (!playlist) { 108 | return res.status(400).json({ message: "Playlist not found!" }); 109 | } 110 | 111 | if (playlist.userId !== userId) { 112 | return res 113 | .status(403) 114 | .json({ message: "You are not allowed to edit other users' playlists!" }); 115 | } 116 | 117 | await Promise.all( 118 | songIds.map(async (id) => { 119 | const songExists = await Song.findById(id); 120 | if (!songExists) { 121 | return res.status(404).json({ message: "Song not found" }); 122 | } 123 | }) 124 | ); 125 | 126 | const updatedPlaylist = await Playlist.findByIdAndUpdate( 127 | id, 128 | { title, description, songs: songIds }, 129 | { 130 | new: true, 131 | } 132 | ); 133 | 134 | if (!updatedPlaylist) { 135 | return res.status(400).json({ message: "Playlist not updated!" }); 136 | } 137 | res.status(200).json(updatedPlaylist); 138 | }; 139 | 140 | export { getPlaylists, createPlaylist, getPlaylist, editPlaylist }; 141 | -------------------------------------------------------------------------------- /client/src/components/ArtisteSong.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Button, 4 | Flex, 5 | Hide, 6 | Image, 7 | Text, 8 | useDisclosure, 9 | useToast, 10 | } from "@chakra-ui/react"; 11 | import { useRef } from "react"; 12 | import { AiFillHeart, AiFillPlayCircle, AiOutlineHeart } from "react-icons/ai"; 13 | import { useDispatch, useSelector } from "react-redux"; 14 | import { BsSoundwave } from "react-icons/bs"; 15 | import LoginModal from "./LoginModal"; 16 | import { client } from "../api"; 17 | import { setUser } from "../redux/slices/userSlice"; 18 | import { setModalMessage } from "../redux/slices/modalSlice"; 19 | 20 | const ArtisteSong = ({ song, handlePlay }) => { 21 | const dispatch = useDispatch(); 22 | const { currentTrack, isPlaying } = useSelector((state) => state.player); 23 | const { user, token } = useSelector((state) => state.user); 24 | const { isOpen, onOpen, onClose } = useDisclosure(); 25 | const modalRef = useRef(); 26 | const toast = useToast(); 27 | 28 | const isCurrentTrack = currentTrack?._id === song?._id; 29 | 30 | const playSong = () => { 31 | handlePlay(song); 32 | }; 33 | 34 | const likeSong = async () => { 35 | await client 36 | .patch(`/songs/like/${song?._id}`, null, { 37 | headers: { 38 | Authorization: `Bearer ${token}`, 39 | }, 40 | }) 41 | .then((res) => { 42 | dispatch(setUser(res.data)); 43 | toast({ 44 | description: "Your favorites have been updated", 45 | status: "success", 46 | }); 47 | }) 48 | .catch(() => { 49 | toast({ 50 | description: "An error occured", 51 | status: "error", 52 | }); 53 | }); 54 | }; 55 | 56 | const handleLike = () => { 57 | if (!token) { 58 | dispatch( 59 | setModalMessage("Please login to save songs to your favorites.") 60 | ); 61 | onOpen(); 62 | } else { 63 | likeSong(); 64 | } 65 | }; 66 | 67 | return ( 68 | <> 69 | 70 | 78 | 79 | {song?.title} 87 | 88 | 89 | {song?.title} 90 | {isCurrentTrack && ( 91 | 92 | 93 | 98 | Playing 99 | 100 | 101 | )} 102 | 103 | 104 | {song?.artistes.join(", ")} 105 | 106 | 107 | 108 | 109 | {isCurrentTrack && isPlaying ? null : ( 110 | 121 | )} 122 | 123 | 124 | {song?.duration?.split(".")?.join(":")} 125 | 126 | 127 | 139 | 140 | 141 | 142 | ); 143 | }; 144 | 145 | export default ArtisteSong; 146 | -------------------------------------------------------------------------------- /client/src/pages/ArtistePage.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useParams } from "react-router-dom"; 3 | import { 4 | Box, 5 | Button, 6 | Divider, 7 | Flex, 8 | Heading, 9 | Image, 10 | Text, 11 | } from "@chakra-ui/react"; 12 | import { client } from "../api"; 13 | import ArtisteSong from "../components/ArtisteSong"; 14 | import { BsFillPlayFill } from "react-icons/bs"; 15 | import { MdErrorOutline } from "react-icons/md"; 16 | import { useDispatch } from "react-redux"; 17 | import { playTrack, setTrackList } from "../redux/slices/playerSlice"; 18 | import LoadingSkeleton from "../components/LoadingSkeleton"; 19 | 20 | const ArtistePage = () => { 21 | const { id } = useParams(); 22 | const dispatch = useDispatch(); 23 | 24 | const [artiste, setArtiste] = useState([]); 25 | const [loading, setLoading] = useState(true); 26 | const [error, setError] = useState(false); 27 | 28 | const fetchArtiste = async () => { 29 | setLoading(true); 30 | setError(false); 31 | await client 32 | .get(`/artistes/${id}`) 33 | .then((res) => { 34 | setArtiste(res.data); 35 | setLoading(false); 36 | }) 37 | .catch(() => { 38 | setError(true); 39 | setLoading(false); 40 | }); 41 | }; 42 | 43 | useEffect(() => { 44 | fetchArtiste(); 45 | }, []); 46 | 47 | const handlePlay = () => { 48 | dispatch(setTrackList({ list: artiste?.songs })); 49 | dispatch(playTrack(artiste?.songs[0])); 50 | }; 51 | 52 | const onSongPlay = (song) => { 53 | const index = artiste?.songs.findIndex((s) => s._id == song._id); 54 | 55 | dispatch(setTrackList({ list: artiste?.songs, index })); 56 | dispatch(playTrack(song)); 57 | }; 58 | 59 | if (loading) { 60 | return ; 61 | } 62 | 63 | if (error) { 64 | return ( 65 | 66 | 67 | 68 | 69 | An error occured 70 | 71 | 72 | 73 | ); 74 | } 75 | 76 | return ( 77 | 78 | 79 | 85 | 86 | {artiste?.name} 94 | 95 | 96 | 102 | {artiste?.name} 103 | 104 | 108 | {artiste?.bio} 109 | 110 | 111 | 112 | 113 | 114 | 118 | Songs 119 | 120 | 134 | 135 | 136 | 137 | 138 | {artiste?.songs?.map((song) => ( 139 | 144 | ))} 145 | 146 | 147 | 148 | 149 | ); 150 | }; 151 | 152 | export default ArtistePage; 153 | -------------------------------------------------------------------------------- /client/src/components/SongCard.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Button, 4 | Flex, 5 | Heading, 6 | Image, 7 | Text, 8 | useToast, 9 | } from "@chakra-ui/react"; 10 | import { useDispatch, useSelector } from "react-redux"; 11 | import { motion } from "framer-motion"; 12 | import { fadeInUp } from "../theme/motionVariants"; 13 | import { 14 | setCurrentTrack, 15 | setPlaying, 16 | setTrackList, 17 | } from "../redux/slices/playerSlice"; 18 | import { 19 | AiFillHeart, 20 | AiFillPauseCircle, 21 | AiFillPlayCircle, 22 | AiOutlineHeart, 23 | } from "react-icons/ai"; 24 | import { Link } from "react-router-dom"; 25 | import { client } from "../api"; 26 | import { setUser } from "../redux/slices/userSlice"; 27 | 28 | const SongCard = ({ song }) => { 29 | const dispatch = useDispatch(); 30 | const { currentTrack, isPlaying } = useSelector((state) => state.player); 31 | const { user, token } = useSelector((state) => state.user); 32 | 33 | const toast = useToast(); 34 | 35 | const playSong = () => { 36 | dispatch(setCurrentTrack(song)); 37 | dispatch(setTrackList({ list: [song] })); 38 | dispatch(setPlaying(true)); 39 | }; 40 | 41 | const handleLike = async () => { 42 | await client 43 | .patch(`/songs/like/${song?._id}`, null, { 44 | headers: { 45 | Authorization: `Bearer ${token}`, 46 | }, 47 | }) 48 | .then((res) => { 49 | dispatch(setUser(res.data)); 50 | toast({ 51 | description: "Your favorites have been updated", 52 | status: "success", 53 | }); 54 | }) 55 | .catch(() => { 56 | toast({ 57 | description: "An error occured", 58 | status: "error", 59 | }); 60 | }); 61 | }; 62 | 63 | const isCurrentTrack = currentTrack?._id === song?._id; 64 | const isFavorite = user?.favorites.includes(song._id); 65 | 66 | return ( 67 | 78 | 85 | {song?.title} 93 | 106 | 120 | 121 | 122 | 123 | 124 | 129 | {song?.title} 130 | 131 | 132 | 136 | {" "} 137 | {song?.artistes.join(", ")}{" "} 138 | 139 | 140 | 141 | {user && ( 142 | 154 | )} 155 | 156 | 157 | ); 158 | }; 159 | 160 | export default SongCard; 161 | -------------------------------------------------------------------------------- /client/src/pages/LoginPage.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Button, 4 | Flex, 5 | FormControl, 6 | FormLabel, 7 | Heading, 8 | Input, 9 | InputGroup, 10 | InputRightElement, 11 | Spinner, 12 | Text, 13 | } from "@chakra-ui/react"; 14 | import { useState } from "react"; 15 | import { AiOutlineEye, AiOutlineEyeInvisible } from "react-icons/ai"; 16 | import { MdError } from "react-icons/md"; 17 | import { useDispatch } from "react-redux"; 18 | import { Link } from "react-router-dom"; 19 | import { client } from "../api"; 20 | import { loginUser } from "../redux/slices/userSlice"; 21 | import { resetPlayer } from "../redux/slices/playerSlice"; 22 | 23 | const LoginPage = () => { 24 | const [error, setError] = useState(null); 25 | const [loading, setLoading] = useState(false); 26 | const [username, setUsername] = useState(""); 27 | const [password, setPassword] = useState(""); 28 | const [showPassword, setShowPassword] = useState(false); 29 | const dispatch = useDispatch(); 30 | 31 | const validateFields = () => { 32 | if (username == "" || password == "") { 33 | setError("All fields are required!"); 34 | return false; 35 | } else { 36 | setError(null); 37 | return true; 38 | } 39 | }; 40 | 41 | const handleLogin = async () => { 42 | if (validateFields()) { 43 | setLoading(true); 44 | await client 45 | .post("/users/login", { 46 | username, 47 | password, 48 | }) 49 | .then((res) => { 50 | dispatch(resetPlayer()); 51 | dispatch(loginUser(res.data)); 52 | setLoading(false); 53 | }) 54 | .catch((err) => { 55 | setError(err?.response?.data?.message); 56 | setLoading(false); 57 | }); 58 | } 59 | }; 60 | 61 | return ( 62 | 63 | 67 | 68 | 69 | Login 70 | 71 | To continue enjoying BeatBox 72 | 73 | 74 | 75 | 76 | Username 77 | 78 | setUsername(e.target.value)} 88 | /> 89 | 90 | 91 | 92 | Password 93 | 94 | 95 | setPassword(e.target.value)} 103 | /> 104 | 105 | 113 | 114 | 115 | 116 | {error && ( 117 | 118 | 119 | 120 | {error} 121 | 122 | 123 | )} 124 | 125 | 133 | 134 | OR 135 | 136 | 137 | 138 | Continue without logging in 139 | 140 | 141 | 142 | 143 | {"Don't have an account yet?"}{" "} 144 | 145 | {" "} 146 | 147 | Register 148 | 149 | 150 | 151 | 152 | 153 | 154 | ); 155 | }; 156 | 157 | export default LoginPage; 158 | -------------------------------------------------------------------------------- /client/src/pages/RegisterPage.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Button, 4 | Flex, 5 | FormControl, 6 | FormLabel, 7 | Heading, 8 | Input, 9 | InputGroup, 10 | InputRightElement, 11 | Spinner, 12 | Text, 13 | } from "@chakra-ui/react"; 14 | import { useState } from "react"; 15 | import { AiOutlineEye, AiOutlineEyeInvisible } from "react-icons/ai"; 16 | import { MdError } from "react-icons/md"; 17 | import { Link } from "react-router-dom"; 18 | import { client } from "../api"; 19 | import { useDispatch } from "react-redux"; 20 | import { loginUser } from "../redux/slices/userSlice"; 21 | import { resetPlayer } from "../redux/slices/playerSlice"; 22 | 23 | const RegisterPage = () => { 24 | const [error, setError] = useState(null); 25 | const [loading, setLoading] = useState(false); 26 | const [username, setUsername] = useState(""); 27 | const [password, setPassword] = useState(""); 28 | const [showPassword, setShowPassword] = useState(false); 29 | const dispatch = useDispatch(); 30 | 31 | const validateFields = () => { 32 | if (username == "" || password == "") { 33 | setError("All fields are required!"); 34 | return false; 35 | } else { 36 | setError(null); 37 | return true; 38 | } 39 | }; 40 | 41 | const handleRegister = async () => { 42 | if (validateFields()) { 43 | setLoading(true); 44 | await client 45 | .post("/users/register", { 46 | username, 47 | password, 48 | }) 49 | .then((res) => { 50 | dispatch(resetPlayer()); 51 | dispatch(loginUser(res.data)); 52 | setLoading(false); 53 | }) 54 | .catch((err) => { 55 | setError(err?.response?.data?.message); 56 | setLoading(false); 57 | }); 58 | } 59 | }; 60 | 61 | return ( 62 | 63 | 67 | 68 | 69 | Register 70 | 71 | To continue enjoying BeatBox 72 | 73 | 74 | 75 | 76 | Username 77 | 78 | setUsername(e.target.value)} 88 | /> 89 | 90 | 91 | 92 | Password 93 | 94 | 95 | setPassword(e.target.value)} 103 | /> 104 | 105 | 113 | 114 | 115 | 116 | {error && ( 117 | 118 | 119 | 120 | {error} 121 | 122 | 123 | )} 124 | 125 | 133 | 134 | OR 135 | 136 | 137 | 138 | Continue without logging in 139 | 140 | 141 | 142 | 143 | Already have an account ?{" "} 144 | 145 | {" "} 146 | 147 | Login 148 | 149 | 150 | 151 | 152 | 153 | 154 | ); 155 | }; 156 | 157 | export default RegisterPage; 158 | -------------------------------------------------------------------------------- /client/src/pages/PlaylistPage.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { client } from "../api"; 3 | import { useParams, Link } from "react-router-dom"; 4 | import LoadingSkeleton from "../components/LoadingSkeleton"; 5 | import { MdErrorOutline } from "react-icons/md"; 6 | import { 7 | Box, 8 | Button, 9 | Divider, 10 | Flex, 11 | Heading, 12 | Image, 13 | Text, 14 | } from "@chakra-ui/react"; 15 | import ArtisteSong from "../components/ArtisteSong"; 16 | import { useDispatch, useSelector } from "react-redux"; 17 | import { playTrack, setTrackList } from "../redux/slices/playerSlice"; 18 | import { BsFillPlayFill } from "react-icons/bs"; 19 | import { AiFillEdit } from "react-icons/ai"; 20 | 21 | const PlaylistPage = () => { 22 | const [data, setData] = useState(null); 23 | const [loading, setLoading] = useState(false); 24 | const [error, setError] = useState(false); 25 | 26 | const { id } = useParams(); 27 | 28 | const dispatch = useDispatch(); 29 | const { user } = useSelector((state) => state.user); 30 | const isUserPlaylist = user?.id === data?.userId; 31 | 32 | const fetchPlaylist = async () => { 33 | setLoading(true); 34 | setError(false); 35 | await client 36 | .get(`/playlists/${id}`) 37 | .then((res) => { 38 | setData(res.data); 39 | setLoading(false); 40 | }) 41 | .catch(() => { 42 | setError(true); 43 | setLoading(false); 44 | }); 45 | }; 46 | 47 | useEffect(() => { 48 | fetchPlaylist(); 49 | }, []); 50 | 51 | const handlePlay = () => { 52 | dispatch(setTrackList({ list: data?.songs })); 53 | dispatch(playTrack(data?.songs[0])); 54 | }; 55 | 56 | const onSongPlay = (song) => { 57 | const index = data?.songs.findIndex((s) => s._id == song._id); 58 | 59 | dispatch(setTrackList({ list: data?.songs, index })); 60 | dispatch(playTrack(song)); 61 | }; 62 | 63 | if (loading) { 64 | return ; 65 | } 66 | 67 | if (error) { 68 | return ( 69 | 70 | 71 | 72 | 73 | An error occured 74 | 75 | 76 | 77 | ); 78 | } 79 | 80 | return ( 81 | 87 | 88 | 93 | 94 | {data?.title} 102 | 103 | 104 | 110 | Playlist 111 | 112 | 118 | {data?.title} 119 | 120 | 121 | {data?.description} 122 | 123 | {isUserPlaylist && ( 124 | 125 | 133 | 134 | )} 135 | 136 | 137 | 138 | 139 | 143 | {data?.songs?.length} Songs 144 | 145 | 159 | 160 | 161 | 162 | 163 | {data?.songs?.map((song) => ( 164 | 169 | ))} 170 | 171 | 172 | 173 | 174 | ); 175 | }; 176 | 177 | export default PlaylistPage; 178 | -------------------------------------------------------------------------------- /client/src/components/Navbar.jsx: -------------------------------------------------------------------------------- 1 | import { BiMenuAltRight, BiMusic } from "react-icons/bi"; 2 | import { AiFillHeart, AiFillHome, AiOutlineLogout } from "react-icons/ai"; 3 | import { BsHeadphones } from "react-icons/bs"; 4 | import { TiTimes } from "react-icons/ti"; 5 | import { HiOutlineUserCircle, HiViewGrid } from "react-icons/hi"; 6 | import { Link, NavLink, useLocation, useNavigate } from "react-router-dom"; 7 | import { 8 | Box, 9 | Button, 10 | Divider, 11 | Flex, 12 | Heading, 13 | Hide, 14 | Show, 15 | Text, 16 | } from "@chakra-ui/react"; 17 | import { useDispatch, useSelector } from "react-redux"; 18 | import { logoutUser } from "../redux/slices/userSlice"; 19 | import { resetPlayer } from "../redux/slices/playerSlice"; 20 | import { useEffect, useState } from "react"; 21 | 22 | const MobileNav = () => { 23 | const [navIsOpen, setNavIsOpen] = useState(false); 24 | const { pathname } = useLocation(); 25 | 26 | useEffect(() => { 27 | setNavIsOpen(false); 28 | }, [pathname]); 29 | 30 | const toggleNav = () => { 31 | setNavIsOpen(!navIsOpen); 32 | }; 33 | 34 | return ( 35 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | BeatBox 50 | 51 | 52 | 53 | 56 | 57 | {navIsOpen && ( 58 | 59 | 60 | 61 | )} 62 | 63 | ); 64 | }; 65 | 66 | const DesktopNav = () => { 67 | return ( 68 | 78 | 79 | 80 | 81 | 82 | BeatBox 83 | 84 | 85 | 86 | 87 | 88 | ); 89 | }; 90 | 91 | const NavContent = () => { 92 | const { user } = useSelector((state) => state.user); 93 | const dispatch = useDispatch(); 94 | const navigate = useNavigate(); 95 | 96 | const handleLogout = () => { 97 | dispatch(resetPlayer()); 98 | dispatch(logoutUser()); 99 | navigate("/auth/login"); 100 | }; 101 | 102 | const gotoLogin = () => { 103 | dispatch(resetPlayer()); 104 | navigate("/auth/login"); 105 | }; 106 | return ( 107 | 108 | 109 | 110 | {({ isActive }) => ( 111 | 127 | )} 128 | 129 | 130 | {({ isActive }) => ( 131 | 147 | )} 148 | 149 | 150 | {({ isActive }) => ( 151 | 167 | )} 168 | 169 | 170 | {({ isActive }) => ( 171 | 187 | )} 188 | 189 | 190 | 197 | 198 | {user ? ( 199 | 200 | 201 | 202 | 203 | {user?.username} 204 | 205 | 206 | 217 | 218 | ) : ( 219 | 231 | )} 232 | 233 | 234 | ); 235 | }; 236 | 237 | const Navbar = () => { 238 | return ( 239 | <> 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | ); 248 | }; 249 | 250 | export default Navbar; 251 | -------------------------------------------------------------------------------- /client/src/components/MusicPlayer/index.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import { 3 | Button, 4 | Flex, 5 | Hide, 6 | SimpleGrid, 7 | useDisclosure, 8 | useToast, 9 | } from "@chakra-ui/react"; 10 | import { useDispatch, useSelector } from "react-redux"; 11 | import { AiFillHeart, AiOutlineHeart } from "react-icons/ai"; 12 | import { 13 | nextTrack, 14 | prevTrack, 15 | setPlaying, 16 | } from "../../redux/slices/playerSlice"; 17 | import { client } from "../../api"; 18 | import { setUser } from "../../redux/slices/userSlice"; 19 | import VolumeControl from "./VolumeControl"; 20 | import TrackDetails from "./TrackDetails"; 21 | import PlayControls from "./PlayControls"; 22 | import LoginModal from "../LoginModal"; 23 | import PlayingBar from "./PlayingBar"; 24 | import { setModalMessage } from "../../redux/slices/modalSlice"; 25 | 26 | const MusicPlayer = () => { 27 | const { isOpen, onOpen, onClose } = useDisclosure(); 28 | const modalRef = useRef(); 29 | const toast = useToast(); 30 | const dispatch = useDispatch(); 31 | const { currentTrack, repeatStatus, currentIndex, trackList, isPlaying } = 32 | useSelector((state) => state.player); 33 | const { user, token } = useSelector((state) => state.user); 34 | const audioRef = useRef(); 35 | 36 | const isEndOfTracklist = currentIndex === trackList.length - 1; 37 | 38 | const [songDetails, setSongDetails] = useState(null); 39 | const [audioPlaying, setAudioPlaying] = useState( 40 | audioRef.current && audioRef.current.playing 41 | ); 42 | 43 | useEffect(() => { 44 | if (audioPlaying) { 45 | dispatch(setPlaying(true)); 46 | } else { 47 | dispatch(setPlaying(false)); 48 | } 49 | }, [audioPlaying]); 50 | 51 | useEffect(() => { 52 | if (isPlaying) { 53 | audioRef.current.play(); 54 | } 55 | }, [isPlaying]); 56 | 57 | useEffect(() => { 58 | setSongDetails((prev) => { 59 | return { ...prev, time: 0 }; 60 | }); 61 | audioRef.current.currentTime = 0; 62 | audioRef.current.play(); 63 | }, [currentTrack?._id]); 64 | 65 | useEffect(() => { 66 | setSongDetails({ 67 | volume: 1, 68 | time: audioRef?.current 69 | ? Math.round( 70 | (audioRef?.current.currentTime / audioRef.current.duration) * 100 71 | ) // eslint-disable-line no-mixed-spaces-and-tabs 72 | : 0, 73 | shuffle: false, 74 | repeat: false, 75 | }); 76 | }, [audioRef.current]); 77 | 78 | const seekPoint = (e) => { 79 | audioRef.current.currentTime = (e / 100) * audioRef.current.duration; 80 | 81 | setSongDetails((prev) => ({ 82 | ...prev, 83 | time: Math.round( 84 | (audioRef.current.currentTime / audioRef.current.duration) * 100 85 | ), 86 | })); 87 | }; 88 | 89 | const changeVolume = (e) => { 90 | setSongDetails((prevValues) => { 91 | return { ...prevValues, volume: e / 100 }; 92 | }); 93 | audioRef.current.volume = e / 100; 94 | }; 95 | 96 | const handlePlayPause = () => { 97 | if (isPlaying) { 98 | audioRef?.current.pause(); 99 | dispatch(setPlaying(false)); 100 | } else { 101 | audioRef?.current.play(); 102 | dispatch(setPlaying(true)); 103 | } 104 | }; 105 | 106 | const volumeToggle = () => { 107 | if (songDetails?.volume > 0) { 108 | setSongDetails((prev) => { 109 | return { ...prev, volume: 0 }; 110 | }); 111 | audioRef.current.volume = 0; 112 | } else { 113 | setSongDetails((prev) => { 114 | return { ...prev, volume: 1 }; 115 | }); 116 | audioRef.current.volume = 1; 117 | } 118 | }; 119 | 120 | useEffect(() => { 121 | audioRef.current.currentTime = 0; 122 | audioRef?.current.play(); 123 | dispatch(setPlaying(true)); 124 | }, [currentTrack.src]); 125 | 126 | const handleNextSong = () => { 127 | if (trackList.length == 1) { 128 | restartSong(); 129 | } else { 130 | dispatch(nextTrack()); 131 | } 132 | }; 133 | 134 | const handlePreviousSong = () => { 135 | if (trackList.length == 1) { 136 | restartSong(); 137 | } else { 138 | dispatch(prevTrack()); 139 | } 140 | }; 141 | 142 | const restartSong = () => { 143 | setSongDetails((prev) => { 144 | return { ...prev, time: 0 }; 145 | }); 146 | audioRef.current.currentTime = 0; 147 | audioRef.current.play(); 148 | }; 149 | 150 | const handleEnded = () => { 151 | switch (repeatStatus) { 152 | case "OFF": 153 | if (!isEndOfTracklist) { 154 | handleNextSong(); 155 | } 156 | break; 157 | case "TRACKLIST": 158 | handleNextSong(); 159 | break; 160 | case "SINGLE": 161 | audioRef.current.play(); 162 | break; 163 | 164 | default: 165 | break; 166 | } 167 | }; 168 | 169 | const likeSong = async () => { 170 | await client 171 | .patch(`/songs/like/${currentTrack?._id}`, null, { 172 | headers: { 173 | Authorization: `Bearer ${token}`, 174 | }, 175 | }) 176 | .then((res) => { 177 | dispatch(setUser(res.data)); 178 | toast({ 179 | description: "Your favorites have been updated", 180 | status: "success", 181 | }); 182 | }) 183 | .catch(() => { 184 | toast({ 185 | description: "An error occured", 186 | status: "error", 187 | }); 188 | }); 189 | }; 190 | 191 | const handleLike = () => { 192 | if (!token) { 193 | dispatch( 194 | setModalMessage("Please login to save songs to your favorites.") 195 | ); 196 | onOpen(); 197 | } else { 198 | likeSong(); 199 | } 200 | }; 201 | 202 | return ( 203 | <> 204 | 205 | 220 | 221 | 222 | 229 | 230 | 236 | 237 | 238 | 239 | 256 | 257 | 258 | 263 | 264 | 281 | 282 | 283 | 284 | ); 285 | }; 286 | 287 | export { MusicPlayer }; 288 | -------------------------------------------------------------------------------- /client/src/pages/EditPlaylistPage.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useNavigate, useParams } from "react-router-dom"; 3 | import { 4 | Box, 5 | Button, 6 | Divider, 7 | Flex, 8 | FormControl, 9 | FormLabel, 10 | Heading, 11 | Input, 12 | SimpleGrid, 13 | Spinner, 14 | Text, 15 | useToast, 16 | } from "@chakra-ui/react"; 17 | import { client } from "../api"; 18 | import PlaylistSong from "../components/PlaylistSong"; 19 | import { useSelector } from "react-redux"; 20 | import { AiOutlineLoading } from "react-icons/ai"; 21 | 22 | const EditPlaylistPage = () => { 23 | const [fetchPlaylistStatus, setFetchPlaylistStatus] = useState({ 24 | loading: false, 25 | error: false, 26 | }); 27 | const [otherSongs, setOtherSongs] = useState({ 28 | loading: false, 29 | error: false, 30 | data: [], 31 | }); 32 | const [editLoading, setEditLoading] = useState(false); 33 | const [playlistSongs, setPlaylistSongs] = useState([]); 34 | const [inputs, setInputs] = useState({ 35 | playlistName: "", 36 | playlistDesc: "", 37 | }); 38 | 39 | const { token } = useSelector((state) => state.user); 40 | 41 | const { id } = useParams(); 42 | const navigate = useNavigate(); 43 | const toast = useToast(); 44 | 45 | const fetchPlaylist = async () => { 46 | setFetchPlaylistStatus({ loading: true, error: false }); 47 | await client 48 | .get(`/playlists/${id}`) 49 | .then((res) => { 50 | setInputs({ 51 | playlistName: res.data.title, 52 | playlistDesc: res.data.description, 53 | }); 54 | setPlaylistSongs(res.data.songs); 55 | 56 | setFetchPlaylistStatus({ loading: false, error: false }); 57 | }) 58 | .catch(() => { 59 | setFetchPlaylistStatus({ loading: false, error: true }); 60 | }); 61 | }; 62 | 63 | const fetchOtherSongs = async () => { 64 | setOtherSongs((prev) => { 65 | return { ...prev, error: false, loading: true }; 66 | }); 67 | await client 68 | .get("/songs/random") 69 | .then((res) => { 70 | setOtherSongs((prev) => { 71 | return { ...prev, data: res.data, loading: false }; 72 | }); 73 | }) 74 | .catch(() => { 75 | setOtherSongs((prev) => { 76 | return { ...prev, error: true, loading: false }; 77 | }); 78 | }); 79 | }; 80 | 81 | useEffect(() => { 82 | fetchPlaylist(); 83 | fetchOtherSongs(); 84 | }, []); 85 | 86 | const songIsInPlaylist = (song) => { 87 | const songExists = playlistSongs.find((s) => song._id === s._id); 88 | return songExists ? true : false; 89 | }; 90 | 91 | const toggleAddSong = (song) => { 92 | if (songIsInPlaylist(song)) { 93 | setPlaylistSongs( 94 | playlistSongs.filter((currentSong) => currentSong._id !== song._id) 95 | ); 96 | } else { 97 | setPlaylistSongs([...playlistSongs, song]); 98 | } 99 | }; 100 | 101 | const canEditPlaylist = () => { 102 | if (inputs.playlistName == "") { 103 | return false; 104 | } 105 | if (playlistSongs.length < 1) { 106 | return false; 107 | } 108 | 109 | return true; 110 | }; 111 | 112 | const handleEditPlaylist = async () => { 113 | if (!canEditPlaylist()) { 114 | toast({ 115 | description: 116 | inputs.playlistName == "" 117 | ? "Give your playlist a name!" 118 | : "Add songs to your playlist!", 119 | status: "error", 120 | }); 121 | } else { 122 | editPlaylist(); 123 | } 124 | }; 125 | 126 | const editPlaylist = async () => { 127 | setEditLoading(true); 128 | const songIds = playlistSongs.map((song) => song._id); 129 | const playlistDetails = { 130 | title: inputs.playlistName, 131 | description: inputs.playlistDesc, 132 | songIds, 133 | }; 134 | await client 135 | .patch(`/playlists/${id}`, playlistDetails, { 136 | headers: { 137 | Authorization: `Bearer ${token}`, 138 | "Content-Type": "application/json", 139 | }, 140 | }) 141 | .then(() => { 142 | setEditLoading(false); 143 | toast({ 144 | description: "Playlist updated!", 145 | status: "success", 146 | }); 147 | navigate("/home"); 148 | }) 149 | .catch((err) => { 150 | setEditLoading(false); 151 | toast({ 152 | description: 153 | err?.response.data.message || "Could not update playlist!", 154 | status: "error", 155 | }); 156 | }); 157 | }; 158 | 159 | return ( 160 | 161 | 162 | 163 | Edit Playlist 164 | 165 | 177 | 178 | 179 | 180 | {fetchPlaylistStatus.loading && ( 181 | 187 | 188 | 189 | )} 190 | {!fetchPlaylistStatus.loading && !fetchPlaylistStatus.error && ( 191 | <> 192 | 197 | 198 | 199 | Playlist Name 200 | 201 | 211 | setInputs({ ...inputs, playlistName: e.target.value }) 212 | } 213 | /> 214 | 215 | 216 | 217 | Playlist Description (optional) 218 | 219 | 229 | setInputs({ ...inputs, playlistDesc: e.target.value }) 230 | } 231 | /> 232 | 233 | 234 | 235 | Songs 236 | 237 | 238 | 239 | 243 | {playlistSongs?.map((song) => ( 244 | toggleAddSong(song)} 248 | isAdded={songIsInPlaylist(song)} 249 | /> 250 | ))} 251 | 252 | 253 | )} 254 | {!fetchPlaylistStatus.loading && fetchPlaylistStatus.error && ( 255 | 256 | Sorry, an error occured 257 | 258 | )} 259 | 260 | 261 | Add Other Songs 262 | 263 | 279 | 280 | 281 | {otherSongs.loading && ( 282 | 288 | 289 | 290 | )} 291 | {!otherSongs.loading && otherSongs.error && ( 292 | 293 | Sorry, an error occured 294 | 295 | )} 296 | {!otherSongs.loading && !otherSongs.error && ( 297 | 301 | {otherSongs?.data?.map((song) => ( 302 | toggleAddSong(song)} 307 | /> 308 | ))} 309 | 310 | )} 311 | 312 | 313 | ); 314 | }; 315 | 316 | export default EditPlaylistPage; 317 | -------------------------------------------------------------------------------- /client/src/pages/CreatePlaylistPage.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Button, 4 | Divider, 5 | Flex, 6 | FormControl, 7 | FormLabel, 8 | Heading, 9 | Input, 10 | SimpleGrid, 11 | Spinner, 12 | Switch, 13 | Text, 14 | useToast, 15 | } from "@chakra-ui/react"; 16 | import { useEffect, useState } from "react"; 17 | import { useNavigate } from "react-router-dom"; 18 | import { useDispatch, useSelector } from "react-redux"; 19 | import PlaylistSong from "../components/PlaylistSong"; 20 | import { setUser } from "../redux/slices/userSlice"; 21 | import { AiOutlineLoading } from "react-icons/ai"; 22 | import { client } from "../api"; 23 | 24 | const CreatePlaylistPage = () => { 25 | const [favorites, setFavorites] = useState([]); 26 | const [favoritesLoading, setFavoritesLoading] = useState(false); 27 | const [otherSongs, setOtherSongs] = useState([]); 28 | const [otherSongsLoading, setOtherSongsLoading] = useState(false); 29 | const [createPlLoading, setCreatePlLoading] = useState(false); 30 | const [inputs, setInputs] = useState({ 31 | playlistName: "", 32 | playlistDesc: "", 33 | isPrivate: false, 34 | }); 35 | const [error, setError] = useState({ favorites: false, otherSongs: false }); 36 | const [addedSongs, setAddedSongs] = useState([]); 37 | const toast = useToast(); 38 | const navigate = useNavigate(); 39 | 40 | const { user, token } = useSelector((state) => state.user); 41 | const dispatch = useDispatch(); 42 | 43 | const fetchFavorites = async () => { 44 | setFavoritesLoading(true); 45 | setError({ ...error, favorites: false }); 46 | await client 47 | .get("/users/favorites", { 48 | headers: { 49 | Authorization: `Bearer ${token}`, 50 | }, 51 | }) 52 | .then((res) => { 53 | setFavoritesLoading(false); 54 | setFavorites(res.data); 55 | }) 56 | .catch(() => { 57 | setFavoritesLoading(false); 58 | setError({ ...error, favorites: true }); 59 | }); 60 | }; 61 | 62 | const fetchOtherSongs = async () => { 63 | setError({ ...error, otherSongs: false }); 64 | setOtherSongsLoading(true); 65 | await client 66 | .get("/songs/random") 67 | .then((res) => { 68 | setOtherSongs(res.data); 69 | setOtherSongsLoading(false); 70 | }) 71 | .catch(() => { 72 | setError({ ...error, otherSongs: true }); 73 | setOtherSongsLoading(false); 74 | }); 75 | }; 76 | 77 | useEffect(() => { 78 | fetchFavorites(); 79 | fetchOtherSongs(); 80 | }, []); 81 | 82 | const createPlaylist = async () => { 83 | const playlistDetails = { 84 | title: inputs.playlistName, 85 | description: inputs.playlistDesc, 86 | isPrivate: inputs.isPrivate, 87 | songIds: addedSongs, 88 | }; 89 | setCreatePlLoading(true); 90 | await client 91 | .post("/playlists/create", playlistDetails, { 92 | headers: { 93 | Authorization: `Bearer ${token}`, 94 | "Content-Type": "application/json", 95 | }, 96 | }) 97 | .then((res) => { 98 | setCreatePlLoading(false); 99 | toast({ 100 | description: "Playlist created!", 101 | status: "success", 102 | }); 103 | navigate("/home"); 104 | dispatch( 105 | setUser({ ...user, playlists: [...user?.playlists, res.data?._id] }) 106 | ); 107 | }) 108 | .catch(() => { 109 | setCreatePlLoading(false); 110 | toast({ 111 | description: "An error occured!", 112 | status: "error", 113 | }); 114 | }); 115 | }; 116 | 117 | const songIsInPlaylist = (id) => { 118 | return addedSongs.includes(id); 119 | }; 120 | 121 | const toggleAddSong = (id) => { 122 | if (songIsInPlaylist(id)) { 123 | setAddedSongs(addedSongs.filter((songId) => songId !== id)); 124 | } else { 125 | setAddedSongs([...addedSongs, id]); 126 | } 127 | }; 128 | 129 | const canCreatePlaylist = () => { 130 | if (inputs.playlistName == "") { 131 | return false; 132 | } 133 | if (addedSongs.length < 1) { 134 | return false; 135 | } 136 | 137 | return true; 138 | }; 139 | 140 | const handleCreatePlaylist = async () => { 141 | if (!canCreatePlaylist()) { 142 | toast({ 143 | description: 144 | inputs.playlistName == "" 145 | ? "Give your playlist a name!" 146 | : "Add songs to your playlist!", 147 | status: "error", 148 | }); 149 | } else { 150 | createPlaylist(); 151 | } 152 | }; 153 | 154 | return ( 155 | 161 | 162 | 163 | Create a Playlist 164 | 165 | 175 | 176 | 177 | 178 | 179 | 180 | Private playlist? 181 | 182 | 188 | setInputs({ ...inputs, isPrivate: !inputs.isPrivate }) 189 | } 190 | /> 191 | 192 | 193 | 194 | 198 | 199 | 200 | Playlist Name 201 | 202 | 212 | setInputs({ ...inputs, playlistName: e.target.value }) 213 | } 214 | /> 215 | 216 | 217 | 218 | Playlist Description (optional) 219 | 220 | 230 | setInputs({ ...inputs, playlistDesc: e.target.value }) 231 | } 232 | /> 233 | 234 | 235 | 236 | 237 | Add Songs 238 | 239 | {favoritesLoading ? ( 240 | 246 | 247 | 248 | ) : error.favorites ? ( 249 | 250 | Sorry, an error occured 251 | 252 | ) : ( 253 | <> 254 | 255 | From Your favorites 256 | 257 | 258 | 262 | {favorites?.map((song) => ( 263 | toggleAddSong(song?._id)} 267 | isAdded={songIsInPlaylist(song?._id)} 268 | /> 269 | ))} 270 | 271 | 272 | )} 273 | 274 | 275 | From Library 276 | 277 | 289 | 290 | 291 | {otherSongsLoading ? ( 292 | 298 | 299 | 300 | ) : error.otherSongs ? ( 301 | 302 | Sorry, an error occured 303 | 304 | ) : ( 305 | <> 306 | 310 | {otherSongs?.map((song) => ( 311 | toggleAddSong(song?._id)} 316 | /> 317 | ))} 318 | 319 | 320 | )} 321 | 322 | 323 | 324 | ); 325 | }; 326 | 327 | export default CreatePlaylistPage; 328 | --------------------------------------------------------------------------------