├── README.md ├── backend ├── .env.sample ├── .gitignore ├── package-lock.json ├── package.json ├── src │ ├── controller │ │ ├── admin.controller.js │ │ ├── album.controller.js │ │ ├── auth.controller.js │ │ ├── song.controller.js │ │ ├── stat.controller.js │ │ └── user.controller.js │ ├── index.js │ ├── lib │ │ ├── cloudinary.js │ │ ├── db.js │ │ └── socket.js │ ├── middleware │ │ └── auth.middleware.js │ ├── models │ │ ├── album.model.js │ │ ├── message.model.js │ │ ├── song.model.js │ │ └── user.model.js │ ├── routes │ │ ├── admin.route.js │ │ ├── album.route.js │ │ ├── auth.route.js │ │ ├── song.route.js │ │ ├── stat.route.js │ │ └── user.route.js │ └── seeds │ │ ├── albums.js │ │ └── songs.js └── tmp │ ├── tmp-1-1730755008772 │ ├── tmp-1-1730755339016 │ ├── tmp-1-1730830998740 │ ├── tmp-2-1730755008784 │ ├── tmp-2-1730755339025 │ ├── tmp-2-1730831033299 │ ├── tmp-3-1730755049451 │ ├── tmp-3-1730831122457 │ ├── tmp-4-1730755049459 │ ├── tmp-4-1730831122471 │ ├── tmp-5-1730831173013 │ └── tmp-6-1730831173018 ├── frontend ├── .env.sample ├── .gitignore ├── README.md ├── components.json ├── eslint.config.js ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── public │ ├── albums │ │ ├── 1.jpg │ │ ├── 2.jpg │ │ ├── 3.jpg │ │ └── 4.jpg │ ├── cover-images │ │ ├── 1.jpg │ │ ├── 10.jpg │ │ ├── 11.jpg │ │ ├── 12.jpg │ │ ├── 13.jpg │ │ ├── 14.jpg │ │ ├── 15.jpg │ │ ├── 16.jpg │ │ ├── 17.jpg │ │ ├── 18.jpg │ │ ├── 2.jpg │ │ ├── 3.jpg │ │ ├── 4.jpg │ │ ├── 5.jpg │ │ ├── 6.jpg │ │ ├── 7.jpg │ │ ├── 8.jpg │ │ └── 9.jpg │ ├── google.png │ ├── screenshot-for-readme.png │ ├── songs │ │ ├── 1.mp3 │ │ ├── 10.mp3 │ │ ├── 11.mp3 │ │ ├── 12.mp3 │ │ ├── 13.mp3 │ │ ├── 14.mp3 │ │ ├── 15.mp3 │ │ ├── 16.mp3 │ │ ├── 17.mp3 │ │ ├── 18.mp3 │ │ ├── 2.mp3 │ │ ├── 3.mp3 │ │ ├── 4.mp3 │ │ ├── 5.mp3 │ │ ├── 6.mp3 │ │ ├── 7.mp3 │ │ ├── 8.mp3 │ │ └── 9.mp3 │ ├── spotify.png │ └── vite.svg ├── src │ ├── App.tsx │ ├── components │ │ ├── SignInOAuthButtons.tsx │ │ ├── Topbar.tsx │ │ ├── skeletons │ │ │ ├── FeaturedGridSkeleton.tsx │ │ │ ├── PlaylistSkeleton.tsx │ │ │ └── UsersListSkeleton.tsx │ │ └── ui │ │ │ ├── avatar.tsx │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── dialog.tsx │ │ │ ├── input.tsx │ │ │ ├── resizable.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── slider.tsx │ │ │ ├── table.tsx │ │ │ └── tabs.tsx │ ├── index.css │ ├── layout │ │ ├── MainLayout.tsx │ │ └── components │ │ │ ├── AudioPlayer.tsx │ │ │ ├── FriendsActivity.tsx │ │ │ ├── LeftSidebar.tsx │ │ │ └── PlaybackControls.tsx │ ├── lib │ │ ├── axios.ts │ │ └── utils.ts │ ├── main.tsx │ ├── pages │ │ ├── 404 │ │ │ └── NotFoundPage.tsx │ │ ├── admin │ │ │ ├── AdminPage.tsx │ │ │ └── components │ │ │ │ ├── AddAlbumDialog.tsx │ │ │ │ ├── AddSongDialog.tsx │ │ │ │ ├── AlbumsTabContent.tsx │ │ │ │ ├── AlbumsTable.tsx │ │ │ │ ├── DashboardStats.tsx │ │ │ │ ├── Header.tsx │ │ │ │ ├── SongsTabContent.tsx │ │ │ │ ├── SongsTable.tsx │ │ │ │ └── StatsCard.tsx │ │ ├── album │ │ │ └── AlbumPage.tsx │ │ ├── auth-callback │ │ │ └── AuthCallbackPage.tsx │ │ ├── chat │ │ │ ├── ChatPage.tsx │ │ │ └── components │ │ │ │ ├── ChatHeader.tsx │ │ │ │ ├── MessageInput.tsx │ │ │ │ └── UsersList.tsx │ │ └── home │ │ │ ├── HomePage.tsx │ │ │ └── components │ │ │ ├── FeaturedSection.tsx │ │ │ ├── PlayButton.tsx │ │ │ ├── SectionGrid.tsx │ │ │ └── SectionGridSkeleton.tsx │ ├── providers │ │ └── AuthProvider.tsx │ ├── stores │ │ ├── useAuthStore.ts │ │ ├── useChatStore.ts │ │ ├── useMusicStore.ts │ │ └── usePlayerStore.ts │ ├── types │ │ └── index.ts │ └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts └── package.json /README.md: -------------------------------------------------------------------------------- 1 |

Realtime Spotify Application ✨

