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 ;
49 | };
50 | export default AudioPlayer;
51 |
--------------------------------------------------------------------------------
/frontend/src/layout/components/FriendsActivity.tsx:
--------------------------------------------------------------------------------
1 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
2 | import { ScrollArea } from "@/components/ui/scroll-area";
3 | import { useChatStore } from "@/stores/useChatStore";
4 | import { useUser } from "@clerk/clerk-react";
5 | import { HeadphonesIcon, Music, Users } from "lucide-react";
6 | import { useEffect } from "react";
7 |
8 | const FriendsActivity = () => {
9 | const { users, fetchUsers, onlineUsers, userActivities } = useChatStore();
10 | const { user } = useUser();
11 |
12 | useEffect(() => {
13 | if (user) fetchUsers();
14 | }, [fetchUsers, user]);
15 |
16 | return (
17 |
18 |
19 |
20 |
21 | What they're listening to
22 |
23 |
24 |
25 | {!user && }
26 |
27 |
28 |
29 | {users.map((user) => {
30 | const activity = userActivities.get(user.clerkId);
31 | const isPlaying = activity && activity !== "Idle";
32 |
33 | return (
34 |
38 |
39 |
40 |
41 |
42 | {user.fullName[0]}
43 |
44 |
50 |
51 |
52 |
53 |
54 | {user.fullName}
55 | {isPlaying && }
56 |
57 |
58 | {isPlaying ? (
59 |
60 |
61 | {activity.replace("Playing ", "").split(" by ")[0]}
62 |
63 |
64 | {activity.split(" by ")[1]}
65 |
66 |
67 | ) : (
68 | Idle
69 | )}
70 |
71 |
72 |
73 | );
74 | })}
75 |
76 |
77 |
78 | );
79 | };
80 | export default FriendsActivity;
81 |
82 | const LoginPrompt = () => (
83 |
84 |
94 |
95 |
96 | See What Friends Are Playing
97 | Login to discover what music your friends are enjoying right now
98 |
99 |
100 | );
101 |
--------------------------------------------------------------------------------
/frontend/src/layout/components/LeftSidebar.tsx:
--------------------------------------------------------------------------------
1 | import PlaylistSkeleton from "@/components/skeletons/PlaylistSkeleton";
2 | import { buttonVariants } from "@/components/ui/button";
3 | import { ScrollArea } from "@/components/ui/scroll-area";
4 | import { cn } from "@/lib/utils";
5 | import { useMusicStore } from "@/stores/useMusicStore";
6 | import { SignedIn } from "@clerk/clerk-react";
7 | import { HomeIcon, Library, MessageCircle } from "lucide-react";
8 | import { useEffect } from "react";
9 | import { Link } from "react-router-dom";
10 |
11 | const LeftSidebar = () => {
12 | const { albums, fetchAlbums, isLoading } = useMusicStore();
13 |
14 | useEffect(() => {
15 | fetchAlbums();
16 | }, [fetchAlbums]);
17 |
18 | console.log({ albums });
19 |
20 | return (
21 |
22 | {/* Navigation menu */}
23 |
24 |
25 |
26 |
35 |
36 | Home
37 |
38 |
39 |
40 |
49 |
50 | Messages
51 |
52 |
53 |
54 |
55 |
56 | {/* Library section */}
57 |
58 |
59 |
60 |
61 | Playlists
62 |
63 |
64 |
65 |
66 |
67 | {isLoading ? (
68 |
69 | ) : (
70 | albums.map((album) => (
71 |
76 | 
81 |
82 |
83 | {album.title}
84 | Album • {album.artist}
85 |
86 |
87 | ))
88 | )}
89 |
90 |
91 |
92 |
93 | );
94 | };
95 | export default LeftSidebar;
96 |
--------------------------------------------------------------------------------
/frontend/src/layout/components/PlaybackControls.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { Slider } from "@/components/ui/slider";
3 | import { usePlayerStore } from "@/stores/usePlayerStore";
4 | import { Laptop2, ListMusic, Mic2, Pause, Play, Repeat, Shuffle, SkipBack, SkipForward, Volume1 } from "lucide-react";
5 | import { useEffect, useRef, useState } from "react";
6 |
7 | const formatTime = (seconds: number) => {
8 | const minutes = Math.floor(seconds / 60);
9 | const remainingSeconds = Math.floor(seconds % 60);
10 | return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
11 | };
12 |
13 | export const PlaybackControls = () => {
14 | const { currentSong, isPlaying, togglePlay, playNext, playPrevious } = usePlayerStore();
15 |
16 | const [volume, setVolume] = useState(75);
17 | const [currentTime, setCurrentTime] = useState(0);
18 | const [duration, setDuration] = useState(0);
19 | const audioRef = useRef(null);
20 |
21 | useEffect(() => {
22 | audioRef.current = document.querySelector("audio");
23 |
24 | const audio = audioRef.current;
25 | if (!audio) return;
26 |
27 | const updateTime = () => setCurrentTime(audio.currentTime);
28 | const updateDuration = () => setDuration(audio.duration);
29 |
30 | audio.addEventListener("timeupdate", updateTime);
31 | audio.addEventListener("loadedmetadata", updateDuration);
32 |
33 | const handleEnded = () => {
34 | usePlayerStore.setState({ isPlaying: false });
35 | };
36 |
37 | audio.addEventListener("ended", handleEnded);
38 |
39 | return () => {
40 | audio.removeEventListener("timeupdate", updateTime);
41 | audio.removeEventListener("loadedmetadata", updateDuration);
42 | audio.removeEventListener("ended", handleEnded);
43 | };
44 | }, [currentSong]);
45 |
46 | const handleSeek = (value: number[]) => {
47 | if (audioRef.current) {
48 | audioRef.current.currentTime = value[0];
49 | }
50 | };
51 |
52 | return (
53 |
168 | );
169 | };
170 |
--------------------------------------------------------------------------------
/frontend/src/lib/axios.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 |
3 | export const axiosInstance = axios.create({
4 | baseURL: import.meta.env.MODE === "development" ? "http://localhost:5000/api" : "/api",
5 | });
6 |
--------------------------------------------------------------------------------
/frontend/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/frontend/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from "react";
2 | import { createRoot } from "react-dom/client";
3 | import "./index.css";
4 | import App from "./App.tsx";
5 | import { ClerkProvider } from "@clerk/clerk-react";
6 | import { BrowserRouter } from "react-router-dom";
7 | import AuthProvider from "./providers/AuthProvider.tsx";
8 |
9 | const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;
10 |
11 | if (!PUBLISHABLE_KEY) {
12 | throw new Error("Missing Publishable Key");
13 | }
14 |
15 | createRoot(document.getElementById("root")!).render(
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | );
26 |
--------------------------------------------------------------------------------
/frontend/src/pages/404/NotFoundPage.tsx:
--------------------------------------------------------------------------------
1 | import { Home, Music2 } from "lucide-react";
2 | import { Button } from "@/components/ui/button";
3 | import { useNavigate } from "react-router-dom";
4 |
5 | export default function NotFoundPage() {
6 | const navigate = useNavigate();
7 |
8 | return (
9 |
10 |
11 | {/* Large animated musical note */}
12 |
13 |
14 |
15 |
16 | {/* Error message */}
17 |
18 | 404
19 | Page not found
20 |
21 | Looks like this track got lost in the shuffle. Let's get you back to the music.
22 |
23 |
24 |
25 | {/* Action buttons */}
26 |
27 |
34 |
41 |
42 |
43 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/frontend/src/pages/admin/AdminPage.tsx:
--------------------------------------------------------------------------------
1 | import { useAuthStore } from "@/stores/useAuthStore";
2 | import Header from "./components/Header";
3 | import DashboardStats from "./components/DashboardStats";
4 | import { Album, Music } from "lucide-react";
5 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
6 | import SongsTabContent from "./components/SongsTabContent";
7 | import AlbumsTabContent from "./components/AlbumsTabContent";
8 | import { useEffect } from "react";
9 | import { useMusicStore } from "@/stores/useMusicStore";
10 |
11 | const AdminPage = () => {
12 | const { isAdmin, isLoading } = useAuthStore();
13 |
14 | const { fetchAlbums, fetchSongs, fetchStats } = useMusicStore();
15 |
16 | useEffect(() => {
17 | fetchAlbums();
18 | fetchSongs();
19 | fetchStats();
20 | }, [fetchAlbums, fetchSongs, fetchStats]);
21 |
22 | if (!isAdmin && !isLoading) return Unauthorized ;
23 |
24 | return (
25 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | Songs
38 |
39 |
40 |
41 | Albums
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | );
54 | };
55 | export default AdminPage;
56 |
--------------------------------------------------------------------------------
/frontend/src/pages/admin/components/AddAlbumDialog.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import {
3 | Dialog,
4 | DialogContent,
5 | DialogDescription,
6 | DialogFooter,
7 | DialogHeader,
8 | DialogTitle,
9 | DialogTrigger,
10 | } from "@/components/ui/dialog";
11 | import { Input } from "@/components/ui/input";
12 | import { axiosInstance } from "@/lib/axios";
13 | import { Plus, Upload } from "lucide-react";
14 | import { useRef, useState } from "react";
15 | import toast from "react-hot-toast";
16 |
17 | const AddAlbumDialog = () => {
18 | const [albumDialogOpen, setAlbumDialogOpen] = useState(false);
19 | const [isLoading, setIsLoading] = useState(false);
20 | const fileInputRef = useRef(null);
21 |
22 | const [newAlbum, setNewAlbum] = useState({
23 | title: "",
24 | artist: "",
25 | releaseYear: new Date().getFullYear(),
26 | });
27 |
28 | const [imageFile, setImageFile] = useState(null);
29 |
30 | const handleImageSelect = (e: React.ChangeEvent) => {
31 | const file = e.target.files?.[0];
32 | if (file) {
33 | setImageFile(file);
34 | }
35 | };
36 |
37 | const handleSubmit = async () => {
38 | setIsLoading(true);
39 |
40 | try {
41 | if (!imageFile) {
42 | return toast.error("Please upload an image");
43 | }
44 |
45 | const formData = new FormData();
46 | formData.append("title", newAlbum.title);
47 | formData.append("artist", newAlbum.artist);
48 | formData.append("releaseYear", newAlbum.releaseYear.toString());
49 | formData.append("imageFile", imageFile);
50 |
51 | await axiosInstance.post("/admin/albums", formData, {
52 | headers: {
53 | "Content-Type": "multipart/form-data",
54 | },
55 | });
56 |
57 | setNewAlbum({
58 | title: "",
59 | artist: "",
60 | releaseYear: new Date().getFullYear(),
61 | });
62 | setImageFile(null);
63 | setAlbumDialogOpen(false);
64 | toast.success("Album created successfully");
65 | } catch (error: any) {
66 | toast.error("Failed to create album: " + error.message);
67 | } finally {
68 | setIsLoading(false);
69 | }
70 | };
71 |
72 | return (
73 |
154 | );
155 | };
156 | export default AddAlbumDialog;
157 |
--------------------------------------------------------------------------------
/frontend/src/pages/admin/components/AddSongDialog.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import {
3 | Dialog,
4 | DialogContent,
5 | DialogDescription,
6 | DialogFooter,
7 | DialogHeader,
8 | DialogTitle,
9 | DialogTrigger,
10 | } from "@/components/ui/dialog";
11 | import { Input } from "@/components/ui/input";
12 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
13 | import { axiosInstance } from "@/lib/axios";
14 | import { useMusicStore } from "@/stores/useMusicStore";
15 | import { Plus, Upload } from "lucide-react";
16 | import { useRef, useState } from "react";
17 | import toast from "react-hot-toast";
18 |
19 | interface NewSong {
20 | title: string;
21 | artist: string;
22 | album: string;
23 | duration: string;
24 | }
25 |
26 | const AddSongDialog = () => {
27 | const { albums } = useMusicStore();
28 | const [songDialogOpen, setSongDialogOpen] = useState(false);
29 | const [isLoading, setIsLoading] = useState(false);
30 |
31 | const [newSong, setNewSong] = useState({
32 | title: "",
33 | artist: "",
34 | album: "",
35 | duration: "0",
36 | });
37 |
38 | const [files, setFiles] = useState<{ audio: File | null; image: File | null }>({
39 | audio: null,
40 | image: null,
41 | });
42 |
43 | const audioInputRef = useRef(null);
44 | const imageInputRef = useRef(null);
45 |
46 | const handleSubmit = async () => {
47 | setIsLoading(true);
48 |
49 | try {
50 | if (!files.audio || !files.image) {
51 | return toast.error("Please upload both audio and image files");
52 | }
53 |
54 | const formData = new FormData();
55 |
56 | formData.append("title", newSong.title);
57 | formData.append("artist", newSong.artist);
58 | formData.append("duration", newSong.duration);
59 | if (newSong.album && newSong.album !== "none") {
60 | formData.append("albumId", newSong.album);
61 | }
62 |
63 | formData.append("audioFile", files.audio);
64 | formData.append("imageFile", files.image);
65 |
66 | await axiosInstance.post("/admin/songs", formData, {
67 | headers: {
68 | "Content-Type": "multipart/form-data",
69 | },
70 | });
71 |
72 | setNewSong({
73 | title: "",
74 | artist: "",
75 | album: "",
76 | duration: "0",
77 | });
78 |
79 | setFiles({
80 | audio: null,
81 | image: null,
82 | });
83 | toast.success("Song added successfully");
84 | } catch (error: any) {
85 | toast.error("Failed to add song: " + error.message);
86 | } finally {
87 | setIsLoading(false);
88 | }
89 | };
90 |
91 | return (
92 |
219 | );
220 | };
221 | export default AddSongDialog;
222 |
--------------------------------------------------------------------------------
/frontend/src/pages/admin/components/AlbumsTabContent.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
2 | import { Library } from "lucide-react";
3 | import AlbumsTable from "./AlbumsTable";
4 | import AddAlbumDialog from "./AddAlbumDialog";
5 |
6 | const AlbumsTabContent = () => {
7 | return (
8 |
9 |
10 |
11 |
12 |
13 |
14 | Albums Library
15 |
16 | Manage your album collection
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | );
27 | };
28 | export default AlbumsTabContent;
29 |
--------------------------------------------------------------------------------
/frontend/src/pages/admin/components/AlbumsTable.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
3 | import { useMusicStore } from "@/stores/useMusicStore";
4 | import { Calendar, Music, Trash2 } from "lucide-react";
5 | import { useEffect } from "react";
6 |
7 | const AlbumsTable = () => {
8 | const { albums, deleteAlbum, fetchAlbums } = useMusicStore();
9 |
10 | useEffect(() => {
11 | fetchAlbums();
12 | }, [fetchAlbums]);
13 |
14 | return (
15 |
16 |
17 |
18 |
19 | Title
20 | Artist
21 | Release Year
22 | Songs
23 | Actions
24 |
25 |
26 |
27 | {albums.map((album) => (
28 |
29 |
30 |
31 |
32 | {album.title}
33 | {album.artist}
34 |
35 |
36 |
37 | {album.releaseYear}
38 |
39 |
40 |
41 |
42 |
43 | {album.songs.length} songs
44 |
45 |
46 |
47 |
48 |
56 |
57 |
58 |
59 | ))}
60 |
61 |
62 | );
63 | };
64 | export default AlbumsTable;
65 |
--------------------------------------------------------------------------------
/frontend/src/pages/admin/components/DashboardStats.tsx:
--------------------------------------------------------------------------------
1 | import { useMusicStore } from "@/stores/useMusicStore";
2 | import { Library, ListMusic, PlayCircle, Users2 } from "lucide-react";
3 | import StatsCard from "./StatsCard";
4 |
5 | const DashboardStats = () => {
6 | const { stats } = useMusicStore();
7 |
8 | const statsData = [
9 | {
10 | icon: ListMusic,
11 | label: "Total Songs",
12 | value: stats.totalSongs.toString(),
13 | bgColor: "bg-emerald-500/10",
14 | iconColor: "text-emerald-500",
15 | },
16 | {
17 | icon: Library,
18 | label: "Total Albums",
19 | value: stats.totalAlbums.toString(),
20 | bgColor: "bg-violet-500/10",
21 | iconColor: "text-violet-500",
22 | },
23 | {
24 | icon: Users2,
25 | label: "Total Artists",
26 | value: stats.totalArtists.toString(),
27 | bgColor: "bg-orange-500/10",
28 | iconColor: "text-orange-500",
29 | },
30 | {
31 | icon: PlayCircle,
32 | label: "Total Users",
33 | value: stats.totalUsers.toLocaleString(),
34 | bgColor: "bg-sky-500/10",
35 | iconColor: "text-sky-500",
36 | },
37 | ];
38 |
39 | return (
40 |
41 | {statsData.map((stat) => (
42 |
50 | ))}
51 |
52 | );
53 | };
54 | export default DashboardStats;
55 |
--------------------------------------------------------------------------------
/frontend/src/pages/admin/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import { UserButton } from "@clerk/clerk-react";
2 | import { Link } from "react-router-dom";
3 |
4 | const Header = () => {
5 | return (
6 |
7 |
8 |
9 | 
10 |
11 |
12 | Music Manager
13 | Manage your music catalog
14 |
15 |
16 |
17 |
18 | );
19 | };
20 | export default Header;
21 |
--------------------------------------------------------------------------------
/frontend/src/pages/admin/components/SongsTabContent.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
2 | import { Music } from "lucide-react";
3 | import SongsTable from "./SongsTable";
4 | import AddSongDialog from "./AddSongDialog";
5 |
6 | const SongsTabContent = () => {
7 | return (
8 |
9 |
10 |
11 |
12 |
13 |
14 | Songs Library
15 |
16 | Manage your music tracks
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | );
26 | };
27 | export default SongsTabContent;
28 |
--------------------------------------------------------------------------------
/frontend/src/pages/admin/components/SongsTable.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
3 | import { useMusicStore } from "@/stores/useMusicStore";
4 | import { Calendar, Trash2 } from "lucide-react";
5 |
6 | const SongsTable = () => {
7 | const { songs, isLoading, error, deleteSong } = useMusicStore();
8 |
9 | if (isLoading) {
10 | return (
11 |
12 | Loading songs...
13 |
14 | );
15 | }
16 |
17 | if (error) {
18 | return (
19 |
22 | );
23 | }
24 |
25 | return (
26 |
27 |
28 |
29 |
30 | Title
31 | Artist
32 | Release Date
33 | Actions
34 |
35 |
36 |
37 |
38 | {songs.map((song) => (
39 |
40 |
41 |
42 |
43 | {song.title}
44 | {song.artist}
45 |
46 |
47 |
48 | {song.createdAt.split("T")[0]}
49 |
50 |
51 |
52 |
53 |
54 |
62 |
63 |
64 |
65 | ))}
66 |
67 |
68 | );
69 | };
70 | export default SongsTable;
71 |
--------------------------------------------------------------------------------
/frontend/src/pages/admin/components/StatsCard.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent } from "@/components/ui/card";
2 |
3 | type StatsCardProps = {
4 | icon: React.ElementType;
5 | label: string;
6 | value: string;
7 | bgColor: string;
8 | iconColor: string;
9 | };
10 |
11 | const StatsCard = ({ bgColor, icon: Icon, iconColor, label, value }: StatsCardProps) => {
12 | return (
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | {label}
21 | {value}
22 |
23 |
24 |
25 |
26 | );
27 | };
28 | export default StatsCard;
29 |
--------------------------------------------------------------------------------
/frontend/src/pages/album/AlbumPage.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { ScrollArea } from "@/components/ui/scroll-area";
3 | import { useMusicStore } from "@/stores/useMusicStore";
4 | import { usePlayerStore } from "@/stores/usePlayerStore";
5 | import { Clock, Pause, Play } from "lucide-react";
6 | import { useEffect } from "react";
7 | import { useParams } from "react-router-dom";
8 |
9 | export const formatDuration = (seconds: number) => {
10 | const minutes = Math.floor(seconds / 60);
11 | const remainingSeconds = seconds % 60;
12 | return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
13 | };
14 |
15 | const AlbumPage = () => {
16 | const { albumId } = useParams();
17 | const { fetchAlbumById, currentAlbum, isLoading } = useMusicStore();
18 | const { currentSong, isPlaying, playAlbum, togglePlay } = usePlayerStore();
19 |
20 | useEffect(() => {
21 | if (albumId) fetchAlbumById(albumId);
22 | }, [fetchAlbumById, albumId]);
23 |
24 | if (isLoading) return null;
25 |
26 | const handlePlayAlbum = () => {
27 | if (!currentAlbum) return;
28 |
29 | const isCurrentAlbumPlaying = currentAlbum?.songs.some((song) => song._id === currentSong?._id);
30 | if (isCurrentAlbumPlaying) togglePlay();
31 | else {
32 | // start playing the album from the beginning
33 | playAlbum(currentAlbum?.songs, 0);
34 | }
35 | };
36 |
37 | const handlePlaySong = (index: number) => {
38 | if (!currentAlbum) return;
39 |
40 | playAlbum(currentAlbum?.songs, index);
41 | };
42 |
43 | return (
44 |
45 |
46 | {/* Main Content */}
47 |
48 | {/* bg gradient */}
49 |
54 |
55 | {/* Content */}
56 |
57 |
58 | 
63 |
64 | Album
65 | {currentAlbum?.title}
66 |
67 | {currentAlbum?.artist}
68 | • {currentAlbum?.songs.length} songs
69 | • {currentAlbum?.releaseYear}
70 |
71 |
72 |
73 |
74 | {/* play button */}
75 |
76 |
88 |
89 |
90 | {/* Table Section */}
91 |
92 | {/* table header */}
93 |
97 | #
98 | Title
99 | Released Date
100 |
101 |
102 |
103 |
104 |
105 | {/* songs list */}
106 |
107 |
108 |
109 | {currentAlbum?.songs.map((song, index) => {
110 | const isCurrentSong = currentSong?._id === song._id;
111 | return (
112 | handlePlaySong(index)}
115 | className={`grid grid-cols-[16px_4fr_2fr_1fr] gap-4 px-4 py-2 text-sm
116 | text-zinc-400 hover:bg-white/5 rounded-md group cursor-pointer
117 | `}
118 | >
119 |
120 | {isCurrentSong && isPlaying ? (
121 | ♫
122 | ) : (
123 | {index + 1}
124 | )}
125 | {!isCurrentSong && (
126 |
127 | )}
128 |
129 |
130 |
131 | 
132 |
133 |
134 | {song.title}
135 | {song.artist}
136 |
137 |
138 | {song.createdAt.split("T")[0]}
139 | {formatDuration(song.duration)}
140 |
141 | );
142 | })}
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 | );
151 | };
152 | export default AlbumPage;
153 |
--------------------------------------------------------------------------------
/frontend/src/pages/auth-callback/AuthCallbackPage.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent } from "@/components/ui/card";
2 | import { axiosInstance } from "@/lib/axios";
3 | import { useUser } from "@clerk/clerk-react";
4 | import { Loader } from "lucide-react";
5 | import { useEffect, useRef } from "react";
6 | import { useNavigate } from "react-router-dom";
7 |
8 | const AuthCallbackPage = () => {
9 | const { isLoaded, user } = useUser();
10 | const navigate = useNavigate();
11 | const syncAttempted = useRef(false);
12 |
13 | useEffect(() => {
14 | const syncUser = async () => {
15 | if (!isLoaded || !user || syncAttempted.current) return;
16 |
17 | try {
18 | syncAttempted.current = true;
19 |
20 | await axiosInstance.post("/auth/callback", {
21 | id: user.id,
22 | firstName: user.firstName,
23 | lastName: user.lastName,
24 | imageUrl: user.imageUrl,
25 | });
26 | } catch (error) {
27 | console.log("Error in auth callback", error);
28 | } finally {
29 | navigate("/");
30 | }
31 | };
32 |
33 | syncUser();
34 | }, [isLoaded, user, navigate]);
35 |
36 | return (
37 |
38 |
39 |
40 |
41 | Logging you in
42 | Redirecting...
43 |
44 |
45 |
46 | );
47 | };
48 | export default AuthCallbackPage;
49 |
--------------------------------------------------------------------------------
/frontend/src/pages/chat/ChatPage.tsx:
--------------------------------------------------------------------------------
1 | import Topbar from "@/components/Topbar";
2 | import { useChatStore } from "@/stores/useChatStore";
3 | import { useUser } from "@clerk/clerk-react";
4 | import { useEffect } from "react";
5 | import UsersList from "./components/UsersList";
6 | import ChatHeader from "./components/ChatHeader";
7 | import { ScrollArea } from "@/components/ui/scroll-area";
8 | import { Avatar, AvatarImage } from "@/components/ui/avatar";
9 | import MessageInput from "./components/MessageInput";
10 |
11 | const formatTime = (date: string) => {
12 | return new Date(date).toLocaleTimeString("en-US", {
13 | hour: "2-digit",
14 | minute: "2-digit",
15 | hour12: true,
16 | });
17 | };
18 |
19 | const ChatPage = () => {
20 | const { user } = useUser();
21 | const { messages, selectedUser, fetchUsers, fetchMessages } = useChatStore();
22 |
23 | useEffect(() => {
24 | if (user) fetchUsers();
25 | }, [fetchUsers, user]);
26 |
27 | useEffect(() => {
28 | if (selectedUser) fetchMessages(selectedUser.clerkId);
29 | }, [selectedUser, fetchMessages]);
30 |
31 | console.log({ messages });
32 |
33 | return (
34 |
35 |
36 |
37 |
38 |
39 |
40 | {/* chat message */}
41 |
42 | {selectedUser ? (
43 | <>
44 |
45 |
46 | {/* Messages */}
47 |
48 |
49 | {messages.map((message) => (
50 |
56 |
57 |
64 |
65 |
66 |
71 | {message.content}
72 |
73 | {formatTime(message.createdAt)}
74 |
75 |
76 |
77 | ))}
78 |
79 |
80 |
81 |
82 | >
83 | ) : (
84 |
85 | )}
86 |
87 |
88 |
89 | );
90 | };
91 | export default ChatPage;
92 |
93 | const NoConversationPlaceholder = () => (
94 |
95 | 
96 |
97 | No conversation selected
98 | Choose a friend to start chatting
99 |
100 |
101 | );
102 |
--------------------------------------------------------------------------------
/frontend/src/pages/chat/components/ChatHeader.tsx:
--------------------------------------------------------------------------------
1 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
2 | import { useChatStore } from "@/stores/useChatStore";
3 |
4 | const ChatHeader = () => {
5 | const { selectedUser, onlineUsers } = useChatStore();
6 |
7 | if (!selectedUser) return null;
8 |
9 | return (
10 |
11 |
12 |
13 |
14 | {selectedUser.fullName[0]}
15 |
16 |
17 | {selectedUser.fullName}
18 |
19 | {onlineUsers.has(selectedUser.clerkId) ? "Online" : "Offline"}
20 |
21 |
22 |
23 |
24 | );
25 | };
26 | export default ChatHeader;
27 |
--------------------------------------------------------------------------------
/frontend/src/pages/chat/components/MessageInput.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { Input } from "@/components/ui/input";
3 | import { useChatStore } from "@/stores/useChatStore";
4 | import { useUser } from "@clerk/clerk-react";
5 | import { Send } from "lucide-react";
6 | import { useState } from "react";
7 |
8 | const MessageInput = () => {
9 | const [newMessage, setNewMessage] = useState("");
10 | const { user } = useUser();
11 | const { selectedUser, sendMessage } = useChatStore();
12 |
13 | const handleSend = () => {
14 | if (!selectedUser || !user || !newMessage) return;
15 | sendMessage(selectedUser.clerkId, user.id, newMessage.trim());
16 | setNewMessage("");
17 | };
18 |
19 | return (
20 |
21 |
22 | setNewMessage(e.target.value)}
26 | className='bg-zinc-800 border-none'
27 | onKeyDown={(e) => e.key === "Enter" && handleSend()}
28 | />
29 |
30 |
33 |
34 |
35 | );
36 | };
37 | export default MessageInput;
38 |
--------------------------------------------------------------------------------
/frontend/src/pages/chat/components/UsersList.tsx:
--------------------------------------------------------------------------------
1 | import UsersListSkeleton from "@/components/skeletons/UsersListSkeleton";
2 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
3 | import { ScrollArea } from "@/components/ui/scroll-area";
4 | import { useChatStore } from "@/stores/useChatStore";
5 |
6 | const UsersList = () => {
7 | const { users, selectedUser, isLoading, setSelectedUser, onlineUsers } = useChatStore();
8 |
9 | return (
10 |
11 |
12 |
13 |
14 | {isLoading ? (
15 |
16 | ) : (
17 | users.map((user) => (
18 | setSelectedUser(user)}
21 | className={`flex items-center justify-center lg:justify-start gap-3 p-3
22 | rounded-lg cursor-pointer transition-colors
23 | ${selectedUser?.clerkId === user.clerkId ? "bg-zinc-800" : "hover:bg-zinc-800/50"}`}
24 | >
25 |
26 |
27 |
28 | {user.fullName[0]}
29 |
30 | {/* online indicator */}
31 |
35 |
36 |
37 |
38 | {user.fullName}
39 |
40 |
41 | ))
42 | )}
43 |
44 |
45 |
46 |
47 | );
48 | };
49 |
50 | export default UsersList;
51 |
--------------------------------------------------------------------------------
/frontend/src/pages/home/HomePage.tsx:
--------------------------------------------------------------------------------
1 | import Topbar from "@/components/Topbar";
2 | import { useMusicStore } from "@/stores/useMusicStore";
3 | import { useEffect } from "react";
4 | import FeaturedSection from "./components/FeaturedSection";
5 | import { ScrollArea } from "@/components/ui/scroll-area";
6 | import SectionGrid from "./components/SectionGrid";
7 | import { usePlayerStore } from "@/stores/usePlayerStore";
8 |
9 | const HomePage = () => {
10 | const {
11 | fetchFeaturedSongs,
12 | fetchMadeForYouSongs,
13 | fetchTrendingSongs,
14 | isLoading,
15 | madeForYouSongs,
16 | featuredSongs,
17 | trendingSongs,
18 | } = useMusicStore();
19 |
20 | const { initializeQueue } = usePlayerStore();
21 |
22 | useEffect(() => {
23 | fetchFeaturedSongs();
24 | fetchMadeForYouSongs();
25 | fetchTrendingSongs();
26 | }, [fetchFeaturedSongs, fetchMadeForYouSongs, fetchTrendingSongs]);
27 |
28 | useEffect(() => {
29 | if (madeForYouSongs.length > 0 && featuredSongs.length > 0 && trendingSongs.length > 0) {
30 | const allSongs = [...featuredSongs, ...madeForYouSongs, ...trendingSongs];
31 | initializeQueue(allSongs);
32 | }
33 | }, [initializeQueue, madeForYouSongs, trendingSongs, featuredSongs]);
34 |
35 | return (
36 |
37 |
38 |
39 |
40 | Good afternoon
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 | );
51 | };
52 | export default HomePage;
53 |
--------------------------------------------------------------------------------
/frontend/src/pages/home/components/FeaturedSection.tsx:
--------------------------------------------------------------------------------
1 | import { useMusicStore } from "@/stores/useMusicStore";
2 | import FeaturedGridSkeleton from "@/components/skeletons/FeaturedGridSkeleton";
3 | import PlayButton from "./PlayButton";
4 |
5 | const FeaturedSection = () => {
6 | const { isLoading, featuredSongs, error } = useMusicStore();
7 |
8 | if (isLoading) return ;
9 |
10 | if (error) return {error} ;
11 |
12 | return (
13 |
14 | {featuredSongs.map((song) => (
15 |
20 | 
25 |
26 | {song.title}
27 | {song.artist}
28 |
29 |
30 |
31 | ))}
32 |
33 | );
34 | };
35 | export default FeaturedSection;
36 |
--------------------------------------------------------------------------------
/frontend/src/pages/home/components/PlayButton.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { usePlayerStore } from "@/stores/usePlayerStore";
3 | import { Song } from "@/types";
4 | import { Pause, Play } from "lucide-react";
5 |
6 | const PlayButton = ({ song }: { song: Song }) => {
7 | const { currentSong, isPlaying, setCurrentSong, togglePlay } = usePlayerStore();
8 | const isCurrentSong = currentSong?._id === song._id;
9 |
10 | const handlePlay = () => {
11 | if (isCurrentSong) togglePlay();
12 | else setCurrentSong(song);
13 | };
14 |
15 | return (
16 |
30 | );
31 | };
32 | export default PlayButton;
33 |
--------------------------------------------------------------------------------
/frontend/src/pages/home/components/SectionGrid.tsx:
--------------------------------------------------------------------------------
1 | import { Song } from "@/types";
2 | import SectionGridSkeleton from "./SectionGridSkeleton";
3 | import { Button } from "@/components/ui/button";
4 | import PlayButton from "./PlayButton";
5 |
6 | type SectionGridProps = {
7 | title: string;
8 | songs: Song[];
9 | isLoading: boolean;
10 | };
11 | const SectionGrid = ({ songs, title, isLoading }: SectionGridProps) => {
12 | if (isLoading) return ;
13 |
14 | return (
15 |
16 |
17 | {title}
18 |
21 |
22 |
23 |
24 | {songs.map((song) => (
25 |
29 |
30 |
31 | 
37 |
38 |
39 |
40 | {song.title}
41 | {song.artist}
42 |
43 | ))}
44 |
45 |
46 | );
47 | };
48 | export default SectionGrid;
49 |
--------------------------------------------------------------------------------
/frontend/src/pages/home/components/SectionGridSkeleton.tsx:
--------------------------------------------------------------------------------
1 | const SectionGridSkeleton = () => {
2 | return (
3 |
4 |
5 |
6 | {Array.from({ length: 4 }).map((_, i) => (
7 |
12 | ))}
13 |
14 |
15 | );
16 | };
17 | export default SectionGridSkeleton;
18 |
--------------------------------------------------------------------------------
/frontend/src/providers/AuthProvider.tsx:
--------------------------------------------------------------------------------
1 | import { axiosInstance } from "@/lib/axios";
2 | import { useAuthStore } from "@/stores/useAuthStore";
3 | import { useChatStore } from "@/stores/useChatStore";
4 | import { useAuth } from "@clerk/clerk-react";
5 | import { Loader } from "lucide-react";
6 | import { useEffect, useState } from "react";
7 |
8 | const updateApiToken = (token: string | null) => {
9 | if (token) axiosInstance.defaults.headers.common["Authorization"] = `Bearer ${token}`;
10 | else delete axiosInstance.defaults.headers.common["Authorization"];
11 | };
12 |
13 | const AuthProvider = ({ children }: { children: React.ReactNode }) => {
14 | const { getToken, userId } = useAuth();
15 | const [loading, setLoading] = useState(true);
16 | const { checkAdminStatus } = useAuthStore();
17 | const { initSocket, disconnectSocket } = useChatStore();
18 |
19 | useEffect(() => {
20 | const initAuth = async () => {
21 | try {
22 | const token = await getToken();
23 | updateApiToken(token);
24 | if (token) {
25 | await checkAdminStatus();
26 | // init socket
27 | if (userId) initSocket(userId);
28 | }
29 | } catch (error: any) {
30 | updateApiToken(null);
31 | console.log("Error in auth provider", error);
32 | } finally {
33 | setLoading(false);
34 | }
35 | };
36 |
37 | initAuth();
38 |
39 | // clean up
40 | return () => disconnectSocket();
41 | }, [getToken, userId, checkAdminStatus, initSocket, disconnectSocket]);
42 |
43 | if (loading)
44 | return (
45 |
46 |
47 |
48 | );
49 |
50 | return <>{children}>;
51 | };
52 | export default AuthProvider;
53 |
--------------------------------------------------------------------------------
/frontend/src/stores/useAuthStore.ts:
--------------------------------------------------------------------------------
1 | import { axiosInstance } from "@/lib/axios";
2 | import { create } from "zustand";
3 |
4 | interface AuthStore {
5 | isAdmin: boolean;
6 | isLoading: boolean;
7 | error: string | null;
8 |
9 | checkAdminStatus: () => Promise;
10 | reset: () => void;
11 | }
12 |
13 | export const useAuthStore = create((set) => ({
14 | isAdmin: false,
15 | isLoading: false,
16 | error: null,
17 |
18 | checkAdminStatus: async () => {
19 | set({ isLoading: true, error: null });
20 | try {
21 | const response = await axiosInstance.get("/admin/check");
22 | set({ isAdmin: response.data.admin });
23 | } catch (error: any) {
24 | set({ isAdmin: false, error: error.response.data.message });
25 | } finally {
26 | set({ isLoading: false });
27 | }
28 | },
29 |
30 | reset: () => {
31 | set({ isAdmin: false, isLoading: false, error: null });
32 | },
33 | }));
34 |
--------------------------------------------------------------------------------
/frontend/src/stores/useChatStore.ts:
--------------------------------------------------------------------------------
1 | import { axiosInstance } from "@/lib/axios";
2 | import { Message, User } from "@/types";
3 | import { create } from "zustand";
4 | import { io } from "socket.io-client";
5 |
6 | interface ChatStore {
7 | users: User[];
8 | isLoading: boolean;
9 | error: string | null;
10 | socket: any;
11 | isConnected: boolean;
12 | onlineUsers: Set;
13 | userActivities: Map;
14 | messages: Message[];
15 | selectedUser: User | null;
16 |
17 | fetchUsers: () => Promise;
18 | initSocket: (userId: string) => void;
19 | disconnectSocket: () => void;
20 | sendMessage: (receiverId: string, senderId: string, content: string) => void;
21 | fetchMessages: (userId: string) => Promise;
22 | setSelectedUser: (user: User | null) => void;
23 | }
24 |
25 | const baseURL = import.meta.env.MODE === "development" ? "http://localhost:5000" : "/";
26 |
27 | const socket = io(baseURL, {
28 | autoConnect: false, // only connect if user is authenticated
29 | withCredentials: true,
30 | });
31 |
32 | export const useChatStore = create((set, get) => ({
33 | users: [],
34 | isLoading: false,
35 | error: null,
36 | socket: socket,
37 | isConnected: false,
38 | onlineUsers: new Set(),
39 | userActivities: new Map(),
40 | messages: [],
41 | selectedUser: null,
42 |
43 | setSelectedUser: (user) => set({ selectedUser: user }),
44 |
45 | fetchUsers: async () => {
46 | set({ isLoading: true, error: null });
47 | try {
48 | const response = await axiosInstance.get("/users");
49 | set({ users: response.data });
50 | } catch (error: any) {
51 | set({ error: error.response.data.message });
52 | } finally {
53 | set({ isLoading: false });
54 | }
55 | },
56 |
57 | initSocket: (userId) => {
58 | if (!get().isConnected) {
59 | socket.auth = { userId };
60 | socket.connect();
61 |
62 | socket.emit("user_connected", userId);
63 |
64 | socket.on("users_online", (users: string[]) => {
65 | set({ onlineUsers: new Set(users) });
66 | });
67 |
68 | socket.on("activities", (activities: [string, string][]) => {
69 | set({ userActivities: new Map(activities) });
70 | });
71 |
72 | socket.on("user_connected", (userId: string) => {
73 | set((state) => ({
74 | onlineUsers: new Set([...state.onlineUsers, userId]),
75 | }));
76 | });
77 |
78 | socket.on("user_disconnected", (userId: string) => {
79 | set((state) => {
80 | const newOnlineUsers = new Set(state.onlineUsers);
81 | newOnlineUsers.delete(userId);
82 | return { onlineUsers: newOnlineUsers };
83 | });
84 | });
85 |
86 | socket.on("receive_message", (message: Message) => {
87 | set((state) => ({
88 | messages: [...state.messages, message],
89 | }));
90 | });
91 |
92 | socket.on("message_sent", (message: Message) => {
93 | set((state) => ({
94 | messages: [...state.messages, message],
95 | }));
96 | });
97 |
98 | socket.on("activity_updated", ({ userId, activity }) => {
99 | set((state) => {
100 | const newActivities = new Map(state.userActivities);
101 | newActivities.set(userId, activity);
102 | return { userActivities: newActivities };
103 | });
104 | });
105 |
106 | set({ isConnected: true });
107 | }
108 | },
109 |
110 | disconnectSocket: () => {
111 | if (get().isConnected) {
112 | socket.disconnect();
113 | set({ isConnected: false });
114 | }
115 | },
116 |
117 | sendMessage: async (receiverId, senderId, content) => {
118 | const socket = get().socket;
119 | if (!socket) return;
120 |
121 | socket.emit("send_message", { receiverId, senderId, content });
122 | },
123 |
124 | fetchMessages: async (userId: string) => {
125 | set({ isLoading: true, error: null });
126 | try {
127 | const response = await axiosInstance.get(`/users/messages/${userId}`);
128 | set({ messages: response.data });
129 | } catch (error: any) {
130 | set({ error: error.response.data.message });
131 | } finally {
132 | set({ isLoading: false });
133 | }
134 | },
135 | }));
136 |
--------------------------------------------------------------------------------
/frontend/src/stores/useMusicStore.ts:
--------------------------------------------------------------------------------
1 | import { axiosInstance } from "@/lib/axios";
2 | import { Album, Song, Stats } from "@/types";
3 | import toast from "react-hot-toast";
4 | import { create } from "zustand";
5 |
6 | interface MusicStore {
7 | songs: Song[];
8 | albums: Album[];
9 | isLoading: boolean;
10 | error: string | null;
11 | currentAlbum: Album | null;
12 | featuredSongs: Song[];
13 | madeForYouSongs: Song[];
14 | trendingSongs: Song[];
15 | stats: Stats;
16 |
17 | fetchAlbums: () => Promise;
18 | fetchAlbumById: (id: string) => Promise;
19 | fetchFeaturedSongs: () => Promise;
20 | fetchMadeForYouSongs: () => Promise;
21 | fetchTrendingSongs: () => Promise;
22 | fetchStats: () => Promise;
23 | fetchSongs: () => Promise;
24 | deleteSong: (id: string) => Promise;
25 | deleteAlbum: (id: string) => Promise;
26 | }
27 |
28 | export const useMusicStore = create((set) => ({
29 | albums: [],
30 | songs: [],
31 | isLoading: false,
32 | error: null,
33 | currentAlbum: null,
34 | madeForYouSongs: [],
35 | featuredSongs: [],
36 | trendingSongs: [],
37 | stats: {
38 | totalSongs: 0,
39 | totalAlbums: 0,
40 | totalUsers: 0,
41 | totalArtists: 0,
42 | },
43 |
44 | deleteSong: async (id) => {
45 | set({ isLoading: true, error: null });
46 | try {
47 | await axiosInstance.delete(`/admin/songs/${id}`);
48 |
49 | set((state) => ({
50 | songs: state.songs.filter((song) => song._id !== id),
51 | }));
52 | toast.success("Song deleted successfully");
53 | } catch (error: any) {
54 | console.log("Error in deleteSong", error);
55 | toast.error("Error deleting song");
56 | } finally {
57 | set({ isLoading: false });
58 | }
59 | },
60 |
61 | deleteAlbum: async (id) => {
62 | set({ isLoading: true, error: null });
63 | try {
64 | await axiosInstance.delete(`/admin/albums/${id}`);
65 | set((state) => ({
66 | albums: state.albums.filter((album) => album._id !== id),
67 | songs: state.songs.map((song) =>
68 | song.albumId === state.albums.find((a) => a._id === id)?.title ? { ...song, album: null } : song
69 | ),
70 | }));
71 | toast.success("Album deleted successfully");
72 | } catch (error: any) {
73 | toast.error("Failed to delete album: " + error.message);
74 | } finally {
75 | set({ isLoading: false });
76 | }
77 | },
78 |
79 | fetchSongs: async () => {
80 | set({ isLoading: true, error: null });
81 | try {
82 | const response = await axiosInstance.get("/songs");
83 | set({ songs: response.data });
84 | } catch (error: any) {
85 | set({ error: error.message });
86 | } finally {
87 | set({ isLoading: false });
88 | }
89 | },
90 |
91 | fetchStats: async () => {
92 | set({ isLoading: true, error: null });
93 | try {
94 | const response = await axiosInstance.get("/stats");
95 | set({ stats: response.data });
96 | } catch (error: any) {
97 | set({ error: error.message });
98 | } finally {
99 | set({ isLoading: false });
100 | }
101 | },
102 |
103 | fetchAlbums: async () => {
104 | set({ isLoading: true, error: null });
105 |
106 | try {
107 | const response = await axiosInstance.get("/albums");
108 | set({ albums: response.data });
109 | } catch (error: any) {
110 | set({ error: error.response.data.message });
111 | } finally {
112 | set({ isLoading: false });
113 | }
114 | },
115 |
116 | fetchAlbumById: async (id) => {
117 | set({ isLoading: true, error: null });
118 | try {
119 | const response = await axiosInstance.get(`/albums/${id}`);
120 | set({ currentAlbum: response.data });
121 | } catch (error: any) {
122 | set({ error: error.response.data.message });
123 | } finally {
124 | set({ isLoading: false });
125 | }
126 | },
127 |
128 | fetchFeaturedSongs: async () => {
129 | set({ isLoading: true, error: null });
130 | try {
131 | const response = await axiosInstance.get("/songs/featured");
132 | set({ featuredSongs: response.data });
133 | } catch (error: any) {
134 | set({ error: error.response.data.message });
135 | } finally {
136 | set({ isLoading: false });
137 | }
138 | },
139 |
140 | fetchMadeForYouSongs: async () => {
141 | set({ isLoading: true, error: null });
142 | try {
143 | const response = await axiosInstance.get("/songs/made-for-you");
144 | set({ madeForYouSongs: response.data });
145 | } catch (error: any) {
146 | set({ error: error.response.data.message });
147 | } finally {
148 | set({ isLoading: false });
149 | }
150 | },
151 |
152 | fetchTrendingSongs: async () => {
153 | set({ isLoading: true, error: null });
154 | try {
155 | const response = await axiosInstance.get("/songs/trending");
156 | set({ trendingSongs: response.data });
157 | } catch (error: any) {
158 | set({ error: error.response.data.message });
159 | } finally {
160 | set({ isLoading: false });
161 | }
162 | },
163 | }));
164 |
--------------------------------------------------------------------------------
/frontend/src/stores/usePlayerStore.ts:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 | import { Song } from "@/types";
3 | import { useChatStore } from "./useChatStore";
4 |
5 | interface PlayerStore {
6 | currentSong: Song | null;
7 | isPlaying: boolean;
8 | queue: Song[];
9 | currentIndex: number;
10 |
11 | initializeQueue: (songs: Song[]) => void;
12 | playAlbum: (songs: Song[], startIndex?: number) => void;
13 | setCurrentSong: (song: Song | null) => void;
14 | togglePlay: () => void;
15 | playNext: () => void;
16 | playPrevious: () => void;
17 | }
18 |
19 | export const usePlayerStore = create((set, get) => ({
20 | currentSong: null,
21 | isPlaying: false,
22 | queue: [],
23 | currentIndex: -1,
24 |
25 | initializeQueue: (songs: Song[]) => {
26 | set({
27 | queue: songs,
28 | currentSong: get().currentSong || songs[0],
29 | currentIndex: get().currentIndex === -1 ? 0 : get().currentIndex,
30 | });
31 | },
32 |
33 | playAlbum: (songs: Song[], startIndex = 0) => {
34 | if (songs.length === 0) return;
35 |
36 | const song = songs[startIndex];
37 |
38 | const socket = useChatStore.getState().socket;
39 | if (socket.auth) {
40 | socket.emit("update_activity", {
41 | userId: socket.auth.userId,
42 | activity: `Playing ${song.title} by ${song.artist}`,
43 | });
44 | }
45 | set({
46 | queue: songs,
47 | currentSong: song,
48 | currentIndex: startIndex,
49 | isPlaying: true,
50 | });
51 | },
52 |
53 | setCurrentSong: (song: Song | null) => {
54 | if (!song) return;
55 |
56 | const socket = useChatStore.getState().socket;
57 | if (socket.auth) {
58 | socket.emit("update_activity", {
59 | userId: socket.auth.userId,
60 | activity: `Playing ${song.title} by ${song.artist}`,
61 | });
62 | }
63 |
64 | const songIndex = get().queue.findIndex((s) => s._id === song._id);
65 | set({
66 | currentSong: song,
67 | isPlaying: true,
68 | currentIndex: songIndex !== -1 ? songIndex : get().currentIndex,
69 | });
70 | },
71 |
72 | togglePlay: () => {
73 | const willStartPlaying = !get().isPlaying;
74 |
75 | const currentSong = get().currentSong;
76 | const socket = useChatStore.getState().socket;
77 | if (socket.auth) {
78 | socket.emit("update_activity", {
79 | userId: socket.auth.userId,
80 | activity:
81 | willStartPlaying && currentSong ? `Playing ${currentSong.title} by ${currentSong.artist}` : "Idle",
82 | });
83 | }
84 |
85 | set({
86 | isPlaying: willStartPlaying,
87 | });
88 | },
89 |
90 | playNext: () => {
91 | const { currentIndex, queue } = get();
92 | const nextIndex = currentIndex + 1;
93 |
94 | // if there is a next song to play, let's play it
95 | if (nextIndex < queue.length) {
96 | const nextSong = queue[nextIndex];
97 |
98 | const socket = useChatStore.getState().socket;
99 | if (socket.auth) {
100 | socket.emit("update_activity", {
101 | userId: socket.auth.userId,
102 | activity: `Playing ${nextSong.title} by ${nextSong.artist}`,
103 | });
104 | }
105 |
106 | set({
107 | currentSong: nextSong,
108 | currentIndex: nextIndex,
109 | isPlaying: true,
110 | });
111 | } else {
112 | // no next song
113 | set({ isPlaying: false });
114 |
115 | const socket = useChatStore.getState().socket;
116 | if (socket.auth) {
117 | socket.emit("update_activity", {
118 | userId: socket.auth.userId,
119 | activity: `Idle`,
120 | });
121 | }
122 | }
123 | },
124 | playPrevious: () => {
125 | const { currentIndex, queue } = get();
126 | const prevIndex = currentIndex - 1;
127 |
128 | // theres a prev song
129 | if (prevIndex >= 0) {
130 | const prevSong = queue[prevIndex];
131 |
132 | const socket = useChatStore.getState().socket;
133 | if (socket.auth) {
134 | socket.emit("update_activity", {
135 | userId: socket.auth.userId,
136 | activity: `Playing ${prevSong.title} by ${prevSong.artist}`,
137 | });
138 | }
139 |
140 | set({
141 | currentSong: prevSong,
142 | currentIndex: prevIndex,
143 | isPlaying: true,
144 | });
145 | } else {
146 | // no prev song
147 | set({ isPlaying: false });
148 |
149 | const socket = useChatStore.getState().socket;
150 | if (socket.auth) {
151 | socket.emit("update_activity", {
152 | userId: socket.auth.userId,
153 | activity: `Idle`,
154 | });
155 | }
156 | }
157 | },
158 | }));
159 |
--------------------------------------------------------------------------------
/frontend/src/types/index.ts:
--------------------------------------------------------------------------------
1 | export interface Song {
2 | _id: string;
3 | title: string;
4 | artist: string;
5 | albumId: string | null;
6 | imageUrl: string;
7 | audioUrl: string;
8 | duration: number;
9 | createdAt: string;
10 | updatedAt: string;
11 | }
12 |
13 | export interface Album {
14 | _id: string;
15 | title: string;
16 | artist: string;
17 | imageUrl: string;
18 | releaseYear: number;
19 | songs: Song[];
20 | }
21 |
22 | export interface Stats {
23 | totalSongs: number;
24 | totalAlbums: number;
25 | totalUsers: number;
26 | totalArtists: number;
27 | }
28 |
29 | export interface Message {
30 | _id: string;
31 | senderId: string;
32 | receiverId: string;
33 | content: string;
34 | createdAt: string;
35 | updatedAt: string;
36 | }
37 |
38 | export interface User {
39 | _id: string;
40 | clerkId: string;
41 | fullName: string;
42 | imageUrl: string;
43 | }
44 |
--------------------------------------------------------------------------------
/frontend/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/frontend/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | darkMode: ["class"],
4 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
5 | theme: {
6 | extend: {
7 | borderRadius: {
8 | lg: 'var(--radius)',
9 | md: 'calc(var(--radius) - 2px)',
10 | sm: 'calc(var(--radius) - 4px)'
11 | },
12 | colors: {
13 | background: 'hsl(var(--background))',
14 | foreground: 'hsl(var(--foreground))',
15 | card: {
16 | DEFAULT: 'hsl(var(--card))',
17 | foreground: 'hsl(var(--card-foreground))'
18 | },
19 | popover: {
20 | DEFAULT: 'hsl(var(--popover))',
21 | foreground: 'hsl(var(--popover-foreground))'
22 | },
23 | primary: {
24 | DEFAULT: 'hsl(var(--primary))',
25 | foreground: 'hsl(var(--primary-foreground))'
26 | },
27 | secondary: {
28 | DEFAULT: 'hsl(var(--secondary))',
29 | foreground: 'hsl(var(--secondary-foreground))'
30 | },
31 | muted: {
32 | DEFAULT: 'hsl(var(--muted))',
33 | foreground: 'hsl(var(--muted-foreground))'
34 | },
35 | accent: {
36 | DEFAULT: 'hsl(var(--accent))',
37 | foreground: 'hsl(var(--accent-foreground))'
38 | },
39 | destructive: {
40 | DEFAULT: 'hsl(var(--destructive))',
41 | foreground: 'hsl(var(--destructive-foreground))'
42 | },
43 | border: 'hsl(var(--border))',
44 | input: 'hsl(var(--input))',
45 | ring: 'hsl(var(--ring))',
46 | chart: {
47 | '1': 'hsl(var(--chart-1))',
48 | '2': 'hsl(var(--chart-2))',
49 | '3': 'hsl(var(--chart-3))',
50 | '4': 'hsl(var(--chart-4))',
51 | '5': 'hsl(var(--chart-5))'
52 | }
53 | }
54 | }
55 | },
56 | plugins: [require("tailwindcss-animate")],
57 | };
58 |
--------------------------------------------------------------------------------
/frontend/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2020",
5 | "useDefineForClassFields": true,
6 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
7 | "module": "ESNext",
8 | "skipLibCheck": true,
9 |
10 | "baseUrl": ".",
11 | "paths": {
12 | "@/*": ["./src/*"]
13 | },
14 |
15 | /* Bundler mode */
16 | "moduleResolution": "Bundler",
17 | "allowImportingTsExtensions": true,
18 | "isolatedModules": true,
19 | "moduleDetection": "force",
20 | "noEmit": true,
21 | "jsx": "react-jsx",
22 |
23 | /* Linting */
24 | "strict": true,
25 | "noUnusedLocals": true,
26 | "noUnusedParameters": true,
27 | "noFallthroughCasesInSwitch": true,
28 | "noUncheckedSideEffectImports": true
29 | },
30 | "include": ["src"]
31 | }
32 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }],
4 | "compilerOptions": {
5 | "baseUrl": ".",
6 | "paths": {
7 | "@/*": ["./src/*"]
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/frontend/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "Bundler",
11 | "allowImportingTsExtensions": true,
12 | "isolatedModules": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noFallthroughCasesInSwitch": true,
21 | "noUncheckedSideEffectImports": true
22 | },
23 | "include": ["vite.config.ts"]
24 | }
25 |
--------------------------------------------------------------------------------
/frontend/vite.config.ts:
--------------------------------------------------------------------------------
1 | import path from "path";
2 | import react from "@vitejs/plugin-react";
3 | import { defineConfig } from "vite";
4 |
5 | export default defineConfig({
6 | plugins: [react()],
7 | resolve: {
8 | alias: {
9 | "@": path.resolve(__dirname, "./src"),
10 | },
11 | },
12 | server: {
13 | port: 3000,
14 | },
15 | });
16 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "spotify-clone",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "build": "npm install --prefix backend && npm install --prefix frontend && npm run build --prefix frontend",
8 | "start": "npm start --prefix backend"
9 | },
10 | "keywords": [],
11 | "author": "",
12 | "license": "ISC"
13 | }
14 |
--------------------------------------------------------------------------------
|