Repository: Avdhesh-Varshney/chanakya-niti
Pull request #404: Audio page setup
Author: login: Avdhesh-Varshney
Labels: name: enhancement
description: New feature or request
Created: 2025-04-06T10:27:20Z | Merged: 2025-04-06T10:28:52Z

### Describe the add-ons or changes you've made 📃
- Create audio page
- create `/eps/:id` authenticate protected route
- Design the UI to be mobile friendly


### Type of change ☑️
What sort of change have you made:

- [x] New feature 


Diff:
diff --git a/backend/.env.example b/backend/.env.example
index ae62bcb..b220bdc 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -1,5 +1,5 @@
 PORT=8000
 MONGODB_URI=
-JWT_SECRET=
-
-BACKEND_URL=https://chanakya-niti-backend.onrender.com
+SECRET_ACCESS_KEY=
+AUDIO_BUCKET_URI=
+ACCESS_TOKEN=
diff --git a/backend/Controllers/audio.controller.js b/backend/Controllers/audio.controller.js
new file mode 100644
index 0000000..92b74c4
--- /dev/null
+++ b/backend/Controllers/audio.controller.js
@@ -0,0 +1,42 @@
+import { formatAudioDataToSend } from '../utils/helpers.js';
+import User from '../Models/user.model.js';
+
+export const getAudio = async (req, res) => {
+    const { id } = req.params;
+    const userId = req.user.id;
+
+    try {
+        const episodeNumber = parseInt(id);
+        if (isNaN(episodeNumber)) {
+            return res.status(400).json({ error: "Invalid episode ID" });
+        }
+
+        if (episodeNumber < 1 || episodeNumber > 806) {
+            return res.status(400).json({ error: "Episode number must be between 1 and 806" });
+        }
+
+        const episodeName = episodeNumber < 10 ? `Ep 0${episodeNumber}` : `Ep ${episodeNumber}`;
+
+        const response = await fetch(process.env.AUDIO_BUCKET_URI, {
+            headers: {
+                Authorization: `token ${process.env.ACCESS_TOKEN}`,
+            },
+        });
+
+        const resp = await response.json();
+
+        const audioFiles = resp
+            .filter(file => file.name.endsWith(".mp3") || file.name.endsWith(".wav") || file.name.endsWith(".m4a") || file.name.endsWith(".aac"))
+            .map(file => file.download_url);
+
+        const URL = audioFiles.find(URL => URL.includes(episodeName));
+        let data = formatAudioDataToSend(URL);
+
+        await User.findByIdAndUpdate(userId, { episode: episodeNumber }, { new: true });
+
+        return res.status(200).json(data);
+
+    } catch (error) {
+        return res.status(500).json({ error: "Failed to fetch episode" });
+    }
+}
\ No newline at end of file
diff --git a/backend/Middlewares/auth.middleware.js b/backend/Middlewares/auth.middleware.js
new file mode 100644
index 0000000..246a96c
--- /dev/null
+++ b/backend/Middlewares/auth.middleware.js
@@ -0,0 +1,21 @@
+import jwt from "jsonwebtoken";
+
+export const authenticateUser = (req, res, next) => {
+    const token = req.header("Authorization");
+
+    if (!token || !token.startsWith("Bearer ")) {
+        return res.status(401).json({ error: "Access Denied: No Token Provided" });
+    }
+
+    try {
+        jwt.verify(token.split(" ")[1], process.env.SECRET_ACCESS_KEY, (err, user) => {
+            if (err) {
+                return res.status(403).json({ error: "Access token is invalid" });
+            }
+            req.user = user;
+            next();
+        });
+    } catch (error) {
+        res.status(500).json({ error: "Token not found" });
+    }
+}
\ No newline at end of file
diff --git a/backend/Routes/api/audio.routes.js b/backend/Routes/api/audio.routes.js
new file mode 100644
index 0000000..b24a71f
--- /dev/null
+++ b/backend/Routes/api/audio.routes.js
@@ -0,0 +1,9 @@
+import express from 'express';
+import { authenticateUser } from '../../Middlewares/auth.middleware.js';
+import { getAudio } from '../../Controllers/audio.controller.js';
+
+const audioRoutes = express.Router();
+
+audioRoutes.get('/eps/:id', authenticateUser, getAudio);
+
+export default audioRoutes;
diff --git a/backend/Routes/index.js b/backend/Routes/index.js
index 8fcf658..b6ac1e8 100644
--- a/backend/Routes/index.js
+++ b/backend/Routes/index.js
@@ -1,8 +1,10 @@
 import express from 'express';
 import authRoutes from './api/auth.routes.js';