2 | 3 | ![Demo App](/frontend/public/screenshot-for-readme.png) 4 | 5 | [Watch Full Tutorial on Youtube](https://youtu.be/4sbklcQ0EXc) 6 | 7 | About This Course: 8 | 9 | - 🎸 Listen to music, play next and previous songs 10 | - 🔈 Update the volume with a slider 11 | - 🎧 Admin dashboard to create albums and songs 12 | - 💬 Real-time Chat App integrated into Spotify 13 | - 👨🏼‍💼 Online/Offline status 14 | - 👀 See what other users are listening to in real-time 15 | - 📊 Aggregate data for the analytics page 16 | - 🚀 And a lot more... 17 | 18 | ### Setup .env file in _backend_ folder 19 | 20 | ```bash 21 | PORT=... 22 | MONGODB_URI=... 23 | ADMIN_EMAIL=... 24 | NODE_ENV=... 25 | 26 | CLOUDINARY_API_KEY=... 27 | CLOUDINARY_API_SECRET=... 28 | CLOUDINARY_CLOUD_NAME=... 29 | 30 | 31 | CLERK_PUBLISHABLE_KEY=... 32 | CLERK_SECRET_KEY=... 33 | ``` 34 | 35 | ### Setup .env file in _frontend_ folder 36 | 37 | ```bash 38 | VITE_CLERK_PUBLISHABLE_KEY=... 39 | ``` 40 | -------------------------------------------------------------------------------- /backend/.env.sample: -------------------------------------------------------------------------------- 1 | PORT= 2 | MONGODB_URI= 3 | ADMIN_EMAIL= 4 | NODE_ENV= 5 | 6 | CLOUDINARY_API_KEY= 7 | CLOUDINARY_API_SECRET= 8 | CLOUDINARY_CLOUD_NAME= 9 | 10 | 11 | CLERK_PUBLISHABLE_KEY= 12 | CLERK_SECRET_KEY= -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | node_modules -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "nodemon src/index.js", 8 | "start": "node src/index.js", 9 | "seed:songs": "node src/seeds/songs.js", 10 | "seed:albums": "node src/seeds/albums.js" 11 | }, 12 | "keywords": [], 13 | "type": "module", 14 | "author": "", 15 | "license": "ISC", 16 | "dependencies": { 17 | "@clerk/express": "^1.3.4", 18 | "cloudinary": "^2.5.1", 19 | "cors": "^2.8.5", 20 | "dotenv": "^16.4.5", 21 | "express": "^4.21.1", 22 | "express-fileupload": "^1.5.1", 23 | "mongoose": "^8.8.0", 24 | "node-cron": "^3.0.3", 25 | "socket.io": "^4.8.1" 26 | }, 27 | "devDependencies": { 28 | "nodemon": "^3.1.7" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /backend/src/controller/admin.controller.js: -------------------------------------------------------------------------------- 1 | import { Song } from "../models/song.model.js"; 2 | import { Album } from "../models/album.model.js"; 3 | import cloudinary from "../lib/cloudinary.js"; 4 | 5 | // helper function for cloudinary uploads 6 | const uploadToCloudinary = async (file) => { 7 | try { 8 | const result = await cloudinary.uploader.upload(file.tempFilePath, { 9 | resource_type: "auto", 10 | }); 11 | return result.secure_url; 12 | } catch (error) { 13 | console.log("Error in uploadToCloudinary", error); 14 | throw new Error("Error uploading to cloudinary"); 15 | } 16 | }; 17 | 18 | export const createSong = async (req, res, next) => { 19 | try { 20 | if (!req.files || !req.files.audioFile || !req.files.imageFile) { 21 | return res.status(400).json({ message: "Please upload all files" }); 22 | } 23 | 24 | const { title, artist, albumId, duration } = req.body; 25 | const audioFile = req.files.audioFile; 26 | const imageFile = req.files.imageFile; 27 | 28 | const audioUrl = await uploadToCloudinary(audioFile); 29 | const imageUrl = await uploadToCloudinary(imageFile); 30 | 31 | const song = new Song({ 32 | title, 33 | artist, 34 | audioUrl, 35 | imageUrl, 36 | duration, 37 | albumId: albumId || null, 38 | }); 39 | 40 | await song.save(); 41 | 42 | // if song belongs to an album, update the album's songs array 43 | if (albumId) { 44 | await Album.findByIdAndUpdate(albumId, { 45 | $push: { songs: song._id }, 46 | }); 47 | } 48 | res.status(201).json(song); 49 | } catch (error) { 50 | console.log("Error in createSong", error); 51 | next(error); 52 | } 53 | }; 54 | 55 | export const deleteSong = async (req, res, next) => { 56 | try { 57 | const { id } = req.params; 58 | 59 | const song = await Song.findById(id); 60 | 61 | // if song belongs to an album, update the album's songs array 62 | if (song.albumId) { 63 | await Album.findByIdAndUpdate(song.albumId, { 64 | $pull: { songs: song._id }, 65 | }); 66 | } 67 | 68 | await Song.findByIdAndDelete(id); 69 | 70 | res.status(200).json({ message: "Song deleted successfully" }); 71 | } catch (error) { 72 | console.log("Error in deleteSong", error); 73 | next(error); 74 | } 75 | }; 76 | 77 | export const createAlbum = async (req, res, next) => { 78 | try { 79 | const { title, artist, releaseYear } = req.body; 80 | const { imageFile } = req.files; 81 | 82 | const imageUrl = await uploadToCloudinary(imageFile); 83 | 84 | const album = new Album({ 85 | title, 86 | artist, 87 | imageUrl, 88 | releaseYear, 89 | }); 90 | 91 | await album.save(); 92 | 93 | res.status(201).json(album); 94 | } catch (error) { 95 | console.log("Error in createAlbum", error); 96 | next(error); 97 | } 98 | }; 99 | 100 | export const deleteAlbum = async (req, res, next) => { 101 | try { 102 | const { id } = req.params; 103 | await Song.deleteMany({ albumId: id }); 104 | await Album.findByIdAndDelete(id); 105 | res.status(200).json({ message: "Album deleted successfully" }); 106 | } catch (error) { 107 | console.log("Error in deleteAlbum", error); 108 | next(error); 109 | } 110 | }; 111 | 112 | export const checkAdmin = async (req, res, next) => { 113 | res.status(200).json({ admin: true }); 114 | }; 115 | -------------------------------------------------------------------------------- /backend/src/controller/album.controller.js: -------------------------------------------------------------------------------- 1 | import { Album } from "../models/album.model.js"; 2 | 3 | export const getAllAlbums = async (req, res, next) => { 4 | try { 5 | const albums = await Album.find(); 6 | res.status(200).json(albums); 7 | } catch (error) { 8 | next(error); 9 | } 10 | }; 11 | 12 | export const getAlbumById = async (req, res, next) => { 13 | try { 14 | const { albumId } = req.params; 15 | 16 | const album = await Album.findById(albumId).populate("songs"); 17 | 18 | if (!album) { 19 | return res.status(404).json({ message: "Album not found" }); 20 | } 21 | 22 | res.status(200).json(album); 23 | } catch (error) { 24 | next(error); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /backend/src/controller/auth.controller.js: -------------------------------------------------------------------------------- 1 | import { User } from "../models/user.model.js"; 2 | 3 | export const authCallback = async (req, res, next) => { 4 | try { 5 | const { id, firstName, lastName, imageUrl } = req.body; 6 | 7 | // check if user already exists 8 | const user = await User.findOne({ clerkId: id }); 9 | 10 | if (!user) { 11 | // signup 12 | await User.create({ 13 | clerkId: id, 14 | fullName: `${firstName || ""} ${lastName || ""}`.trim(), 15 | imageUrl, 16 | }); 17 | } 18 | 19 | res.status(200).json({ success: true }); 20 | } catch (error) { 21 | console.log("Error in auth callback", error); 22 | next(error); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /backend/src/controller/song.controller.js: -------------------------------------------------------------------------------- 1 | import { Song } from "../models/song.model.js"; 2 | 3 | export const getAllSongs = async (req, res, next) => { 4 | try { 5 | // -1 = Descending => newest -> oldest 6 | // 1 = Ascending => oldest -> newest 7 | const songs = await Song.find().sort({ createdAt: -1 }); 8 | res.json(songs); 9 | } catch (error) { 10 | next(error); 11 | } 12 | }; 13 | 14 | export const getFeaturedSongs = async (req, res, next) => { 15 | try { 16 | // fetch 6 random songs using mongodb's aggregation pipeline 17 | const songs = await Song.aggregate([ 18 | { 19 | $sample: { size: 6 }, 20 | }, 21 | { 22 | $project: { 23 | _id: 1, 24 | title: 1, 25 | artist: 1, 26 | imageUrl: 1, 27 | audioUrl: 1, 28 | }, 29 | }, 30 | ]); 31 | 32 | res.json(songs); 33 | } catch (error) { 34 | next(error); 35 | } 36 | }; 37 | 38 | export const getMadeForYouSongs = async (req, res, next) => { 39 | try { 40 | const songs = await Song.aggregate([ 41 | { 42 | $sample: { size: 4 }, 43 | }, 44 | { 45 | $project: { 46 | _id: 1, 47 | title: 1, 48 | artist: 1, 49 | imageUrl: 1, 50 | audioUrl: 1, 51 | }, 52 | }, 53 | ]); 54 | 55 | res.json(songs); 56 | } catch (error) { 57 | next(error); 58 | } 59 | }; 60 | 61 | export const getTrendingSongs = async (req, res, next) => { 62 | try { 63 | const songs = await Song.aggregate([ 64 | { 65 | $sample: { size: 4 }, 66 | }, 67 | { 68 | $project: { 69 | _id: 1, 70 | title: 1, 71 | artist: 1, 72 | imageUrl: 1, 73 | audioUrl: 1, 74 | }, 75 | }, 76 | ]); 77 | 78 | res.json(songs); 79 | } catch (error) { 80 | next(error); 81 | } 82 | }; 83 | -------------------------------------------------------------------------------- /backend/src/controller/stat.controller.js: -------------------------------------------------------------------------------- 1 | import { Album } from "../models/album.model.js"; 2 | import { Song } from "../models/song.model.js"; 3 | import { User } from "../models/user.model.js"; 4 | 5 | export const getStats = async (req, res, next) => { 6 | try { 7 | const [totalSongs, totalAlbums, totalUsers, uniqueArtists] = await Promise.all([ 8 | Song.countDocuments(), 9 | Album.countDocuments(), 10 | User.countDocuments(), 11 | 12 | Song.aggregate([ 13 | { 14 | $unionWith: { 15 | coll: "albums", 16 | pipeline: [], 17 | }, 18 | }, 19 | { 20 | $group: { 21 | _id: "$artist", 22 | }, 23 | }, 24 | { 25 | $count: "count", 26 | }, 27 | ]), 28 | ]); 29 | 30 | res.status(200).json({ 31 | totalAlbums, 32 | totalSongs, 33 | totalUsers, 34 | totalArtists: uniqueArtists[0]?.count || 0, 35 | }); 36 | } catch (error) { 37 | next(error); 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /backend/src/controller/user.controller.js: -------------------------------------------------------------------------------- 1 | import { User } from "../models/user.model.js"; 2 | import { Message } from "../models/message.model.js"; 3 | 4 | export const getAllUsers = async (req, res, next) => { 5 | try { 6 | const currentUserId = req.auth.userId; 7 | const users = await User.find({ clerkId: { $ne: currentUserId } }); 8 | res.status(200).json(users); 9 | } catch (error) { 10 | next(error); 11 | } 12 | }; 13 | 14 | export const getMessages = async (req, res, next) => { 15 | try { 16 | const myId = req.auth.userId; 17 | const { userId } = req.params; 18 | 19 | const messages = await Message.find({ 20 | $or: [ 21 | { senderId: userId, receiverId: myId }, 22 | { senderId: myId, receiverId: userId }, 23 | ], 24 | }).sort({ createdAt: 1 }); 25 | 26 | res.status(200).json(messages); 27 | } catch (error) { 28 | next(error); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /backend/src/index.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import dotenv from "dotenv"; 3 | import { clerkMiddleware } from "@clerk/express"; 4 | import fileUpload from "express-fileupload"; 5 | import path from "path"; 6 | import cors from "cors"; 7 | import fs from "fs"; 8 | import { createServer } from "http"; 9 | import cron from "node-cron"; 10 | 11 | import { initializeSocket } from "./lib/socket.js"; 12 | 13 | import { connectDB } from "./lib/db.js"; 14 | import userRoutes from "./routes/user.route.js"; 15 | import adminRoutes from "./routes/admin.route.js"; 16 | import authRoutes from "./routes/auth.route.js"; 17 | import songRoutes from "./routes/song.route.js"; 18 | import albumRoutes from "./routes/album.route.js"; 19 | import statRoutes from "./routes/stat.route.js"; 20 | 21 | dotenv.config(); 22 | 23 | const __dirname = path.resolve(); 24 | const app = express(); 25 | const PORT = process.env.PORT; 26 | 27 | const httpServer = createServer(app); 28 | initializeSocket(httpServer); 29 | 30 | app.use( 31 | cors({ 32 | origin: "http://localhost:3000", 33 | credentials: true, 34 | }) 35 | ); 36 | 37 | app.use(express.json()); // to parse req.body 38 | app.use(clerkMiddleware()); // this will add auth to req obj => req.auth 39 | app.use( 40 | fileUpload({ 41 | useTempFiles: true, 42 | tempFileDir: path.join(__dirname, "tmp"), 43 | createParentPath: true, 44 | limits: { 45 | fileSize: 10 * 1024 * 1024, // 10MB max file size 46 | }, 47 | }) 48 | ); 49 | 50 | // cron jobs 51 | const tempDir = path.join(process.cwd(), "tmp"); 52 | cron.schedule("0 * * * *", () => { 53 | if (fs.existsSync(tempDir)) { 54 | fs.readdir(tempDir, (err, files) => { 55 | if (err) { 56 | console.log("error", err); 57 | return; 58 | } 59 | for (const file of files) { 60 | fs.unlink(path.join(tempDir, file), (err) => {}); 61 | } 62 | }); 63 | } 64 | }); 65 | 66 | app.use("/api/users", userRoutes); 67 | app.use("/api/admin", adminRoutes); 68 | app.use("/api/auth", authRoutes); 69 | app.use("/api/songs", songRoutes); 70 | app.use("/api/albums", albumRoutes); 71 | app.use("/api/stats", statRoutes); 72 | 73 | if (process.env.NODE_ENV === "production") { 74 | app.use(express.static(path.join(__dirname, "../frontend/dist"))); 75 | app.get("*", (req, res) => { 76 | res.sendFile(path.resolve(__dirname, "../frontend", "dist", "index.html")); 77 | }); 78 | } 79 | 80 | // error handler 81 | app.use((err, req, res, next) => { 82 | res.status(500).json({ message: process.env.NODE_ENV === "production" ? "Internal server error" : err.message }); 83 | }); 84 | 85 | httpServer.listen(PORT, () => { 86 | console.log("Server is running on port " + PORT); 87 | connectDB(); 88 | }); 89 | -------------------------------------------------------------------------------- /backend/src/lib/cloudinary.js: -------------------------------------------------------------------------------- 1 | import { v2 as cloudinary } from "cloudinary"; 2 | 3 | import dotenv from "dotenv"; 4 | dotenv.config(); 5 | 6 | cloudinary.config({ 7 | cloud_name: process.env.CLOUDINARY_CLOUD_NAME, 8 | api_key: process.env.CLOUDINARY_API_KEY, 9 | api_secret: process.env.CLOUDINARY_API_SECRET, 10 | }); 11 | 12 | export default cloudinary; 13 | -------------------------------------------------------------------------------- /backend/src/lib/db.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | export const connectDB = async () => { 4 | try { 5 | const conn = await mongoose.connect(process.env.MONGODB_URI); 6 | console.log(`Connected to MongoDB ${conn.connection.host}`); 7 | } catch (error) { 8 | console.log("Failed to connect to MongoDB", error); 9 | process.exit(1); // 1 is failure, 0 is success 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /backend/src/lib/socket.js: -------------------------------------------------------------------------------- 1 | import { Server } from "socket.io"; 2 | import { Message } from "../models/message.model.js"; 3 | 4 | export const initializeSocket = (server) => { 5 | const io = new Server(server, { 6 | cors: { 7 | origin: "http://localhost:3000", 8 | credentials: true, 9 | }, 10 | }); 11 | 12 | const userSockets = new Map(); // { userId: socketId} 13 | const userActivities = new Map(); // {userId: activity} 14 | 15 | io.on("connection", (socket) => { 16 | socket.on("user_connected", (userId) => { 17 | userSockets.set(userId, socket.id); 18 | userActivities.set(userId, "Idle"); 19 | 20 | // broadcast to all connected sockets that this user just logged in 21 | io.emit("user_connected", userId); 22 | 23 | socket.emit("users_online", Array.from(userSockets.keys())); 24 | 25 | io.emit("activities", Array.from(userActivities.entries())); 26 | }); 27 | 28 | socket.on("update_activity", ({ userId, activity }) => { 29 | console.log("activity updated", userId, activity); 30 | userActivities.set(userId, activity); 31 | io.emit("activity_updated", { userId, activity }); 32 | }); 33 | 34 | socket.on("send_message", async (data) => { 35 | try { 36 | const { senderId, receiverId, content } = data; 37 | 38 | const message = await Message.create({ 39 | senderId, 40 | receiverId, 41 | content, 42 | }); 43 | 44 | // send to receiver in realtime, if they're online 45 | const receiverSocketId = userSockets.get(receiverId); 46 | if (receiverSocketId) { 47 | io.to(receiverSocketId).emit("receive_message", message); 48 | } 49 | 50 | socket.emit("message_sent", message); 51 | } catch (error) { 52 | console.error("Message error:", error); 53 | socket.emit("message_error", error.message); 54 | } 55 | }); 56 | 57 | socket.on("disconnect", () => { 58 | let disconnectedUserId; 59 | for (const [userId, socketId] of userSockets.entries()) { 60 | // find disconnected user 61 | if (socketId === socket.id) { 62 | disconnectedUserId = userId; 63 | userSockets.delete(userId); 64 | userActivities.delete(userId); 65 | break; 66 | } 67 | } 68 | if (disconnectedUserId) { 69 | io.emit("user_disconnected", disconnectedUserId); 70 | } 71 | }); 72 | }); 73 | }; 74 | -------------------------------------------------------------------------------- /backend/src/middleware/auth.middleware.js: -------------------------------------------------------------------------------- 1 | import { clerkClient } from "@clerk/express"; 2 | 3 | export const protectRoute = async (req, res, next) => { 4 | if (!req.auth.userId) { 5 | return res.status(401).json({ message: "Unauthorized - you must be logged in" }); 6 | } 7 | next(); 8 | }; 9 | 10 | export const requireAdmin = async (req, res, next) => { 11 | try { 12 | const currentUser = await clerkClient.users.getUser(req.auth.userId); 13 | const isAdmin = process.env.ADMIN_EMAIL === currentUser.primaryEmailAddress?.emailAddress; 14 | 15 | if (!isAdmin) { 16 | return res.status(403).json({ message: "Unauthorized - you must be an admin" }); 17 | } 18 | 19 | next(); 20 | } catch (error) { 21 | next(error); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /backend/src/models/album.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | const albumSchema = new mongoose.Schema( 4 | { 5 | title: { type: String, required: true }, 6 | artist: { type: String, required: true }, 7 | imageUrl: { type: String, required: true }, 8 | releaseYear: { type: Number, required: true }, 9 | songs: [{ type: mongoose.Schema.Types.ObjectId, ref: "Song" }], 10 | }, 11 | { timestamps: true } 12 | ); // createdAt, updatedAt 13 | 14 | export const Album = mongoose.model("Album", albumSchema); 15 | -------------------------------------------------------------------------------- /backend/src/models/message.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | const messageSchema = new mongoose.Schema( 4 | { 5 | senderId: { type: String, required: true }, // Clerk user ID 6 | receiverId: { type: String, required: true }, // Clerk user ID 7 | content: { type: String, required: true }, 8 | }, 9 | { timestamps: true } 10 | ); 11 | 12 | export const Message = mongoose.model("Message", messageSchema); 13 | -------------------------------------------------------------------------------- /backend/src/models/song.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | const songSchema = new mongoose.Schema( 4 | { 5 | title: { 6 | type: String, 7 | required: true, 8 | }, 9 | artist: { 10 | type: String, 11 | required: true, 12 | }, 13 | imageUrl: { 14 | type: String, 15 | required: true, 16 | }, 17 | audioUrl: { 18 | type: String, 19 | required: true, 20 | }, 21 | duration: { 22 | type: Number, 23 | required: true, 24 | }, 25 | albumId: { 26 | type: mongoose.Schema.Types.ObjectId, 27 | ref: "Album", 28 | required: false, 29 | }, 30 | }, 31 | { timestamps: true } 32 | ); 33 | 34 | export const Song = mongoose.model("Song", songSchema); 35 | -------------------------------------------------------------------------------- /backend/src/models/user.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | const userSchema = new mongoose.Schema( 4 | { 5 | fullName: { 6 | type: String, 7 | required: true, 8 | }, 9 | imageUrl: { 10 | type: String, 11 | required: true, 12 | }, 13 | clerkId: { 14 | type: String, 15 | required: true, 16 | unique: true, 17 | }, 18 | }, 19 | { timestamps: true } // createdAt, updatedAt 20 | ); 21 | 22 | export const User = mongoose.model("User", userSchema); 23 | -------------------------------------------------------------------------------- /backend/src/routes/admin.route.js: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { checkAdmin, createAlbum, createSong, deleteAlbum, deleteSong } from "../controller/admin.controller.js"; 3 | import { protectRoute, requireAdmin } from "../middleware/auth.middleware.js"; 4 | 5 | const router = Router(); 6 | 7 | router.use(protectRoute, requireAdmin); 8 | 9 | router.get("/check", checkAdmin); 10 | 11 | router.post("/songs", createSong); 12 | router.delete("/songs/:id", deleteSong); 13 | 14 | router.post("/albums", createAlbum); 15 | router.delete("/albums/:id", deleteAlbum); 16 | 17 | export default router; 18 | -------------------------------------------------------------------------------- /backend/src/routes/album.route.js: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { getAlbumById, getAllAlbums } from "../controller/album.controller.js"; 3 | 4 | const router = Router(); 5 | 6 | router.get("/", getAllAlbums); 7 | router.get("/:albumId", getAlbumById); 8 | 9 | export default router; 10 | -------------------------------------------------------------------------------- /backend/src/routes/auth.route.js: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { authCallback } from "../controller/auth.controller.js"; 3 | 4 | const router = Router(); 5 | 6 | router.post("/callback", authCallback); 7 | 8 | export default router; 9 | -------------------------------------------------------------------------------- /backend/src/routes/song.route.js: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { getAllSongs, getFeaturedSongs, getMadeForYouSongs, getTrendingSongs } from "../controller/song.controller.js"; 3 | import { protectRoute, requireAdmin } from "../middleware/auth.middleware.js"; 4 | 5 | const router = Router(); 6 | 7 | router.get("/", protectRoute, requireAdmin, getAllSongs); 8 | router.get("/featured", getFeaturedSongs); 9 | router.get("/made-for-you", getMadeForYouSongs); 10 | router.get("/trending", getTrendingSongs); 11 | 12 | export default router; 13 | -------------------------------------------------------------------------------- /backend/src/routes/stat.route.js: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { protectRoute, requireAdmin } from "../middleware/auth.middleware.js"; 3 | import { getStats } from "../controller/stat.controller.js"; 4 | 5 | const router = Router(); 6 | 7 | router.get("/", protectRoute, requireAdmin, getStats); 8 | 9 | export default router; 10 | -------------------------------------------------------------------------------- /backend/src/routes/user.route.js: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { protectRoute } from "../middleware/auth.middleware.js"; 3 | import { getAllUsers, getMessages } from "../controller/user.controller.js"; 4 | const router = Router(); 5 | 6 | router.get("/", protectRoute, getAllUsers); 7 | router.get("/messages/:userId", protectRoute, getMessages); 8 | 9 | export default router; 10 | -------------------------------------------------------------------------------- /backend/src/seeds/albums.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import { Song } from "../models/song.model.js"; 3 | import { Album } from "../models/album.model.js"; 4 | import { config } from "dotenv"; 5 | 6 | config(); 7 | 8 | const seedDatabase = async () => { 9 | try { 10 | await mongoose.connect(process.env.MONGODB_URI); 11 | 12 | // Clear existing data 13 | await Album.deleteMany({}); 14 | await Song.deleteMany({}); 15 | 16 | // First, create all songs 17 | const createdSongs = await Song.insertMany([ 18 | { 19 | title: "City Rain", 20 | artist: "Urban Echo", 21 | imageUrl: "/cover-images/7.jpg", 22 | audioUrl: "/songs/7.mp3", 23 | plays: Math.floor(Math.random() * 5000), 24 | duration: 39, // 0:39 25 | }, 26 | { 27 | title: "Neon Lights", 28 | artist: "Night Runners", 29 | imageUrl: "/cover-images/5.jpg", 30 | audioUrl: "/songs/5.mp3", 31 | plays: Math.floor(Math.random() * 5000), 32 | duration: 36, // 0:36 33 | }, 34 | { 35 | title: "Urban Jungle", 36 | artist: "City Lights", 37 | imageUrl: "/cover-images/15.jpg", 38 | audioUrl: "/songs/15.mp3", 39 | plays: Math.floor(Math.random() * 5000), 40 | duration: 36, // 0:36 41 | }, 42 | { 43 | title: "Neon Dreams", 44 | artist: "Cyber Pulse", 45 | imageUrl: "/cover-images/13.jpg", 46 | audioUrl: "/songs/13.mp3", 47 | plays: Math.floor(Math.random() * 5000), 48 | duration: 39, // 0:39 49 | }, 50 | { 51 | title: "Summer Daze", 52 | artist: "Coastal Kids", 53 | imageUrl: "/cover-images/4.jpg", 54 | audioUrl: "/songs/4.mp3", 55 | plays: Math.floor(Math.random() * 5000), 56 | duration: 24, // 0:24 57 | }, 58 | { 59 | title: "Ocean Waves", 60 | artist: "Coastal Drift", 61 | imageUrl: "/cover-images/9.jpg", 62 | audioUrl: "/songs/9.mp3", 63 | plays: Math.floor(Math.random() * 5000), 64 | duration: 28, // 0:28 65 | }, 66 | { 67 | title: "Crystal Rain", 68 | artist: "Echo Valley", 69 | imageUrl: "/cover-images/16.jpg", 70 | audioUrl: "/songs/16.mp3", 71 | plays: Math.floor(Math.random() * 5000), 72 | duration: 39, // 0:39 73 | }, 74 | { 75 | title: "Starlight", 76 | artist: "Luna Bay", 77 | imageUrl: "/cover-images/10.jpg", 78 | audioUrl: "/songs/10.mp3", 79 | plays: Math.floor(Math.random() * 5000), 80 | duration: 30, // 0:30 81 | }, 82 | { 83 | title: "Stay With Me", 84 | artist: "Sarah Mitchell", 85 | imageUrl: "/cover-images/1.jpg", 86 | audioUrl: "/songs/1.mp3", 87 | plays: Math.floor(Math.random() * 5000), 88 | duration: 46, // 0:46 89 | }, 90 | { 91 | title: "Midnight Drive", 92 | artist: "The Wanderers", 93 | imageUrl: "/cover-images/2.jpg", 94 | audioUrl: "/songs/2.mp3", 95 | plays: Math.floor(Math.random() * 5000), 96 | duration: 41, // 0:41 97 | }, 98 | { 99 | title: "Moonlight Dance", 100 | artist: "Silver Shadows", 101 | imageUrl: "/cover-images/14.jpg", 102 | audioUrl: "/songs/14.mp3", 103 | plays: Math.floor(Math.random() * 5000), 104 | duration: 27, // 0:27 105 | }, 106 | { 107 | title: "Lost in Tokyo", 108 | artist: "Electric Dreams", 109 | imageUrl: "/cover-images/3.jpg", 110 | audioUrl: "/songs/3.mp3", 111 | plays: Math.floor(Math.random() * 5000), 112 | duration: 24, // 0:24 113 | }, 114 | { 115 | title: "Neon Tokyo", 116 | artist: "Future Pulse", 117 | imageUrl: "/cover-images/17.jpg", 118 | audioUrl: "/songs/17.mp3", 119 | plays: Math.floor(Math.random() * 5000), 120 | duration: 39, // 0:39 121 | }, 122 | { 123 | title: "Purple Sunset", 124 | artist: "Dream Valley", 125 | imageUrl: "/cover-images/12.jpg", 126 | audioUrl: "/songs/12.mp3", 127 | plays: Math.floor(Math.random() * 5000), 128 | duration: 17, // 0:17 129 | }, 130 | ]); 131 | 132 | // Create albums with references to song IDs 133 | const albums = [ 134 | { 135 | title: "Urban Nights", 136 | artist: "Various Artists", 137 | imageUrl: "/albums/1.jpg", 138 | releaseYear: 2024, 139 | songs: createdSongs.slice(0, 4).map((song) => song._id), 140 | }, 141 | { 142 | title: "Coastal Dreaming", 143 | artist: "Various Artists", 144 | imageUrl: "/albums/2.jpg", 145 | releaseYear: 2024, 146 | songs: createdSongs.slice(4, 8).map((song) => song._id), 147 | }, 148 | { 149 | title: "Midnight Sessions", 150 | artist: "Various Artists", 151 | imageUrl: "/albums/3.jpg", 152 | releaseYear: 2024, 153 | songs: createdSongs.slice(8, 11).map((song) => song._id), 154 | }, 155 | { 156 | title: "Eastern Dreams", 157 | artist: "Various Artists", 158 | imageUrl: "/albums/4.jpg", 159 | releaseYear: 2024, 160 | songs: createdSongs.slice(11, 14).map((song) => song._id), 161 | }, 162 | ]; 163 | 164 | // Insert all albums 165 | const createdAlbums = await Album.insertMany(albums); 166 | 167 | // Update songs with their album references 168 | for (let i = 0; i < createdAlbums.length; i++) { 169 | const album = createdAlbums[i]; 170 | const albumSongs = albums[i].songs; 171 | 172 | await Song.updateMany({ _id: { $in: albumSongs } }, { albumId: album._id }); 173 | } 174 | 175 | console.log("Database seeded successfully!"); 176 | } catch (error) { 177 | console.error("Error seeding database:", error); 178 | } finally { 179 | mongoose.connection.close(); 180 | } 181 | }; 182 | 183 | seedDatabase(); 184 | -------------------------------------------------------------------------------- /backend/src/seeds/songs.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import { Song } from "../models/song.model.js"; 3 | import { config } from "dotenv"; 4 | 5 | config(); 6 | 7 | const songs = [ 8 | { 9 | title: "Stay With Me", 10 | artist: "Sarah Mitchell", 11 | imageUrl: "/cover-images/1.jpg", 12 | audioUrl: "/songs/1.mp3", 13 | duration: 46, // 0:46 14 | }, 15 | { 16 | title: "Midnight Drive", 17 | artist: "The Wanderers", 18 | imageUrl: "/cover-images/2.jpg", 19 | audioUrl: "/songs/2.mp3", 20 | duration: 41, // 0:41 21 | }, 22 | { 23 | title: "Lost in Tokyo", 24 | artist: "Electric Dreams", 25 | imageUrl: "/cover-images/3.jpg", 26 | audioUrl: "/songs/3.mp3", 27 | duration: 24, // 0:24 28 | }, 29 | { 30 | title: "Summer Daze", 31 | artist: "Coastal Kids", 32 | imageUrl: "/cover-images/4.jpg", 33 | audioUrl: "/songs/4.mp3", 34 | duration: 24, // 0:24 35 | }, 36 | { 37 | title: "Neon Lights", 38 | artist: "Night Runners", 39 | imageUrl: "/cover-images/5.jpg", 40 | audioUrl: "/songs/5.mp3", 41 | duration: 36, // 0:36 42 | }, 43 | { 44 | title: "Mountain High", 45 | artist: "The Wild Ones", 46 | imageUrl: "/cover-images/6.jpg", 47 | audioUrl: "/songs/6.mp3", 48 | duration: 40, // 0:40 49 | }, 50 | { 51 | title: "City Rain", 52 | artist: "Urban Echo", 53 | imageUrl: "/cover-images/7.jpg", 54 | audioUrl: "/songs/7.mp3", 55 | duration: 39, // 0:39 56 | }, 57 | { 58 | title: "Desert Wind", 59 | artist: "Sahara Sons", 60 | imageUrl: "/cover-images/8.jpg", 61 | audioUrl: "/songs/8.mp3", 62 | duration: 28, // 0:28 63 | }, 64 | { 65 | title: "Ocean Waves", 66 | artist: "Coastal Drift", 67 | imageUrl: "/cover-images/9.jpg", 68 | audioUrl: "/songs/9.mp3", 69 | duration: 28, // 0:28 70 | }, 71 | { 72 | title: "Starlight", 73 | artist: "Luna Bay", 74 | imageUrl: "/cover-images/10.jpg", 75 | audioUrl: "/songs/10.mp3", 76 | duration: 30, // 0:30 77 | }, 78 | { 79 | title: "Winter Dreams", 80 | artist: "Arctic Pulse", 81 | imageUrl: "/cover-images/11.jpg", 82 | audioUrl: "/songs/11.mp3", 83 | duration: 29, // 0:29 84 | }, 85 | { 86 | title: "Purple Sunset", 87 | artist: "Dream Valley", 88 | imageUrl: "/cover-images/12.jpg", 89 | audioUrl: "/songs/12.mp3", 90 | duration: 17, // 0:17 91 | }, 92 | { 93 | title: "Neon Dreams", 94 | artist: "Cyber Pulse", 95 | imageUrl: "/cover-images/13.jpg", 96 | audioUrl: "/songs/13.mp3", 97 | duration: 39, // 0:39 98 | }, 99 | { 100 | title: "Moonlight Dance", 101 | artist: "Silver Shadows", 102 | imageUrl: "/cover-images/14.jpg", 103 | audioUrl: "/songs/14.mp3", 104 | duration: 27, // 0:27 105 | }, 106 | { 107 | title: "Urban Jungle", 108 | artist: "City Lights", 109 | imageUrl: "/cover-images/15.jpg", 110 | audioUrl: "/songs/15.mp3", 111 | duration: 36, // 0:36 112 | }, 113 | { 114 | title: "Crystal Rain", 115 | artist: "Echo Valley", 116 | imageUrl: "/cover-images/16.jpg", 117 | audioUrl: "/songs/16.mp3", 118 | duration: 39, // 0:39 119 | }, 120 | { 121 | title: "Neon Tokyo", 122 | artist: "Future Pulse", 123 | imageUrl: "/cover-images/17.jpg", 124 | audioUrl: "/songs/17.mp3", 125 | duration: 39, // 0:39 126 | }, 127 | { 128 | title: "Midnight Blues", 129 | artist: "Jazz Cats", 130 | imageUrl: "/cover-images/18.jpg", 131 | audioUrl: "/songs/18.mp3", 132 | duration: 29, // 0:29 133 | }, 134 | ]; 135 | 136 | const seedSongs = async () => { 137 | try { 138 | await mongoose.connect(process.env.MONGODB_URI); 139 | 140 | // Clear existing songs 141 | await Song.deleteMany({}); 142 | 143 | // Insert new songs 144 | await Song.insertMany(songs); 145 | 146 | console.log("Songs seeded successfully!"); 147 | } catch (error) { 148 | console.error("Error seeding songs:", error); 149 | } finally { 150 | mongoose.connection.close(); 151 | } 152 | }; 153 | 154 | seedSongs(); 155 | -------------------------------------------------------------------------------- /backend/tmp/tmp-1-1730755008772: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/backend/tmp/tmp-1-1730755008772 -------------------------------------------------------------------------------- /backend/tmp/tmp-1-1730755339016: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/backend/tmp/tmp-1-1730755339016 -------------------------------------------------------------------------------- /backend/tmp/tmp-1-1730830998740: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/backend/tmp/tmp-1-1730830998740 -------------------------------------------------------------------------------- /backend/tmp/tmp-2-1730755008784: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/backend/tmp/tmp-2-1730755008784 -------------------------------------------------------------------------------- /backend/tmp/tmp-2-1730755339025: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/backend/tmp/tmp-2-1730755339025 -------------------------------------------------------------------------------- /backend/tmp/tmp-2-1730831033299: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/backend/tmp/tmp-2-1730831033299 -------------------------------------------------------------------------------- /backend/tmp/tmp-3-1730755049451: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/backend/tmp/tmp-3-1730755049451 -------------------------------------------------------------------------------- /backend/tmp/tmp-3-1730831122457: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/backend/tmp/tmp-3-1730831122457 -------------------------------------------------------------------------------- /backend/tmp/tmp-4-1730755049459: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/backend/tmp/tmp-4-1730755049459 -------------------------------------------------------------------------------- /backend/tmp/tmp-4-1730831122471: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/backend/tmp/tmp-4-1730831122471 -------------------------------------------------------------------------------- /backend/tmp/tmp-5-1730831173013: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/backend/tmp/tmp-5-1730831173013 -------------------------------------------------------------------------------- /backend/tmp/tmp-6-1730831173018: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/backend/tmp/tmp-6-1730831173018 -------------------------------------------------------------------------------- /frontend/.env.sample: -------------------------------------------------------------------------------- 1 | VITE_CLERK_PUBLISHABLE_KEY= -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default tseslint.config({ 18 | languageOptions: { 19 | // other options... 20 | parserOptions: { 21 | project: ['./tsconfig.node.json', './tsconfig.app.json'], 22 | tsconfigRootDir: import.meta.dirname, 23 | }, 24 | }, 25 | }) 26 | ``` 27 | 28 | - Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` 29 | - Optionally add `...tseslint.configs.stylisticTypeChecked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: 31 | 32 | ```js 33 | // eslint.config.js 34 | import react from 'eslint-plugin-react' 35 | 36 | export default tseslint.config({ 37 | // Set the react version 38 | settings: { react: { version: '18.3' } }, 39 | plugins: { 40 | // Add the react plugin 41 | react, 42 | }, 43 | rules: { 44 | // other rules... 45 | // Enable its recommended rules 46 | ...react.configs.recommended.rules, 47 | ...react.configs['jsx-runtime'].rules, 48 | }, 49 | }) 50 | ``` 51 | -------------------------------------------------------------------------------- /frontend/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/index.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } -------------------------------------------------------------------------------- /frontend/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import globals from "globals"; 3 | import reactHooks from "eslint-plugin-react-hooks"; 4 | import reactRefresh from "eslint-plugin-react-refresh"; 5 | import tseslint from "typescript-eslint"; 6 | 7 | export default tseslint.config( 8 | { ignores: ["dist"] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ["**/*.{ts,tsx}"], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | "react-hooks": reactHooks, 18 | "react-refresh": reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], 23 | "@typescript-eslint/no-explicit-any": "off", 24 | }, 25 | } 26 | ); 27 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Spotify but Better 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@clerk/clerk-react": "^5.14.0", 14 | "@radix-ui/react-avatar": "^1.1.1", 15 | "@radix-ui/react-dialog": "^1.1.2", 16 | "@radix-ui/react-icons": "^1.3.1", 17 | "@radix-ui/react-scroll-area": "^1.2.0", 18 | "@radix-ui/react-select": "^2.1.2", 19 | "@radix-ui/react-slider": "^1.2.1", 20 | "@radix-ui/react-slot": "^1.1.0", 21 | "@radix-ui/react-tabs": "^1.1.1", 22 | "axios": "^1.7.7", 23 | "class-variance-authority": "^0.7.0", 24 | "clsx": "^2.1.1", 25 | "lucide-react": "^0.454.0", 26 | "react": "^18.3.1", 27 | "react-dom": "^18.3.1", 28 | "react-hot-toast": "^2.4.1", 29 | "react-resizable-panels": "^2.1.6", 30 | "react-router-dom": "^6.27.0", 31 | "socket.io-client": "^4.8.1", 32 | "tailwind-merge": "^2.5.4", 33 | "tailwindcss-animate": "^1.0.7", 34 | "zustand": "^5.0.1" 35 | }, 36 | "devDependencies": { 37 | "@eslint/js": "^9.13.0", 38 | "@types/node": "^22.8.6", 39 | "@types/react": "^18.3.12", 40 | "@types/react-dom": "^18.3.1", 41 | "@vitejs/plugin-react": "^4.3.3", 42 | "autoprefixer": "^10.4.20", 43 | "eslint": "^9.13.0", 44 | "eslint-plugin-react-hooks": "^5.0.0", 45 | "eslint-plugin-react-refresh": "^0.4.14", 46 | "globals": "^15.11.0", 47 | "postcss": "^8.4.47", 48 | "tailwindcss": "^3.4.14", 49 | "typescript": "~5.6.2", 50 | "typescript-eslint": "^8.11.0", 51 | "vite": "^5.4.10" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /frontend/public/albums/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/albums/1.jpg -------------------------------------------------------------------------------- /frontend/public/albums/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/albums/2.jpg -------------------------------------------------------------------------------- /frontend/public/albums/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/albums/3.jpg -------------------------------------------------------------------------------- /frontend/public/albums/4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/albums/4.jpg -------------------------------------------------------------------------------- /frontend/public/cover-images/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/cover-images/1.jpg -------------------------------------------------------------------------------- /frontend/public/cover-images/10.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/cover-images/10.jpg -------------------------------------------------------------------------------- /frontend/public/cover-images/11.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/cover-images/11.jpg -------------------------------------------------------------------------------- /frontend/public/cover-images/12.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/cover-images/12.jpg -------------------------------------------------------------------------------- /frontend/public/cover-images/13.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/cover-images/13.jpg -------------------------------------------------------------------------------- /frontend/public/cover-images/14.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/cover-images/14.jpg -------------------------------------------------------------------------------- /frontend/public/cover-images/15.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/cover-images/15.jpg -------------------------------------------------------------------------------- /frontend/public/cover-images/16.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/cover-images/16.jpg -------------------------------------------------------------------------------- /frontend/public/cover-images/17.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/cover-images/17.jpg -------------------------------------------------------------------------------- /frontend/public/cover-images/18.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/cover-images/18.jpg -------------------------------------------------------------------------------- /frontend/public/cover-images/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/cover-images/2.jpg -------------------------------------------------------------------------------- /frontend/public/cover-images/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/cover-images/3.jpg -------------------------------------------------------------------------------- /frontend/public/cover-images/4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/cover-images/4.jpg -------------------------------------------------------------------------------- /frontend/public/cover-images/5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/cover-images/5.jpg -------------------------------------------------------------------------------- /frontend/public/cover-images/6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/cover-images/6.jpg -------------------------------------------------------------------------------- /frontend/public/cover-images/7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/cover-images/7.jpg -------------------------------------------------------------------------------- /frontend/public/cover-images/8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/cover-images/8.jpg -------------------------------------------------------------------------------- /frontend/public/cover-images/9.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/cover-images/9.jpg -------------------------------------------------------------------------------- /frontend/public/google.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/google.png -------------------------------------------------------------------------------- /frontend/public/screenshot-for-readme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/screenshot-for-readme.png -------------------------------------------------------------------------------- /frontend/public/songs/1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/songs/1.mp3 -------------------------------------------------------------------------------- /frontend/public/songs/10.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/songs/10.mp3 -------------------------------------------------------------------------------- /frontend/public/songs/11.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/songs/11.mp3 -------------------------------------------------------------------------------- /frontend/public/songs/12.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/songs/12.mp3 -------------------------------------------------------------------------------- /frontend/public/songs/13.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/songs/13.mp3 -------------------------------------------------------------------------------- /frontend/public/songs/14.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/songs/14.mp3 -------------------------------------------------------------------------------- /frontend/public/songs/15.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/songs/15.mp3 -------------------------------------------------------------------------------- /frontend/public/songs/16.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/songs/16.mp3 -------------------------------------------------------------------------------- /frontend/public/songs/17.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/songs/17.mp3 -------------------------------------------------------------------------------- /frontend/public/songs/18.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/songs/18.mp3 -------------------------------------------------------------------------------- /frontend/public/songs/2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/songs/2.mp3 -------------------------------------------------------------------------------- /frontend/public/songs/3.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/songs/3.mp3 -------------------------------------------------------------------------------- /frontend/public/songs/4.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/songs/4.mp3 -------------------------------------------------------------------------------- /frontend/public/songs/5.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/songs/5.mp3 -------------------------------------------------------------------------------- /frontend/public/songs/6.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/songs/6.mp3 -------------------------------------------------------------------------------- /frontend/public/songs/7.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/songs/7.mp3 -------------------------------------------------------------------------------- /frontend/public/songs/8.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/songs/8.mp3 -------------------------------------------------------------------------------- /frontend/public/songs/9.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/songs/9.mp3 -------------------------------------------------------------------------------- /frontend/public/spotify.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/realtime-spotify-clone/1e60bd11b75740cf15053bc8cc53c63641376114/frontend/public/spotify.png -------------------------------------------------------------------------------- /frontend/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Route, Routes } from "react-router-dom"; 2 | import HomePage from "./pages/home/HomePage"; 3 | import AuthCallbackPage from "./pages/auth-callback/AuthCallbackPage"; 4 | import { AuthenticateWithRedirectCallback } from "@clerk/clerk-react"; 5 | import MainLayout from "./layout/MainLayout"; 6 | import ChatPage from "./pages/chat/ChatPage"; 7 | import AlbumPage from "./pages/album/AlbumPage"; 8 | import AdminPage from "./pages/admin/AdminPage"; 9 | 10 | import { Toaster } from "react-hot-toast"; 11 | import NotFoundPage from "./pages/404/NotFoundPage"; 12 | 13 | function App() { 14 | return ( 15 | <> 16 | 17 | } 20 | /> 21 | } /> 22 | } /> 23 | 24 | }> 25 | } /> 26 | } /> 27 | } /> 28 | } /> 29 | 30 | 31 | 32 | 33 | ); 34 | } 35 | 36 | export default App; 37 | -------------------------------------------------------------------------------- /frontend/src/components/SignInOAuthButtons.tsx: -------------------------------------------------------------------------------- 1 | import { useSignIn } from "@clerk/clerk-react"; 2 | import { Button } from "./ui/button"; 3 | 4 | const SignInOAuthButtons = () => { 5 | const { signIn, isLoaded } = useSignIn(); 6 | 7 | if (!isLoaded) { 8 | return null; 9 | } 10 | 11 | const signInWithGoogle = () => { 12 | signIn.authenticateWithRedirect({ 13 | strategy: "oauth_google", 14 | redirectUrl: "/sso-callback", 15 | redirectUrlComplete: "/auth-callback", 16 | }); 17 | }; 18 | 19 | return ( 20 | 24 | ); 25 | }; 26 | export default SignInOAuthButtons; 27 | -------------------------------------------------------------------------------- /frontend/src/components/Topbar.tsx: -------------------------------------------------------------------------------- 1 | import { SignedOut, UserButton } from "@clerk/clerk-react"; 2 | import { LayoutDashboardIcon } from "lucide-react"; 3 | import { Link } from "react-router-dom"; 4 | import SignInOAuthButtons from "./SignInOAuthButtons"; 5 | import { useAuthStore } from "@/stores/useAuthStore"; 6 | import { cn } from "@/lib/utils"; 7 | import { buttonVariants } from "./ui/button"; 8 | 9 | const Topbar = () => { 10 | const { isAdmin } = useAuthStore(); 11 | console.log({ isAdmin }); 12 | 13 | return ( 14 |
19 |
20 | Spotify logo 21 | Spotify 22 |
23 |
24 | {isAdmin && ( 25 | 26 | 27 | Admin Dashboard 28 | 29 | )} 30 | 31 | 32 | 33 | 34 | 35 | 36 |
37 |
38 | ); 39 | }; 40 | export default Topbar; 41 | -------------------------------------------------------------------------------- /frontend/src/components/skeletons/FeaturedGridSkeleton.tsx: -------------------------------------------------------------------------------- 1 | const FeaturedGridSkeleton = () => { 2 | return ( 3 |
4 | {Array.from({ length: 6 }).map((_, i) => ( 5 |
6 |
7 |
8 |
9 |
10 |
11 | ))} 12 |
13 | ); 14 | }; 15 | export default FeaturedGridSkeleton; 16 | -------------------------------------------------------------------------------- /frontend/src/components/skeletons/PlaylistSkeleton.tsx: -------------------------------------------------------------------------------- 1 | const PlaylistSkeleton = () => { 2 | return Array.from({ length: 7 }).map((_, i) => ( 3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | )); 11 | }; 12 | export default PlaylistSkeleton; 13 | -------------------------------------------------------------------------------- /frontend/src/components/skeletons/UsersListSkeleton.tsx: -------------------------------------------------------------------------------- 1 | const UsersListSkeleton = () => { 2 | return Array.from({ length: 4 }).map((_, i) => ( 3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | )); 11 | }; 12 | export default UsersListSkeleton; 13 | -------------------------------------------------------------------------------- /frontend/src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Avatar = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | )) 19 | Avatar.displayName = AvatarPrimitive.Root.displayName 20 | 21 | const AvatarImage = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef 24 | >(({ className, ...props }, ref) => ( 25 | 30 | )) 31 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 32 | 33 | const AvatarFallback = React.forwardRef< 34 | React.ElementRef, 35 | React.ComponentPropsWithoutRef 36 | >(({ className, ...props }, ref) => ( 37 | 45 | )) 46 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 47 | 48 | export { Avatar, AvatarImage, AvatarFallback } 49 | -------------------------------------------------------------------------------- /frontend/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /frontend/src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

41 | )) 42 | CardTitle.displayName = "CardTitle" 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLParagraphElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |

53 | )) 54 | CardDescription.displayName = "CardDescription" 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |

61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | -------------------------------------------------------------------------------- /frontend/src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as DialogPrimitive from "@radix-ui/react-dialog" 3 | import { Cross2Icon } from "@radix-ui/react-icons" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Dialog = DialogPrimitive.Root 8 | 9 | const DialogTrigger = DialogPrimitive.Trigger 10 | 11 | const DialogPortal = DialogPrimitive.Portal 12 | 13 | const DialogClose = DialogPrimitive.Close 14 | 15 | const DialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )) 28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 29 | 30 | const DialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, children, ...props }, ref) => ( 34 | 35 | 36 | 44 | {children} 45 | 46 | 47 | Close 48 | 49 | 50 | 51 | )) 52 | DialogContent.displayName = DialogPrimitive.Content.displayName 53 | 54 | const DialogHeader = ({ 55 | className, 56 | ...props 57 | }: React.HTMLAttributes) => ( 58 |
65 | ) 66 | DialogHeader.displayName = "DialogHeader" 67 | 68 | const DialogFooter = ({ 69 | className, 70 | ...props 71 | }: React.HTMLAttributes) => ( 72 |
79 | ) 80 | DialogFooter.displayName = "DialogFooter" 81 | 82 | const DialogTitle = React.forwardRef< 83 | React.ElementRef, 84 | React.ComponentPropsWithoutRef 85 | >(({ className, ...props }, ref) => ( 86 | 94 | )) 95 | DialogTitle.displayName = DialogPrimitive.Title.displayName 96 | 97 | const DialogDescription = React.forwardRef< 98 | React.ElementRef, 99 | React.ComponentPropsWithoutRef 100 | >(({ className, ...props }, ref) => ( 101 | 106 | )) 107 | DialogDescription.displayName = DialogPrimitive.Description.displayName 108 | 109 | export { 110 | Dialog, 111 | DialogPortal, 112 | DialogOverlay, 113 | DialogTrigger, 114 | DialogClose, 115 | DialogContent, 116 | DialogHeader, 117 | DialogFooter, 118 | DialogTitle, 119 | DialogDescription, 120 | } 121 | -------------------------------------------------------------------------------- /frontend/src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ) 21 | } 22 | ) 23 | Input.displayName = "Input" 24 | 25 | export { Input } 26 | -------------------------------------------------------------------------------- /frontend/src/components/ui/resizable.tsx: -------------------------------------------------------------------------------- 1 | import { DragHandleDots2Icon } from "@radix-ui/react-icons" 2 | import * as ResizablePrimitive from "react-resizable-panels" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const ResizablePanelGroup = ({ 7 | className, 8 | ...props 9 | }: React.ComponentProps) => ( 10 | 17 | ) 18 | 19 | const ResizablePanel = ResizablePrimitive.Panel 20 | 21 | const ResizableHandle = ({ 22 | withHandle, 23 | className, 24 | ...props 25 | }: React.ComponentProps & { 26 | withHandle?: boolean 27 | }) => ( 28 | div]:rotate-90", 31 | className 32 | )} 33 | {...props} 34 | > 35 | {withHandle && ( 36 |
37 | 38 |
39 | )} 40 |
41 | ) 42 | 43 | export { ResizablePanelGroup, ResizablePanel, ResizableHandle } 44 | -------------------------------------------------------------------------------- /frontend/src/components/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const ScrollArea = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, children, ...props }, ref) => ( 10 | 15 | 16 | {children} 17 | 18 | 19 | 20 | 21 | )) 22 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName 23 | 24 | const ScrollBar = React.forwardRef< 25 | React.ElementRef, 26 | React.ComponentPropsWithoutRef 27 | >(({ className, orientation = "vertical", ...props }, ref) => ( 28 | 41 | 42 | 43 | )) 44 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName 45 | 46 | export { ScrollArea, ScrollBar } 47 | -------------------------------------------------------------------------------- /frontend/src/components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { 3 | CaretSortIcon, 4 | CheckIcon, 5 | ChevronDownIcon, 6 | ChevronUpIcon, 7 | } from "@radix-ui/react-icons" 8 | import * as SelectPrimitive from "@radix-ui/react-select" 9 | 10 | import { cn } from "@/lib/utils" 11 | 12 | const Select = SelectPrimitive.Root 13 | 14 | const SelectGroup = SelectPrimitive.Group 15 | 16 | const SelectValue = SelectPrimitive.Value 17 | 18 | const SelectTrigger = React.forwardRef< 19 | React.ElementRef, 20 | React.ComponentPropsWithoutRef 21 | >(({ className, children, ...props }, ref) => ( 22 | span]:line-clamp-1", 26 | className 27 | )} 28 | {...props} 29 | > 30 | {children} 31 | 32 | 33 | 34 | 35 | )) 36 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName 37 | 38 | const SelectScrollUpButton = React.forwardRef< 39 | React.ElementRef, 40 | React.ComponentPropsWithoutRef 41 | >(({ className, ...props }, ref) => ( 42 | 50 | 51 | 52 | )) 53 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName 54 | 55 | const SelectScrollDownButton = React.forwardRef< 56 | React.ElementRef, 57 | React.ComponentPropsWithoutRef 58 | >(({ className, ...props }, ref) => ( 59 | 67 | 68 | 69 | )) 70 | SelectScrollDownButton.displayName = 71 | SelectPrimitive.ScrollDownButton.displayName 72 | 73 | const SelectContent = React.forwardRef< 74 | React.ElementRef, 75 | React.ComponentPropsWithoutRef 76 | >(({ className, children, position = "popper", ...props }, ref) => ( 77 | 78 | 89 | 90 | 97 | {children} 98 | 99 | 100 | 101 | 102 | )) 103 | SelectContent.displayName = SelectPrimitive.Content.displayName 104 | 105 | const SelectLabel = React.forwardRef< 106 | React.ElementRef, 107 | React.ComponentPropsWithoutRef 108 | >(({ className, ...props }, ref) => ( 109 | 114 | )) 115 | SelectLabel.displayName = SelectPrimitive.Label.displayName 116 | 117 | const SelectItem = React.forwardRef< 118 | React.ElementRef, 119 | React.ComponentPropsWithoutRef 120 | >(({ className, children, ...props }, ref) => ( 121 | 129 | 130 | 131 | 132 | 133 | 134 | {children} 135 | 136 | )) 137 | SelectItem.displayName = SelectPrimitive.Item.displayName 138 | 139 | const SelectSeparator = React.forwardRef< 140 | React.ElementRef, 141 | React.ComponentPropsWithoutRef 142 | >(({ className, ...props }, ref) => ( 143 | 148 | )) 149 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName 150 | 151 | export { 152 | Select, 153 | SelectGroup, 154 | SelectValue, 155 | SelectTrigger, 156 | SelectContent, 157 | SelectLabel, 158 | SelectItem, 159 | SelectSeparator, 160 | SelectScrollUpButton, 161 | SelectScrollDownButton, 162 | } 163 | -------------------------------------------------------------------------------- /frontend/src/components/ui/slider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as SliderPrimitive from "@radix-ui/react-slider" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Slider = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | 19 | 20 | 21 | 22 | 23 | )) 24 | Slider.displayName = SliderPrimitive.Root.displayName 25 | 26 | export { Slider } 27 | -------------------------------------------------------------------------------- /frontend/src/components/ui/table.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Table = React.forwardRef< 6 | HTMLTableElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
10 | 15 | 16 | )) 17 | Table.displayName = "Table" 18 | 19 | const TableHeader = React.forwardRef< 20 | HTMLTableSectionElement, 21 | React.HTMLAttributes 22 | >(({ className, ...props }, ref) => ( 23 | 24 | )) 25 | TableHeader.displayName = "TableHeader" 26 | 27 | const TableBody = React.forwardRef< 28 | HTMLTableSectionElement, 29 | React.HTMLAttributes 30 | >(({ className, ...props }, ref) => ( 31 | 36 | )) 37 | TableBody.displayName = "TableBody" 38 | 39 | const TableFooter = React.forwardRef< 40 | HTMLTableSectionElement, 41 | React.HTMLAttributes 42 | >(({ className, ...props }, ref) => ( 43 | tr]:last:border-b-0", 47 | className 48 | )} 49 | {...props} 50 | /> 51 | )) 52 | TableFooter.displayName = "TableFooter" 53 | 54 | const TableRow = React.forwardRef< 55 | HTMLTableRowElement, 56 | React.HTMLAttributes 57 | >(({ className, ...props }, ref) => ( 58 | 66 | )) 67 | TableRow.displayName = "TableRow" 68 | 69 | const TableHead = React.forwardRef< 70 | HTMLTableCellElement, 71 | React.ThHTMLAttributes 72 | >(({ className, ...props }, ref) => ( 73 |
[role=checkbox]]:translate-y-[2px]", 77 | className 78 | )} 79 | {...props} 80 | /> 81 | )) 82 | TableHead.displayName = "TableHead" 83 | 84 | const TableCell = React.forwardRef< 85 | HTMLTableCellElement, 86 | React.TdHTMLAttributes 87 | >(({ className, ...props }, ref) => ( 88 | [role=checkbox]]:translate-y-[2px]", 92 | className 93 | )} 94 | {...props} 95 | /> 96 | )) 97 | TableCell.displayName = "TableCell" 98 | 99 | const TableCaption = React.forwardRef< 100 | HTMLTableCaptionElement, 101 | React.HTMLAttributes 102 | >(({ className, ...props }, ref) => ( 103 |
108 | )) 109 | TableCaption.displayName = "TableCaption" 110 | 111 | export { 112 | Table, 113 | TableHeader, 114 | TableBody, 115 | TableFooter, 116 | TableHead, 117 | TableRow, 118 | TableCell, 119 | TableCaption, 120 | } 121 | -------------------------------------------------------------------------------- /frontend/src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as TabsPrimitive from "@radix-ui/react-tabs" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Tabs = TabsPrimitive.Root 7 | 8 | const TabsList = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | TabsList.displayName = TabsPrimitive.List.displayName 22 | 23 | const TabsTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 35 | )) 36 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName 37 | 38 | const TabsContent = React.forwardRef< 39 | React.ElementRef, 40 | React.ComponentPropsWithoutRef 41 | >(({ className, ...props }, ref) => ( 42 | 50 | )) 51 | TabsContent.displayName = TabsPrimitive.Content.displayName 52 | 53 | export { Tabs, TabsList, TabsTrigger, TabsContent } 54 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 240 10% 3.9%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 240 10% 3.9%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 240 10% 3.9%; 13 | --primary: 142.1 76.2% 36.3%; 14 | --primary-foreground: 355.7 100% 97.3%; 15 | --secondary: 240 4.8% 95.9%; 16 | --secondary-foreground: 240 5.9% 10%; 17 | --muted: 240 4.8% 95.9%; 18 | --muted-foreground: 240 3.8% 46.1%; 19 | --accent: 240 4.8% 95.9%; 20 | --accent-foreground: 240 5.9% 10%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 0 0% 98%; 23 | --border: 240 5.9% 90%; 24 | --input: 240 5.9% 90%; 25 | --ring: 142.1 76.2% 36.3%; 26 | --radius: 0.5rem; 27 | --chart-1: 12 76% 61%; 28 | --chart-2: 173 58% 39%; 29 | --chart-3: 197 37% 24%; 30 | --chart-4: 43 74% 66%; 31 | --chart-5: 27 87% 67%; 32 | } 33 | 34 | .dark { 35 | --background: 20 14.3% 4.1%; 36 | --foreground: 0 0% 95%; 37 | --card: 24 9.8% 10%; 38 | --card-foreground: 0 0% 95%; 39 | --popover: 0 0% 9%; 40 | --popover-foreground: 0 0% 95%; 41 | --primary: 142.1 70.6% 45.3%; 42 | --primary-foreground: 144.9 80.4% 10%; 43 | --secondary: 240 3.7% 15.9%; 44 | --secondary-foreground: 0 0% 98%; 45 | --muted: 0 0% 15%; 46 | --muted-foreground: 240 5% 64.9%; 47 | --accent: 12 6.5% 15.1%; 48 | --accent-foreground: 0 0% 98%; 49 | --destructive: 0 62.8% 30.6%; 50 | --destructive-foreground: 0 85.7% 97.3%; 51 | --border: 240 3.7% 15.9%; 52 | --input: 240 3.7% 15.9%; 53 | --ring: 142.4 71.8% 29.2%; 54 | --chart-1: 220 70% 50%; 55 | --chart-2: 160 60% 45%; 56 | --chart-3: 30 80% 55%; 57 | --chart-4: 280 65% 60%; 58 | --chart-5: 340 75% 55%; 59 | } 60 | } 61 | 62 | @layer base { 63 | * { 64 | @apply border-border; 65 | } 66 | body { 67 | @apply bg-background text-foreground; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /frontend/src/layout/MainLayout.tsx: -------------------------------------------------------------------------------- 1 | import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; 2 | import { Outlet } from "react-router-dom"; 3 | import LeftSidebar from "./components/LeftSidebar"; 4 | import FriendsActivity from "./components/FriendsActivity"; 5 | import AudioPlayer from "./components/AudioPlayer"; 6 | import { PlaybackControls } from "./components/PlaybackControls"; 7 | import { useEffect, useState } from "react"; 8 | 9 | const MainLayout = () => { 10 | const [isMobile, setIsMobile] = useState(false); 11 | 12 | useEffect(() => { 13 | const checkMobile = () => { 14 | setIsMobile(window.innerWidth < 768); 15 | }; 16 | 17 | checkMobile(); 18 | window.addEventListener("resize", checkMobile); 19 | return () => window.removeEventListener("resize", checkMobile); 20 | }, []); 21 | 22 | return ( 23 |
24 | 25 | 26 | {/* left sidebar */} 27 | 28 | 29 | 30 | 31 | 32 | 33 | {/* Main content */} 34 | 35 | 36 | 37 | 38 | {!isMobile && ( 39 | <> 40 | 41 | 42 | {/* right sidebar */} 43 | 44 | 45 | 46 | 47 | )} 48 | 49 | 50 | 51 |
52 | ); 53 | }; 54 | export default MainLayout; 55 | -------------------------------------------------------------------------------- /frontend/src/layout/components/AudioPlayer.tsx: -------------------------------------------------------------------------------- 1 | import { usePlayerStore } from "@/stores/usePlayerStore"; 2 | import { useEffect, useRef } from "react"; 3 | 4 | const AudioPlayer = () => { 5 | const audioRef = useRef(null); 6 | const prevSongRef = useRef(null); 7 | 8 | const { currentSong, isPlaying, playNext } = usePlayerStore(); 9 | 10 | // handle play/pause logic 11 | useEffect(() => { 12 | if (isPlaying) audioRef.current?.play(); 13 | else audioRef.current?.pause(); 14 | }, [isPlaying]); 15 | 16 | // handle song ends 17 | useEffect(() => { 18 | const audio = audioRef.current; 19 | 20 | const handleEnded = () => { 21 | playNext(); 22 | }; 23 | 24 | audio?.addEventListener("ended", handleEnded); 25 | 26 | return () => audio?.removeEventListener("ended", handleEnded); 27 | }, [playNext]); 28 | 29 | // handle song changes 30 | useEffect(() => { 31 | if (!audioRef.current || !currentSong) return; 32 | 33 | const audio = audioRef.current; 34 | 35 | // check if this is actually a new song 36 | const isSongChange = prevSongRef.current !== currentSong?.audioUrl; 37 | if (isSongChange) { 38 | audio.src = currentSong?.audioUrl; 39 | // reset the playback position 40 | audio.currentTime = 0; 41 | 42 | prevSongRef.current = currentSong?.audioUrl; 43 | 44 | if (isPlaying) audio.play(); 45 | } 46 | }, [currentSong, isPlaying]); 47 | 48 | return