+import audioRoutes from './api/audio.routes.js';
 
 const router = express.Router();
 
 router.use('/auth', authRoutes);
+router.use('/audio', audioRoutes);
 
 export default router;
diff --git a/backend/utils/helpers.js b/backend/utils/helpers.js
index 15eb85b..255a16d 100644
--- a/backend/utils/helpers.js
+++ b/backend/utils/helpers.js
@@ -4,9 +4,25 @@ export const emailRegex = /^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/;
 export const passwordRegex = /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{6,20}$/;
 
 export const formatDataToSend = (user) => {
-    const access_token = jwt.sign({ id: user._id }, process.env.SECRET_ACCESS_KEY, { expiresIn: '30d' });
+    const access_token = jwt.sign({ id: user._id, email: user.email }, process.env.SECRET_ACCESS_KEY, { expiresIn: '30d' });
     return {
         access_token,
         episode: user.episode
     }
+}
+
+export const formatAudioDataToSend = (URL) => {
+    const parts = URL.split("/");
+    const filename = parts[parts.length - 1];
+
+    let splitFilename;
+    if (filename.includes(" - ")) {
+        splitFilename = filename.split(" - ");
+    } else if (filename.includes("- ")) {
+        splitFilename = filename.split("- ");
+    }
+
+    const title = splitFilename[0];
+    const content = splitFilename[1].split(".")[0];
+    return { title, content, filename };
 }
\ No newline at end of file
diff --git a/frontend/.env.example b/frontend/.env.example
index 1742692..7f5320f 100644
--- a/frontend/.env.example
+++ b/frontend/.env.example
@@ -1,2 +1,2 @@
-VITE_EPISODES_API_URL=https://api.github.com/repos/hack-boi/Chanakya/contents
-VITE_BACKEND_URL=https://chanakya-niti-backend.onrender.com
+VITE_SERVER_DOMAIN=https://chanakya-niti-backend.onrender.com
+VITE_AUDIO_BUCKET_URI=
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 5a8b2e5..877af7d 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -8,6 +8,7 @@ import Footer from "./components/Footer";
 
 import Home from "./pages/Home";
 import UserAuthForm from "./pages/UserAuthForm";
+import Audio from "./pages/Audio";
 
 export const UserContext = createContext({});
 
@@ -32,7 +33,12 @@ function App() {
 
         <main className="flex-1 w-full max-w-7xl px-4 mx-auto">
           <Routes>
-            <Route path="/" element={<Home />} />
+            {
+              userAuth.access_token ?
+                <Route path="/" element={<Audio />} />
+                :
+                <Route path="/" element={<Home />} />
+            }
             <Route path="/login" element={<UserAuthForm type='login' />} />
             <Route path="/register" element={<UserAuthForm type='register' />} />
           </Routes>
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 0862e33..f4fc542 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -93,11 +93,11 @@
   }
 
   .btn-dark {
-    @apply whitespace-nowrap bg-[#bfae64] text-white rounded-full py-3 px-6 text-xl capitalize hover:bg-[#a6914f] transition;
+    @apply whitespace-nowrap bg-[#bfae64] text-white rounded-full py-3 px-6 text-xl capitalize hover:bg-[#a6914f] transition duration-300;
   }
 
   .btn-light {
-    @apply whitespace-nowrap bg-[#fdf8e4] text-black rounded-full py-3 px-6 text-xl capitalize border border-[#bfae64] hover:bg-[#f4eacb] transition;
+    @apply whitespace-nowrap bg-[#fdf8e4] text-black rounded-full py-3 px-6 text-xl capitalize border border-[#bfae64] hover:bg-[#f4eacb] transition duration-300;
   }
 
   .input-box {
diff --git a/frontend/src/pages/Audio.jsx b/frontend/src/pages/Audio.jsx
new file mode 100644
index 0000000..a4e8d65
--- /dev/null
+++ b/frontend/src/pages/Audio.jsx
@@ -0,0 +1,130 @@
+import axios from "axios";
+import { useContext, useEffect, useState } from "react";
+import ReactPlayer from "react-player";
+import { Toaster, toast } from "react-hot-toast";
+
+import { UserContext } from "../App";
+import { storeInSession } from "../common/session";
+
+const Imgs = [
+  "/image.webp",
+  "/chanakya_and_chandragupta_maurya.jpg",
+  "/chanakya-statue.jpg",
+  "/Chandragupta_Maurya_Empire.png",
+  "/logo.webp",
+  "/takshashila.jpg"
+];
+
+const Audio = () => {
+
+  const { userAuth, userAuth: { access_token, episode }, setUserAuth } = useContext(UserContext);
+
+  const [title, setTitle] = useState("");
+  const [content, setContent] = useState("");
+  const [URL, setURL] = useState("");
+
+  const fetchEpisode = async () => {
+    await axios.get(import.meta.env.VITE_SERVER_DOMAIN + `/api/audio/eps/${episode}`, {
+      headers: {
+        Authorization: `Bearer ${access_token}`,
+      }
+    })
+      .then(({ data }) => {
+        setTitle(data.title);
+        setContent(data.content);
+        setURL(`${import.meta.env.VITE_AUDIO_BUCKET_URI}/${data.filename}`);
+        toast.success("Enjoy your episode!");
+      })
+      .catch(({ response }) => {
+        toast.error(response.data.error);
+        console.error(response.data.error);
+      });
+  }
+
+  const getRandomImage = () => {
+    const randomIndex = Math.floor(Math.random() * Imgs.length);
+    return Imgs[randomIndex];
+  };
+
+  const handleSubmit = (e) => {
+    e.preventDefault();
+
+    let form = new FormData(audioFormElement);
+    let formData = {};
+
+    for (let [key, value] of form.entries()) {
+      formData[key] = value;
+    }
+
+    let { episode } = formData;
+
+    episode = parseInt(episode);
+    if (isNaN(episode)) return toast.error("Invalid episode number");
+
+    if (!episode) {
+      return toast.error("Episode number is required");
+    }
+    if (episode < 1 || episode > 806) {
+      return toast.error("Episode number must be between 1 and 806");
+    }
+
+    const updatedUser = { ...userAuth, episode: episode };
+    setUserAuth(updatedUser);
+    storeInSession("user", JSON.stringify(updatedUser));
+  }
+
+  useEffect(() => {
+    fetchEpisode();
+  }, [episode]);
+
+  return (
+    <div className="flex flex-col gap-6 max-w-4xl mx-auto px-4 py-8">
+      <Toaster />
+
+      <form id="audioFormElement" onSubmit={handleSubmit} className="flex flex-col md:flex-row gap-4 items-center relative">
+        <input
+          name="episode"
+          type="number"
+          placeholder="Enter episode number"
+          className="input-box"
+        />
+
+        <i className="fi fi-rr-music input-icon"></i>
+
+        <button
+          type="submit"
+          className="input-icon left-[auto] right-4 bg-[#fdf8e4] text-black rounded-lg py-2 px-4 capitalize border border-[#bfae64] hover:bg-[#f4eacb] transition duration-300"
+        >
+          Enter
+        </button>
+      </form>
+
+      {URL && (
+        <div className="flex flex-col gap-4">
+          <div className="overflow-hidden rounded-xl shadow-lg w-full max-h-[500px] aspect-video">
+            <img
+              src={getRandomImage()}
+              alt=""
+              className="w-full h-full object-cover object-top transition duration-300 hover:scale-105"
+            />
+          </div>
+
+          <p className="text-center text-xl font-semibold text-gray-800">
+            {title} - <span className="text-md text-gray-600">{content}</span>
+          </p>
+
+          <ReactPlayer
+            url={URL}
+            volume={0.75}
+            playing
+            controls
+            height="50px"
+            width="100%"
+          />
+        </div>
+      )}
+    </div>
+  );
+};
+
+export default Audio;
diff --git a/frontend/src/pages/ChanakyaAudio.jsx b/frontend/src/pages/ChanakyaAudio.jsx
deleted file mode 100644
index 13d575e..0000000
--- a/frontend/src/pages/ChanakyaAudio.jsx
+++ /dev/null
@@ -1,98 +0,0 @@
-import { useEffect, useState } from "react";
-import * as func from '../functions/RequestEpisode.module';
-import ReactPlayer from "react-player";
-
-const ChanakyaAudio = () => {
-  const [title, setTitle] = useState("");
-  const [content, setContent] = useState("");
-  const [URL, setURL] = useState(null);
-  const [episodeNumber, setEpisodeNumber] = useState(1);
-  const [value, setValue] = useState(1);
-  const [playbackRate, setPlaybackRate] = useState(1);
-
-  const changePlaybackRate = (rate) => {
-    setPlaybackRate(rate);
-  };
-
-  useEffect(() => {
-    const getData = async () => {
-      setProgress(40);
-      try {
-        const data = await func.fetchData(episodeNumber);
-        setProgress(65);
-        if (data.trimData) {
-          setTitle(data.trimData.title);
-          setContent(data.trimData.content);
-          setURL(data.trimData.URL);
-          setProgress(85);
-        } else {
-          console.error("No data found!");
-          setURL(null);
-        }
-      } catch (error) {
-        console.error("Error fetching episode:", error);
-        setURL(null);
-      }
-      setProgress(100);
-    };
-
-    getData();
-  }, [episodeNumber, setProgress]);
-
-  const handleClick = () => {
-    if (value >= 1 && value <= 806) {
-      setEpisodeNumber(value);
-    }
-  };
-
-  const handleKeyPress = (event) => {
-    if (event.key === "Enter" &&
-      event.target.value >= 1 &&
-      event.target.value <= 806) {
-      setEpisodeNumber(event.target.value);
-    }
-  };
-
-  const handleChange = (event) => {
-    setValue(event.target.value);
-  };
-
-  return (
-    <div className="container my-4 d-flex flex-column gap-4 justify-content-center align-items-center text-center">
-
-      <div className="d-flex gap-2 align-items-center">
-        <input type="number"
-          placeholder="Enter episode number"
-          value={value}
-          onChange={handleChange}
-          onKeyDown={handleKeyPress}
-          className="form-control"
-          min="1"
-          max="806"
-          style={{ width: "14rem" }}
-        />
-        <button type="submit" className="btn btn-dark" onClick={handleClick}>
-          Enter
-        </button>
-      </div>
-
-      <div className="container d-flex flex-column justify-content-center align-items-center text-center gap-4">
-        <img src="/image.webp" alt="chanakya-image" />
-        <div>
-          <h5>{title} - {content}</h5>
-          <ReactPlayer
-            url={URL}
-            volume={0.5}
-            playing
-            controls
-            playbackRate={playbackRate}
-            height="50px"
-          />
-        </div>
-      </div>
-
-    </div>
-  );
-};
-
-export default ChanakyaAudio;


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: 

Copilot reviewed 8 out of 11 changed files in this pull request and generated 2 comments.

<details>
<summary>Files not reviewed (3)</summary>

* **backend/.env.example**: Language not supported
* **frontend/.env.example**: Language not supported
* **frontend/src/index.css**: Language not supported
</details>

<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**backend/Controllers/audio.controller.js:20**
* Ensure that the global fetch API is available in your Node environment or consider using an alternative library like axios to avoid compatibility issues.
```
const response = await fetch(process.env.AUDIO_BUCKET_URI, {
```
</details>


state: COMMENTED
author_association: NONE
submitted_at: 2025-04-06T10:28:08Z

Review comments:
diff_hunk: @@ -0,0 +1,130 @@
+import axios from "axios";
+import { useContext, useEffect, useState } from "react";
+import ReactPlayer from "react-player";
+import { Toaster, toast } from "react-hot-toast";
+
+import { UserContext } from "../App";
+import { storeInSession } from "../common/session";
+
+const Imgs = [
+  "/image.webp",
+  "/chanakya_and_chandragupta_maurya.jpg",
+  "/chanakya-statue.jpg",
+  "/Chandragupta_Maurya_Empire.png",
+  "/logo.webp",
+  "/takshashila.jpg"
+];
+
+const Audio = () => {
+
+  const { userAuth, userAuth: { access_token, episode }, setUserAuth } = useContext(UserContext);
+
+  const [title, setTitle] = useState("");
+  const [content, setContent] = useState("");
+  const [URL, setURL] = useState("");
+
+  const fetchEpisode = async () => {
+    await axios.get(import.meta.env.VITE_SERVER_DOMAIN + `/api/audio/eps/${episode}`, {
+      headers: {
+        Authorization: `Bearer ${access_token}`,
+      }
+    })
+      .then(({ data }) => {
+        setTitle(data.title);
+        setContent(data.content);
+        setURL(`${import.meta.env.VITE_AUDIO_BUCKET_URI}/${data.filename}`);
+        toast.success("Enjoy your episode!");
+      })
+      .catch(({ response }) => {
+        toast.error(response.data.error);
+        console.error(response.data.error);
+      });
+  }
+
+  const getRandomImage = () => {
+    const randomIndex = Math.floor(Math.random() * Imgs.length);
+    return Imgs[randomIndex];
+  };
+
+  const handleSubmit = (e) => {
+    e.preventDefault();
+
+    let form = new FormData(audioFormElement);
path: frontend/src/pages/Audio.jsx
user: login: Copilot
body: The variable 'audioFormElement' is referenced but not defined. Consider obtaining the form element using document.getElementById('audioFormElement') or a ref.
created_at: 2025-04-06T10:28:08Z
author_association: NONE
diff_hunk: @@ -4,9 +4,25 @@ export const emailRegex = /^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/;
 export const passwordRegex = /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{6,20}$/;
 
 export const formatDataToSend = (user) => {
-    const access_token = jwt.sign({ id: user._id }, process.env.SECRET_ACCESS_KEY, { expiresIn: '30d' });
+    const access_token = jwt.sign({ id: user._id, email: user.email }, process.env.SECRET_ACCESS_KEY, { expiresIn: '30d' });
     return {
         access_token,
         episode: user.episode
     }
+}
+
+export const formatAudioDataToSend = (URL) => {
+    const parts = URL.split("/");
+    const filename = parts[parts.length - 1];
+
+    let splitFilename;
+    if (filename.includes(" - ")) {
+        splitFilename = filename.split(" - ");
+    } else if (filename.includes("- ")) {
+        splitFilename = filename.split("- ");
+    }
+
+    const title = splitFilename[0];
path: backend/utils/helpers.js
user: login: Copilot
body: The function 'formatAudioDataToSend' does not check whether 'splitFilename' is defined or contains the expected two parts. Add input validation to avoid runtime errors if the filename format is unexpected.
created_at: 2025-04-06T10:28:08Z
author_association: NONE

PR comments:
user: login: vercel[bot]
created_at: 2025-04-06T10:27:24Z
body: [vc]: #jRMjy8nYsvmWaVGZ/y34nzxLhrPJTVLB2kLxNoA0tKI=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJjaGFuYWt5YSIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJjaGFuYWt5YS1naXQtZmVhdC1hdWRpby1wYWdlLXNldHVwLWF2ZGhlc2h2YXJzaG5leXMtcHJvamVjdHMudmVyY2VsLmFwcCJ9LCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vYXZkaGVzaHZhcnNobmV5cy1wcm9qZWN0cy9jaGFuYWt5YS8zUVVRUXhSdjc3VUNVbmVXbWticjVIYUV5V3kxIiwicHJldmlld1VybCI6ImNoYW5ha3lhLWdpdC1mZWF0LWF1ZGlvLXBhZ2Utc2V0dXAtYXZkaGVzaHZhcnNobmV5cy1wcm9qZWN0cy52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIn1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **chanakya** | ✅ Ready ([Inspect](https://vercel.com/avdheshvarshneys-projects/chanakya/3QUQQxRv77UCUneWmkbr5HaEyWy1)) | [Visit Preview](https://chanakya-git-feat-audio-page-setup-avdheshvarshneys-projects.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/chanakya-git-feat-audio-page-setup-avdheshvarshneys-projects.vercel.app?via=pr-comment-feedback-link) | Apr 6, 2025 10:27am |


author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-06T10:27:32Z
body: Thank you for submitting your pull request! We'll review it as soon as possible. For further communication, join our discord server https://discord.gg/tSqtvHUJzE.
author_association: NONE