├── .eslintrc.cjs
├── .gitignore
├── README.md
├── index.html
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
├── 404.html
├── _redirects
├── index.html
└── vite.svg
├── src
├── App.jsx
├── Components
│ ├── CategoryList.jsx
│ ├── ChannelVideoPage.jsx
│ ├── ExploreVideos.jsx
│ ├── Header.jsx
│ ├── MainContainer.jsx
│ ├── RelatedVideos.jsx
│ ├── SearchBar.jsx
│ ├── SearchVideoPage.jsx
│ ├── ShimmerUI
│ │ ├── ButtonsShimmer.jsx
│ │ ├── SearchVideoShimmer.jsx
│ │ └── VideoShimmer.jsx
│ ├── SideBar.jsx
│ ├── VideoCard.jsx
│ └── VideoContainer.jsx
├── HomePageContainer
│ ├── Body.jsx
│ ├── CustomError.jsx
│ ├── ExploreVideoPage.jsx
│ ├── SearchResults.jsx
│ ├── SubScriptionPage.jsx
│ ├── WatchPage.jsx
│ └── WatchPageData
│ │ ├── ChannelData.jsx
│ │ ├── ChatMessage.jsx
│ │ ├── Comments.jsx
│ │ ├── CommentsData.jsx
│ │ ├── LiveChat.jsx
│ │ └── RelatedVideoPage.jsx
├── assets
│ ├── MyPic.jpg
│ ├── YouTubeWhite.png
│ ├── YouTube_Logo.png
│ └── react.svg
├── index.css
├── main.jsx
└── utils
│ ├── APIList.jsx
│ ├── appSlice.jsx
│ ├── chatSlice.jsx
│ ├── constants.jsx
│ ├── helper.jsx
│ ├── searchSlice.jsx
│ └── store.jsx
├── tailwind.config.js
└── vite.config.js
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: { browser: true, es2020: true },
4 | extends: [
5 | 'eslint:recommended',
6 | 'plugin:react/recommended',
7 | 'plugin:react/jsx-runtime',
8 | 'plugin:react-hooks/recommended',
9 | ],
10 | ignorePatterns: ['dist', '.eslintrc.cjs'],
11 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
12 | settings: { react: { version: '18.2' } },
13 | plugins: ['react-refresh'],
14 | rules: {
15 | 'react-refresh/only-export-components': [
16 | 'warn',
17 | { allowConstantExport: true },
18 | ],
19 | },
20 | }
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # envFiles
2 | .env
3 |
4 |
5 | # dependencies
6 | /node_modules
7 | /.pnp
8 | .pnp.js
9 |
10 | # testing
11 | /coverage
12 |
13 | # misc
14 | .DS_Store
15 | .env.local
16 | .env.development.local
17 | .env.test.local
18 | .env.production.local
19 |
20 | npm-debug.log*
21 | yarn-debug.log*
22 | yarn-error.log*
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # YOUTUBE CLONE :
2 |
3 | ### Project Overview
4 |
5 | Developed a highly optimized and scalable video streaming application, leveraging YouTube's Live API to deliver seamless live streaming experiences. The application focuses on enhancing user engagement through various optimization techniques and features.
6 |
7 | ### Live Demo : https://my-youtube-ui.netlify.app/
8 |
9 | ### Key Features
10 |
11 | - Optimized Search (debouncing and caching techniques)
12 | - Live Chat Simulation (using API polling)
13 | - Lazy Loading
14 | - Shimmer UI while loading
15 | - Caching Search Suggestions
16 | - Hover to Seek Videos
17 | - Filter Videos by Tags
18 | - Experience on search results and explore pages (e.g., Trending).
19 |
20 | ### Technologies Used
21 |
22 | - React JS
23 | - Redux Toolkit
24 | - Tailwind CSS
25 | - Vite
26 |
27 | ### Getting Started :
28 |
29 | Follow these steps to set up and run the application locally:
30 |
31 | ### Prerequisites :
32 |
33 | - npm (Node Package Manager) installed.
34 | - Vite should be installed.
35 |
36 | ### Installation :
37 |
38 | 1. Clone the repository:
39 |
40 | ```
41 | git clone https://github.com/onkar895/YouTube-UI.git
42 | cd MyYouTube
43 | ```
44 |
45 | LOCALHOST=1234
46 |
47 | 2. Install dependencies:
48 |
49 | ```
50 | cd MyYouTube
51 | npm install
52 | ```
53 |
54 | 3. Run the code
55 | ```
56 | npm run dev
57 | ```
58 |
59 | Screenshots :
60 | 
61 | 
62 | 
63 | 
64 | 
65 | 
66 | 
67 | 
68 | 
69 | 
70 | 
71 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | YouTube
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "myyoutube",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build",
9 | "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "@reduxjs/toolkit": "^2.0.1",
14 | "dotenv": "^16.3.1",
15 | "framer-motion": "^11.0.3",
16 | "random-avatar-generator": "^2.0.0",
17 | "react": "^18.2.0",
18 | "react-dom": "^18.2.0",
19 | "react-fontawesome": "^1.7.1",
20 | "react-icons": "^4.12.0",
21 | "react-infinite-scroll-component": "^6.1.0",
22 | "react-redux": "^9.0.2",
23 | "react-router": "^6.21.2",
24 | "react-router-dom": "^6.21.1",
25 | "redux": "^5.0.0"
26 | },
27 | "devDependencies": {
28 | "@types/react": "^18.2.37",
29 | "@types/react-dom": "^18.2.15",
30 | "@vitejs/plugin-react": "^4.2.0",
31 | "autoprefixer": "^10.4.16",
32 | "eslint": "^8.53.0",
33 | "eslint-plugin-react": "^7.33.2",
34 | "eslint-plugin-react-hooks": "^4.6.0",
35 | "eslint-plugin-react-refresh": "^0.4.4",
36 | "postcss": "^8.4.31",
37 | "tailwind-scrollbar": "^3.0.5",
38 | "tailwindcss": "^3.3.5",
39 | "vite": "^5.0.0"
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/public/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Not Found
7 |
45 |
46 |
47 |
48 |
404 - Page Not Found
49 |
The page you requested could not be found.
50 |
Visit the homepage
51 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/public/_redirects:
--------------------------------------------------------------------------------
1 | /* /index.html 200
2 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | YouTube
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/App.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-unused-vars */
2 | import React, { lazy, Suspense } from 'react';
3 | import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
4 | import { Provider } from 'react-redux';
5 | import store from './utils/store';
6 | import "../src/index.css";
7 | import Header from './Components/Header'
8 | import Body from './HomePageContainer/Body'
9 | import SearchResults from './HomePageContainer/SearchResults';
10 | import MainContainer from './Components/MainContainer'
11 | import SubScriptionPage from './HomePageContainer/SubScriptionPage';
12 | import ExploreVideoPage from './HomePageContainer/ExploreVideoPage';
13 |
14 | // Lazy load WatchPage
15 | const WatchPage = lazy(() => import('./HomePageContainer/WatchPage'));
16 |
17 | const App = () => {
18 | return (
19 |
20 |
21 |
22 |
23 |
24 | }>
25 | } />
26 | Loading...}>
29 |
30 |
31 | }
32 | />
33 | } />
34 | } />
35 | } />
36 |
37 |
38 |
39 |
40 |
41 | );
42 | }
43 |
44 | export default App;
45 |
--------------------------------------------------------------------------------
/src/Components/CategoryList.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-unused-vars */
2 | import React, { useState, useEffect, useRef } from 'react';
3 | import { useNavigate } from 'react-router-dom';
4 | import { useDispatch } from 'react-redux'
5 | import { fetchTagsUrl, YOUTUBE_SEARCH_API } from '../utils/APIList';
6 | import { BiSolidChevronLeftCircle, BiSolidChevronRightCircle } from "react-icons/bi";
7 | import ButtonsShimmer from './ShimmerUI/ButtonsShimmer';
8 | import { TagNames } from '../utils/constants';
9 |
10 | const CategoryList = () => {
11 | const listRef = useRef();
12 | const navigate = useNavigate();
13 | const [selectedButton, setSelectedButton] = useState("All");
14 | const [loading, setLoading] = useState(true);
15 | const [slideNumber, setSlideNumber] = useState(0);
16 | const [tags, setTags] = useState([]);
17 | const [error, setError] = useState(null);
18 | const [isLoading, setIsLoading] = useState(false);
19 |
20 |
21 | const dispatch = useDispatch()
22 |
23 | useEffect(() => {
24 | const timer = setTimeout(() => {
25 | setLoading(false)
26 | }, 1000)
27 |
28 | return () => clearTimeout(timer);
29 | }, []);
30 |
31 | useEffect(() => {
32 | fetchTags();
33 | }, []);
34 |
35 | const fetchTags = async () => {
36 | setLoading(true);
37 | try {
38 | const res = await fetch(fetchTagsUrl);
39 | const data = await res.json();
40 | const items = data.items || []; // Extract items array from the response
41 | const tags = items.map(item => item.snippet.title); // Extract titles from items
42 | setTags(tags);
43 | setLoading(false); // Set loading to false after fetching tags
44 | } catch (error) {
45 | console.error('Error fetching tags:', error);
46 | setError('Failed to fetch tags. Please try again later.');
47 | setLoading(false);
48 | }
49 | };
50 |
51 | const handleExploreButtonClick = (ExploreName) => {
52 | const newQuery = ExploreName.replace(" ", "+");
53 | setSelectedButton(newQuery);
54 | setIsLoading(true); // Start loading
55 | if (newQuery === "All") {
56 | navigate("/");
57 | } else {
58 | navigate(`/explore?eq=${newQuery}`);
59 | }
60 | };
61 |
62 | const handleScroll = (direction) => {
63 | const box = listRef.current;
64 | const slideWidth = box.clientWidth;
65 | const newSlideNumber = direction === "previous" ? slideNumber - 1 : slideNumber + 1;
66 | setSlideNumber(newSlideNumber);
67 | box.scrollLeft += (direction === "previous" ? -slideWidth : slideWidth);
68 | };
69 |
70 | return (
71 |
72 |
73 |
74 | handleScroll("previous")}>
75 |
76 |
77 |
78 |
79 | handleScroll("next")}>
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 | {loading ? (
88 |
89 | ) : (
90 | <>
91 | {
92 | tags.length === 0 && (
93 |
94 |
95 | {
96 | TagNames.map((item, index) => (
97 | handleExploreButtonClick(name)}>
98 | {item}
99 |
100 | ))
101 | }
102 |
103 |
104 | )
105 | }
106 |
107 | {
108 | tags.length > 0 && (
109 |
110 | navigate('/')} className={`bg-gray-100 hover:bg-gray-900 hover:text-white hover:transition duration-500 px-[12px] py-[6px] rounded-lg ${selectedButton === "All" ? 'bg-gray-900 text-white' : ''}`}>
111 | All
112 |
113 | {
114 | tags.map((name, index) => {
115 | return (
116 | handleExploreButtonClick(name)}>
117 | {name}
118 |
119 | )
120 | })
121 | }
122 |
123 | )
124 | }
125 | >
126 | )}
127 |
128 |
129 |
130 | );
131 | };
132 |
133 | export default CategoryList;
134 |
135 |
--------------------------------------------------------------------------------
/src/Components/ChannelVideoPage.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-hooks/exhaustive-deps */
2 | /* eslint-disable react/prop-types */
3 | /* eslint-disable no-unused-vars */
4 | import React, { useState, useEffect } from 'react';
5 | import { CHANNEL_INFO_API, VIDEO_DETAILS_API } from '../utils/APIList';
6 | import { CiBellOn } from 'react-icons/ci'
7 | import { HiOutlineChevronDown } from 'react-icons/hi2'
8 | import { formatTime, formatNumberWithSuffix, timeDuration } from '../utils/constants';
9 |
10 | const ChannelVideoPage = ({ info, videoId }) => {
11 |
12 | const [videos, setVideos] = useState([]);
13 | const [channelPicture, setChannelPicture] = useState([]);
14 | const [channelInfo, setChannelInfo] = useState([])
15 | const [subScribers, setSubScribers] = useState(0)
16 | const [subscribe, setSubscribe] = useState(false);
17 | const [isHovered, setIsHovered] = useState(false);
18 |
19 | const { snippet } = info;
20 | const { title, thumbnails, publishedAt, description, channelTitle } = snippet;
21 |
22 | const subscriberCount = formatNumberWithSuffix(subScribers);
23 | const viewCount = formatNumberWithSuffix(videos?.statistics?.viewCount);
24 | let calender = formatTime(publishedAt);
25 | const duration = timeDuration(videos?.contentDetails?.duration);
26 |
27 | const RemoveSpaces = channelTitle.replace(/ /g, "")
28 |
29 | useEffect(() => {
30 | fetchVideoData();
31 | }, [videoId]);
32 |
33 | const fetchVideoData = async () => {
34 | try {
35 | const data = await fetch(VIDEO_DETAILS_API + '&id=' + videoId);
36 | const response = await data.json();
37 | setVideos(response?.items?.[0] || {});
38 | console.log(response?.items?.[0])
39 | } catch (error) {
40 | console.log('Error while fetching video details', error);
41 | }
42 | };
43 |
44 | useEffect(() => {
45 | if (info?.snippet?.channelId) {
46 | fetchChannelData();
47 | }
48 | }, [info?.snippet?.channelId]);
49 |
50 | const fetchChannelData = async () => {
51 | try {
52 | const data = await fetch(CHANNEL_INFO_API + '&id=' + info?.snippet?.channelId);
53 | const response = await data.json();
54 | setChannelInfo(response?.items[0])
55 | const profilePictureUrl = response?.items?.[0]?.snippet?.thumbnails?.default?.url || '';
56 | setChannelPicture(profilePictureUrl);
57 | const subScribers = response?.items?.[0]?.statistics?.subscriberCount || ''
58 | setSubScribers(subScribers)
59 | } catch (error) {
60 | console.log("Couldn't fetch channel profile picture", error);
61 | }
62 | };
63 |
64 | return (
65 | <>
66 | setIsHovered(true)}
68 | onMouseOut={() => setIsHovered(false)}>
69 |
70 | {
71 | isHovered && (
72 |
73 | VIDEO
83 |
84 | )
85 | }
86 |
91 |
92 | {duration}
93 |
94 |
95 |
96 |
97 |
{title}
98 |
99 |
100 |
{channelTitle}
101 |
102 |
103 | {viewCount} Views
104 | •
105 | {calender}
106 |
107 |
108 |
109 |
110 | >
111 | );
112 | };
113 |
114 | export default ChannelVideoPage;
--------------------------------------------------------------------------------
/src/Components/ExploreVideos.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-hooks/exhaustive-deps */
2 | /* eslint-disable react/prop-types */
3 | /* eslint-disable no-unused-vars */
4 | import React, { useState, useEffect } from 'react';
5 | import { CHANNEL_INFO_API, VIDEO_DETAILS_API } from '../utils/APIList';
6 | import { formatTime, formatNumberWithSuffix, timeDuration } from '../utils/constants';
7 |
8 | const ExploreVideos = ({ info, videoId }) => {
9 |
10 | const [videos, setVideos] = useState([]);
11 | const [channelPicture, setChannelPicture] = useState('');
12 | const [channelInfo, setChannelInfo] = useState([])
13 | const [isHovered, setIsHovered] = useState(false);
14 | const { snippet } = info;
15 | const { title, thumbnails, publishedAt, description, channelTitle } = snippet;
16 |
17 | const viewCount = formatNumberWithSuffix(videos?.statistics?.viewCount);
18 | let calender = formatTime(publishedAt);
19 | const duration = timeDuration(videos?.contentDetails?.duration);
20 |
21 | useEffect(() => {
22 | fetchVideoData();
23 | }, [videoId]);
24 |
25 | const fetchVideoData = async () => {
26 | try {
27 | const data = await fetch(VIDEO_DETAILS_API + '&id=' + videoId);
28 | const response = await data.json();
29 | setVideos(response?.items?.[0] || {});
30 | // console.log(response?.items?.[0])
31 | } catch (error) {
32 | console.log('Error while fetching video details', error);
33 | }
34 | };
35 |
36 | useEffect(() => {
37 | if (info?.snippet?.channelId) {
38 | fetchChannelData();
39 | }
40 | }, [info?.snippet?.channelId]);
41 |
42 | const fetchChannelData = async () => {
43 | try {
44 | const data = await fetch(CHANNEL_INFO_API + '&id=' + info?.snippet?.channelId);
45 | const response = await data.json();
46 | setChannelInfo(response?.items[0])
47 | const profilePictureUrl = response?.items?.[0]?.snippet?.thumbnails?.default?.url || '';
48 | setChannelPicture(profilePictureUrl);
49 | } catch (error) {
50 | console.log("Couldn't fetch channel profile picture", error);
51 | }
52 | };
53 |
54 | return (
55 | setIsHovered(true)}
56 | onMouseOut={() => setIsHovered(false)}>
57 |
58 | {
59 | isHovered && (
60 |
61 | VIDEO
71 |
72 | )
73 | }
74 |
75 |
80 |
81 | {duration}
82 |
83 |
84 |
85 |
86 |
{title}
87 |
88 |
89 |
{channelTitle}
90 |
91 |
92 | {viewCount} Views
93 | •
94 | {calender}
95 |
96 |
97 |
98 |
99 | );
100 | };
101 |
102 | export default ExploreVideos;
--------------------------------------------------------------------------------
/src/Components/Header.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/no-unescaped-entities */
2 | /* eslint-disable no-unused-vars */
3 | import React, { useState } from 'react';
4 | import { CgMenuLeftAlt } from "react-icons/cg";
5 | import { IoNotificationsOutline } from 'react-icons/io5';
6 | import { RiVideoAddLine } from 'react-icons/ri';
7 | import YouTubeLogo from '../assets/YouTube_Logo.png';
8 | import MyPic from '../assets/MyPic.jpg';
9 | import { useDispatch } from 'react-redux';
10 | import { toggleMenu } from '../utils/appSlice';
11 | import { AiFillAudio } from "react-icons/ai";
12 | import { useNavigate } from 'react-router-dom';
13 | import SearchBar from './SearchBar';
14 |
15 | const Header = () => {
16 | const dispatch = useDispatch();
17 |
18 | const [showSearch, setShowSearch] = useState(false);
19 |
20 | const navigate = useNavigate()
21 |
22 | function handleClick() {
23 | navigate("/");
24 | }
25 |
26 | // Function to toggle the side menu
27 | const toggleMenuHandler = () => {
28 | dispatch(toggleMenu());
29 | };
30 |
31 | return (
32 |
33 |
34 | {/* Logo and Menu Icon */}
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | {/* SearchBar Component */}
43 |
52 |
53 | {/* Notification and User Icons */}
54 |
55 |
56 |
57 |
58 |
59 | 9+
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 | );
68 | };
69 |
70 | export default Header;
--------------------------------------------------------------------------------
/src/Components/MainContainer.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-unused-vars */
2 | import React from 'react'
3 | import CategoryList from './CategoryList'
4 | import VideoContainer from '../Components/VideoContainer'
5 |
6 | const MainContainer = () => {
7 |
8 | return (
9 | <>
10 |
11 |
12 |
13 |
14 | >
15 | )
16 | }
17 |
18 | export default MainContainer
--------------------------------------------------------------------------------
/src/Components/RelatedVideos.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/prop-types */
2 | /* eslint-disable no-unused-vars */
3 | import React, { useState } from 'react'
4 | import { timeDuration, formatTime, formatNumberWithSuffix } from '../utils/constants'
5 | import { truncateText } from '../utils/helper'
6 |
7 | const RelatedVideos = ({ info, videoId }) => {
8 | const [isHovered, setIsHovered] = useState(false);
9 |
10 | const { snippet, contentDetails, statistics } = info;
11 | const { title, thumbnails, channelTitle, publishedAt } = snippet;
12 |
13 | const duration = timeDuration(contentDetails.duration);
14 | const calender = formatTime(publishedAt);
15 | const viewCount = statistics?.viewCount ? formatNumberWithSuffix(statistics.viewCount) : 0;
16 |
17 | return (
18 | setIsHovered(true)}
19 | onMouseOut={() => setIsHovered(false)}>
20 |
21 | {
22 | isHovered && (
23 |
24 | VIDEO
34 |
35 | )
36 | }
37 |
38 |
39 |
44 |
45 | {duration}
46 |
47 |
48 |
49 |
50 |
51 |
{truncateText(title, 60)}
52 |
53 | {viewCount} Views
54 | {calender}
55 |
56 |
57 |
58 | {channelTitle}
59 |
60 |
61 |
62 | );
63 | }
64 |
65 | export default RelatedVideos
66 |
67 |
--------------------------------------------------------------------------------
/src/Components/SearchBar.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-hooks/exhaustive-deps */
2 | /* eslint-disable react/prop-types */
3 | /* eslint-disable no-unused-vars */
4 | import React, { useEffect, useRef, useState } from 'react';
5 | import { IoSearchOutline } from 'react-icons/io5';
6 | import { BsArrowLeftShort } from 'react-icons/bs';
7 | import { IoCloseOutline } from "react-icons/io5";
8 | import { YOUTUBE_SEARCH_SUGGESTION_API } from '../utils/APIList';
9 | import { useSelector } from 'react-redux';
10 | import { cacheResults } from '../utils/searchSlice';
11 | import { useDispatch } from 'react-redux';
12 | import { useNavigate } from 'react-router-dom';
13 |
14 | const SearchBar = ({ showSearch, setShowSearch }) => {
15 | const isMenuOpen = useSelector((store) => store.app.isMenuOpen);
16 | const [searchQuery, setSearchQuery] = useState('');
17 | const [suggestions, setSuggestions] = useState([]);
18 | const [showSuggestions, setShowSuggestions] = useState(false);
19 | const [isInputFocused, setIsInputFocused] = useState(false);
20 |
21 | const searchCache = useSelector((store) => store.search)
22 | const search = useRef(null);
23 | const dispatch = useDispatch()
24 | const navigate = useNavigate()
25 |
26 | // Styles stored in variables for better readability and maintainability
27 | const searchSuggestionBarStyles = `${showSearch ? 'max-sm:w-[98%] max-sm:rounded-b-2xl max-sm:h-full max-sm:border-none' : 'max-sm:hidden'} fixed py-5 bg-white md:shadow-2xl md:rounded-2xl md:w-[45vw] lg:w-[42.3vw] lg:h-[75vh] border border-gray-100 `;
28 |
29 | const inputStyles = `${showSearch ? 'w-[61vw] max-sm:mx-auto transition-all duration-500 max-sm:ml-2 ml-2 pl-[12px] py-2' : 'max-sm:hidden'} ${isInputFocused ? 'max-sm:w-[74vw] max-sm:mx-auto max-sm:focus:outline-0 max-sm:border max-sm:border-red-600 md:pl-[3.2rem] md:border md:border-blue-600' : ''} md:w-[36vw] lg:w-[42vw] md:py-[7px] lg:py-[7px] border border-gray-300 rounded-l-full py-1 pl-3 md:pl-6 items-center transition-all focus:outline-0 duration-500}`;
30 |
31 | const searchButtonStyles = `${showSearch ? 'max-sm:px-3 max-sm:py-[10px]' : 'max-sm:border-none max-sm:text-2xl max-sm:px-auto max-sm:ml-32'} text-xl px-[2px] py-[9px] border border-gray-300 md:hover:bg-gray-200 rounded-r-full md:px-6 flex justify-center items-center md:bg-gray-100`;
32 |
33 | /**
34 | * Suppose,
35 | * searchCache = {
36 | * "iphone" : ["iphone 13", "iphone 14", .....]
37 | * }
38 | *
39 | * searchQuery = iphone
40 | */
41 |
42 | useEffect(() => {
43 | const timer = setTimeout(() => {
44 | // If there are cached suggestions for the current searchQuery, it sets those suggestions using setSuggestions.
45 | // If there are no cached suggestions, it calls the getSearchSuggestions function.
46 | if (searchCache[searchQuery]) {
47 | setSuggestions((searchCache[searchQuery]))
48 | } else {
49 | getSearchSuggestions()
50 | }
51 | }, 200);
52 |
53 | return () => {
54 | clearTimeout(timer);
55 | }
56 | }, [searchQuery]);
57 |
58 | const getSearchSuggestions = async () => {
59 | try {
60 | console.log('API CALL - ' + searchQuery);
61 | const data = await fetch(YOUTUBE_SEARCH_SUGGESTION_API + searchQuery);
62 | const response = await data.json();
63 | setSuggestions(response[1]);
64 |
65 | // Dispatching a Redux action (cacheResults) to update the search cache with the new suggestions.
66 | dispatch(cacheResults({
67 | [searchQuery]: response[1] // key:value
68 | }))
69 |
70 | } catch (error) {
71 | console.error('Error while fetching search suggestions:', error);
72 | }
73 | };
74 |
75 | // Function to handle the search button click
76 | const handleSearchButtonClick = () => {
77 | const screenWidth = window.innerWidth;
78 | if (screenWidth < 768 && !showSearch) {
79 | setShowSearch(true);
80 | }
81 | };
82 |
83 | // Function to handle the arrow left button click in the search
84 | const handleArrowLeftClick = () => {
85 | setShowSearch(false);
86 | };
87 |
88 | // Function to handle input focus
89 | const handleInputFocus = () => {
90 | setIsInputFocused(true);
91 | setShowSuggestions(true);
92 | };
93 |
94 | // Function to handle input blur
95 | const handleInputBlur = () => {
96 | setIsInputFocused(false);
97 | // setShowSuggestions(false);
98 | };
99 |
100 | const handleClearSearch = (event) => {
101 | event.preventDefault();
102 | setSearchQuery('');
103 | search.current.focus()
104 | };
105 |
106 | const handleScrollTop = () => {
107 | window.scrollTo({
108 | top: 0,
109 | behavior: "smooth",
110 | });
111 | };
112 |
113 | const handleSearch = (event, search) => {
114 | event.preventDefault();
115 | if (search !== "") {
116 | const query = search.replace(" ", "+");
117 | navigate(`/results?search_query=${query}`);
118 | handleScrollTop();
119 | setShowSuggestions(false);
120 | setSearchQuery("");
121 | }
122 | };
123 |
124 | return (
125 | <>
126 |
127 |
128 | {/* Left Arrow Button In sm Search To Move to normal Screen */}
129 | {
130 | showSearch && (
131 |
132 |
133 |
134 |
135 |
136 | )
137 | }
138 |
139 | {
140 | isInputFocused && (
141 |
142 |
143 |
144 |
145 |
146 | )
147 | }
148 |
149 | {
150 | searchQuery && (
151 |
152 |
153 |
154 |
155 |
156 | )
157 | }
158 |
159 |
setSearchQuery(e.target.value)}
166 | onFocus={handleInputFocus}
167 | onBlur={handleInputBlur}
168 | />
169 |
{ handleSearchButtonClick(); handleSearch(event, searchQuery) }}>
170 |
171 |
172 |
173 |
174 |
175 | {
176 | searchQuery && showSuggestions && (
177 |
178 | {
179 | suggestions.length === 0 ? (
180 | searchQuery !== "" && (
181 |
{`No Results Found for ${searchQuery}`}
182 | )
183 | ) : (
184 |
185 | {
186 | suggestions.map((suggestion) => (
187 | handleSearch(event, suggestion)} >
188 |
189 | {suggestion}
190 |
191 | ))
192 | }
193 |
194 | )
195 | }
196 |
197 | )
198 | }
199 |
200 |
201 | >
202 | );
203 | };
204 |
205 | export default SearchBar;
206 |
--------------------------------------------------------------------------------
/src/Components/SearchVideoPage.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-hooks/exhaustive-deps */
2 | /* eslint-disable react/prop-types */
3 | /* eslint-disable no-unused-vars */
4 | import React, { useState, useEffect } from 'react';
5 | import { CHANNEL_INFO_API, VIDEO_DETAILS_API } from '../utils/APIList';
6 | import { formatTime, formatNumberWithSuffix, timeDuration } from '../utils/constants';
7 | import { truncateText } from '../utils/helper'
8 |
9 |
10 | const SearchVideoPage = ({ info, videoId }) => {
11 |
12 | const [videos, setVideos] = useState([]);
13 | const [channelPicture, setChannelPicture] = useState([]);
14 | const [channelInfo, setChannelInfo] = useState([])
15 | const [isHovered, setIsHovered] = useState(false);
16 | const { snippet } = info;
17 | const { title, thumbnails, publishedAt, description, channelTitle } = snippet;
18 |
19 | const viewCount = formatNumberWithSuffix(videos?.statistics?.viewCount);
20 | let calender = formatTime(publishedAt);
21 | const duration = timeDuration(videos?.contentDetails?.duration);
22 |
23 | useEffect(() => {
24 | fetchVideoData();
25 | }, [videoId]);
26 |
27 | const fetchVideoData = async () => {
28 | try {
29 | const data = await fetch(VIDEO_DETAILS_API + '&id=' + videoId);
30 | const response = await data.json();
31 | setVideos(response?.items?.[0] || {}); // Ensure items array exists
32 | console.log(response?.items?.[0])
33 | } catch (error) {
34 | console.log('Error while fetching video details', error);
35 | }
36 | };
37 |
38 | useEffect(() => {
39 | if (info?.snippet?.channelId) {
40 | fetchChannelData();
41 | }
42 | }, [info?.snippet?.channelId]);
43 |
44 | const fetchChannelData = async () => {
45 | try {
46 | const data = await fetch(CHANNEL_INFO_API + '&id=' + info?.snippet?.channelId);
47 | const response = await data.json();
48 | setChannelInfo(response?.items[0])
49 | const profilePictureUrl = response?.items?.[0]?.snippet?.thumbnails?.default?.url || '';
50 | setChannelPicture(profilePictureUrl);
51 | } catch (error) {
52 | console.log("Couldn't fetch channel profile picture", error);
53 | }
54 | };
55 |
56 | return (
57 | setIsHovered(true)} onMouseOut={() => setIsHovered(false)}>
58 |
59 | {
60 | isHovered && (
61 |
62 | VIDEO
72 |
73 | )
74 | }
75 |
76 |
77 |
82 |
83 | {duration}
84 |
85 |
86 |
87 |
88 |
89 |
{truncateText(title, 60)}
90 |
91 | {viewCount} Views
92 | •
93 | {calender}
94 |
95 |
96 |
97 |
98 |
{channelTitle}
99 |
100 |
101 | {truncateText(description, 90)}
102 |
103 |
104 |
105 | );
106 | };
107 |
108 | export default SearchVideoPage;
--------------------------------------------------------------------------------
/src/Components/ShimmerUI/ButtonsShimmer.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-unused-vars */
2 | import React from 'react';
3 |
4 | const ButtonsShimmer = () => {
5 | return (
6 |
7 | {Array(12).fill('').map((_, index) => (
8 |
11 | ))}
12 |
13 | );
14 | };
15 |
16 | export default ButtonsShimmer;
17 |
--------------------------------------------------------------------------------
/src/Components/ShimmerUI/SearchVideoShimmer.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-unused-vars */
2 | import React from 'react'
3 |
4 | const SearchVideoShimmer = () => {
5 | return (
6 |
7 | {
8 | // creating a new Array instance using Array() constructor and map through every element of array
9 | Array(50)
10 | .fill("")
11 | .map((_, index) => (
12 |
13 |
27 | ))
28 | }
29 |
30 | )
31 | }
32 |
33 | export default SearchVideoShimmer
34 |
--------------------------------------------------------------------------------
/src/Components/ShimmerUI/VideoShimmer.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-unused-vars */
2 | import React from 'react'
3 |
4 | const VideoShimmer = () => {
5 | return (
6 |
7 | {
8 | // creating the new Array instance using Array() constructor and map through every element of array
9 | Array(50)
10 | .fill("")
11 | .map((_, index) => (
12 |
13 |
17 |
24 | {/*
25 |
26 |
27 |
28 |
*/}
29 |
30 |
31 | ))
32 | }
33 |
34 | )
35 | }
36 |
37 | export default VideoShimmer
38 |
--------------------------------------------------------------------------------
/src/Components/SideBar.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-hooks/exhaustive-deps */
2 | /* eslint-disable no-unused-vars */
3 | import React, { useEffect, useState } from 'react';
4 | import { Explore, Premium, Setting, Home, Subscriptions } from '../utils/constants';
5 | import { MdHomeFilled } from 'react-icons/md';
6 | import { FaSquareYoutube } from 'react-icons/fa6';
7 | import { SiYoutubeshorts } from 'react-icons/si';
8 | import { MdSubscriptions } from 'react-icons/md';
9 | import { CgMenuLeftAlt } from "react-icons/cg";
10 | import YouTubeLogo from '../assets/YouTube_Logo.png';
11 | import { useDispatch } from 'react-redux';
12 | import { toggleMenu } from '../utils/appSlice';
13 | import { useNavigate } from 'react-router-dom';
14 | import { useSelector } from 'react-redux';
15 | import { NavLink, useLocation, useSearchParams } from 'react-router-dom';
16 |
17 | const SideBar = () => {
18 |
19 | const [searchParams] = useSearchParams();
20 | const [selectedButton, setSelectedButton] = useState("Home");
21 | const [isLoading, setIsLoading] = useState(false);
22 |
23 | const isMenuOpen = useSelector((store) => store.app.isMenuOpen)
24 |
25 | const location = useLocation();
26 |
27 | const dispatch = useDispatch();
28 |
29 | const navigate = useNavigate()
30 |
31 | // useEffect(() => {
32 | // const Query = searchParams.get("eId");
33 | // if (Query) {
34 | // setSelectedButton(Query);
35 | // } else {
36 | // setSelectedButton("Home");
37 | // }
38 | // }, [])
39 |
40 | function handleClick() {
41 | navigate("/");
42 | }
43 |
44 | const StopLoading = () => {
45 | setTimeout(() => {
46 | // Close sidebar after 1000ms
47 | dispatch(toggleMenu());
48 | setIsLoading(false); // Stop loading
49 | }, 500);
50 | }
51 |
52 | const handleChannelButtonClick = (ButtonName) => {
53 | const Query = ButtonName.replace(" ", "+");
54 | setSelectedButton(Query);
55 | setIsLoading(true); // Start loading
56 | StopLoading() // Stop Loading and Close sidebar after 500ms
57 | if (Query === "Home") {
58 | navigate("/");
59 | } else {
60 | navigate(`/channel?cId=${Query}`);
61 | }
62 | };
63 |
64 | const handleExploreButtonClick = (ExploreName) => {
65 | const newQuery = ExploreName.replace(" ", "+");
66 | setSelectedButton(newQuery);
67 | setIsLoading(true); // Start loading
68 | StopLoading() // Stop Loading and Close sidebar after 500ms
69 | if (newQuery === "Home") {
70 | navigate("/");
71 | } else {
72 | navigate(`/explore?eq=${newQuery}`);
73 | }
74 | };
75 |
76 | // Function to toggle the side menu
77 | const toggleMenuHandler = () => {
78 | dispatch(toggleMenu());
79 | };
80 |
81 | const SideBarStyle = isMenuOpen
82 | ? ' sidebar-open fixed left-0 md:w-[30vw] lg:w-[19vw] max-sm:w-[60vw] h-full bg-white z-50 text-sm md:top-0 max-sm:top-0 shadow-gray-700 shadow-2xl transition-shadow duration-300'
83 | : 'fixed max-sm:hidden md:flex-col text-xs space-y-6 mt-[73px] ml-1'
84 |
85 | // Early return pattern
86 | // If the menu is closed and the location is '/watch', hide the first sidebar
87 | if (!isMenuOpen) {
88 | if (location.pathname === '/watch') {
89 | return null;
90 | }
91 |
92 | // Render the first sidebar with four sections when the menu is closed.
93 | return (
94 |
95 |
96 |
97 |
98 | Home
99 |
100 |
101 |
102 |
103 | Shorts
104 |
105 |
106 |
107 | Subscriptions
108 |
109 |
110 |
111 | You
112 |
113 |
114 | );
115 | }
116 |
117 | // If the menu is open, render the toggle sidebar with all sections.
118 | return (
119 | <>
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
144 |
145 |
146 |
147 |
148 |
149 |
150 | SubScriptions
151 | {
152 | Subscriptions.map(({ src, profileId, fullname }) => {
153 | return (
154 |
155 |
handleChannelButtonClick(fullname)}>
156 |
157 |
159 |
{fullname}
160 |
161 |
162 |
163 | )
164 | })
165 | }
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 | Explore
174 | {
175 | Explore.map(({ icon, name }) => {
176 | return (
177 |
178 |
handleExploreButtonClick(name)}>
179 |
180 | {icon}
181 | {name}
182 |
183 |
184 |
185 | )
186 | })
187 | }
188 |
189 |
190 |
191 |
192 |
193 |
194 |
195 | More from YouTube
196 | {
197 | Premium.map(({ icon, name }) => {
198 | return (
199 |
200 |
handleExploreButtonClick(name)}>
201 |
202 | {icon}
203 | {name}
204 |
205 |
206 |
207 | )
208 | })
209 | }
210 |
211 |
212 |
213 |
214 |
215 |
216 |
232 |
233 |
234 | >
235 | );
236 | };
237 |
238 | export default SideBar;
--------------------------------------------------------------------------------
/src/Components/VideoCard.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-hooks/exhaustive-deps */
2 | /* eslint-disable react/prop-types */
3 | /* eslint-disable no-unused-vars */
4 | import React, { useState, useEffect } from 'react';
5 | import { LuEye } from 'react-icons/lu';
6 | import { BiLike } from "react-icons/bi";
7 | import { timeDuration, formatTime, formatNumberWithSuffix } from '../utils/constants';
8 | import { CHANNEL_INFO_API } from '../utils/APIList';
9 | import VideoShimmer from './ShimmerUI/VideoShimmer';
10 |
11 | const VideoCard = ({ info }) => {
12 | const [isHovered, setIsHovered] = useState(false);
13 | const [profilePicture, setProfilePicture] = useState([]);
14 |
15 | useEffect(() => {
16 | if (info) {
17 | fetchProfilePicture();
18 | }
19 | }, [info]);
20 |
21 | const fetchProfilePicture = async () => {
22 | try {
23 | const data = await fetch(CHANNEL_INFO_API + "&id=" + info.snippet.channelId);
24 | const response = await data.json();
25 | const profilePictureUrl = response?.items[0]?.snippet?.thumbnails?.default?.url;
26 | setProfilePicture(profilePictureUrl);
27 | } catch (error) {
28 | console.error("Couldn't fetch channel profile picture", error);
29 | }
30 | };
31 |
32 | const { snippet, statistics, contentDetails } = info;
33 | const { title, channelTitle, thumbnails, publishedAt } = snippet;
34 | const duration = timeDuration(contentDetails.duration);
35 | const calender = formatTime(publishedAt);
36 | const viewCount = statistics?.viewCount ? formatNumberWithSuffix(statistics.viewCount) : 0;
37 | const likeCount = statistics?.likeCount ? formatNumberWithSuffix(statistics.likeCount) : 0;
38 |
39 | return (
40 | setIsHovered(true)}
42 | onMouseOut={() => setIsHovered(false)}>
43 |
44 | {
45 | isHovered && (
46 |
47 | VIDEO
57 |
58 | )
59 | }
60 |
65 |
66 | {duration}
67 |
68 |
69 |
70 |
71 |
72 |
{title}...
73 |
74 |
75 |
76 |
77 |
{channelTitle}
78 |
79 |
80 |
{likeCount} Likes
81 |
82 |
83 |
84 |
85 |
86 |
{viewCount} Views
87 |
88 |
89 |
90 | {calender}
91 |
92 |
93 |
94 |
95 |
96 | );
97 | };
98 |
99 | export default VideoCard;
100 |
--------------------------------------------------------------------------------
/src/Components/VideoContainer.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-unused-vars */
2 | import React, { useEffect, useState } from 'react';
3 | import { YOUTUBE_VIDEO_API, YOUTUBE_SEARCHCATEGORY_API } from '../utils/APIList';
4 | import VideoCard from '../Components/VideoCard';
5 | import VideoShimmer from './ShimmerUI/VideoShimmer';
6 | import CustomError from '../HomePageContainer/CustomError';
7 | import { NavLink } from 'react-router-dom';
8 |
9 | const VideoContainer = () => {
10 | const [videos, setVideos] = useState([]);
11 | const [loading, setLoading] = useState(true);
12 | const [error, setError] = useState(null);
13 |
14 | useEffect(() => {
15 | // Simulate loading data
16 | const timer = setTimeout(() => {
17 | setLoading(false);
18 | }, 2000);
19 |
20 | return () => clearTimeout(timer);
21 | }, []);
22 |
23 | useEffect(() => {
24 | getVideos()
25 | }, []);
26 |
27 | const getVideos = async () => {
28 | try {
29 | const response = await fetch(YOUTUBE_VIDEO_API);
30 |
31 | if (!response.ok) {
32 | throw new Error(`Failed to fetch videos. Status: ${response.status}`);
33 | }
34 |
35 | const data = await response.json();
36 | setVideos(data.items);
37 | setError(null); // Clear error state on successful fetch
38 | } catch (error) {
39 | console.error('Error while fetching the videos:', error);
40 | setError('Failed to fetch videos. Please try again later.');
41 | }
42 | };
43 |
44 | if (error) {
45 | return ;
46 | }
47 |
48 | return (
49 |
50 | {loading ? (
51 |
52 | ) : (
53 | videos.map((video) => (
54 |
55 |
56 |
57 | ))
58 | )}
59 |
60 | );
61 | };
62 |
63 | export default VideoContainer;
64 |
--------------------------------------------------------------------------------
/src/HomePageContainer/Body.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-unused-vars */
2 | import React from 'react'
3 | import SideBar from '../Components/SideBar'
4 | import { Outlet } from 'react-router-dom'
5 | import { useSelector } from 'react-redux';
6 |
7 | const Body = () => {
8 |
9 | const isMenuOpen = useSelector((store) => store.app.isMenuOpen)
10 |
11 | const mainStyle = isMenuOpen ? 'blur-effect' : ''
12 | return (
13 |
14 |
15 | {/* {
16 | The < Outlet > component from react-router-dom is a special component that is used within a parent route to render the child routes. In this case, it allows the child routes defined in the App component to be rendered inside the Body component.
17 | } */}
18 |
19 |
20 |
21 |
22 | )
23 | }
24 |
25 | export default Body
26 |
--------------------------------------------------------------------------------
/src/HomePageContainer/CustomError.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-unused-vars */
2 | /* eslint-disable react/prop-types */
3 | /* eslint-disable react/no-unescaped-entities */
4 | import React from "react";
5 |
6 | const Error = ({ message }) => {
7 |
8 | const classNameString = `bg-black font-bold tracking-wide rounded-2xl shadow-md text-white text-justify box-border lg:w-[48%] md:mt-12 max-sm:mt-12 max-sm:w-[94vw] lg:py-6 lg:px-6 max-sm:py-6 max-sm:px-5 max-sm:mx-3 lg:ml-[24.5%] lg:min-h-[20vh] md:w-[68%] md:py-6 md:px-6 md:ml-[15%] relative`
9 |
10 | return (
11 | <>
12 |
13 |
14 |
{message}
15 |
16 |
- This service, powered by free APIs with limitations, may experience interruptions.
17 |
- The API key has reached the maximum number of allowed requests. So, it breaks anytime! Sorry for the inconvenience.
18 |
- For a more reliable experience, kindly consider using the original YouTube platform.
19 |
20 | >
21 | );
22 | };
23 |
24 | export default Error;
--------------------------------------------------------------------------------
/src/HomePageContainer/ExploreVideoPage.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-hooks/exhaustive-deps */
2 | /* eslint-disable no-unused-vars */
3 | import React, { useState, useEffect } from 'react';
4 | import { YOUTUBE_SEARCH_API } from '../utils/APIList';
5 | import { NavLink, useSearchParams } from 'react-router-dom';
6 | import CustomError from './CustomError'
7 | import ExploreVideos from '../Components/ExploreVideos'
8 | import VideoShimmer from '../Components/ShimmerUI/VideoShimmer'
9 | import CategoryList from '../Components/CategoryList';
10 |
11 | const ExploreVideoPage = () => {
12 | const [videos, setVideos] = useState([]);
13 | const [isLoading, setIsLoading] = useState(true);
14 | const [error, setError] = useState(null);
15 |
16 | const [searchParam] = useSearchParams();
17 | const explore = searchParam.get('eq');
18 |
19 | useEffect(() => {
20 | const timer = setTimeout(() => {
21 | setIsLoading(false);
22 | }, 2000);
23 |
24 | return () => clearTimeout(timer);
25 | }, []);
26 |
27 | useEffect(() => {
28 | getExploreResults();
29 | }, [explore]);
30 |
31 | const getExploreResults = async () => {
32 | try {
33 | if (!explore) return;
34 | const response = await fetch(`${YOUTUBE_SEARCH_API}&q=${explore}®ionCode=IN&type=video`);
35 | if (!response.ok) {
36 | throw new Error(`Failed to fetch search results. Status: ${response.status}`);
37 | }
38 | const data = await response.json();
39 | setVideos(data.items || []);
40 | setError(null);
41 | } catch (error) {
42 | console.error('Error while fetching search videos', error);
43 | setError('Failed to fetch videos. Please try again later.');
44 | }
45 | };
46 |
47 | return (
48 | <>
49 |
50 |
51 |
52 |
53 |
54 | {
55 | isLoading ? (
56 |
57 | ) : error ? (
58 |
59 |
60 |
61 | ) : (
62 | videos.map((video) => (
63 |
64 |
65 |
66 | ))
67 | )
68 | }
69 |
70 |
71 | >
72 | );
73 | };
74 |
75 | export default ExploreVideoPage;
--------------------------------------------------------------------------------
/src/HomePageContainer/SearchResults.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-hooks/exhaustive-deps */
2 | /* eslint-disable no-unused-vars */
3 | import React, { useState, useEffect } from 'react';
4 | import { YOUTUBE_SEARCH_API } from '../utils/APIList';
5 | import { NavLink, useSearchParams } from 'react-router-dom';
6 | import CustomError from './CustomError'
7 | import SearchVideoPage from '../Components/SearchVideoPage';
8 | import SearchVideoShimmer from '../Components/ShimmerUI/SearchVideoShimmer';
9 | import CategoryList from '../Components/CategoryList';
10 |
11 | const SearchResults = () => {
12 | const [searchParam] = useSearchParams();
13 | const searchQuery = searchParam.get('search_query');
14 | const [videos, setVideos] = useState([]);
15 | const [isLoading, setIsLoading] = useState(true);
16 | const [error, setError] = useState(null);
17 |
18 | useEffect(() => {
19 | const timer = setTimeout(() => {
20 | setIsLoading(false);
21 | }, 2000);
22 |
23 | return () => clearTimeout(timer);
24 | }, []);
25 |
26 | useEffect(() => {
27 | getSearchResults();
28 | }, [searchQuery]);
29 |
30 | const getSearchResults = async () => {
31 | try {
32 | if (!searchQuery) return;
33 | const response = await fetch(`${YOUTUBE_SEARCH_API}&q=${searchQuery}®ionCode=IN&type=video`);
34 | if (!response.ok) {
35 | throw new Error(`Failed to fetch search results. Status: ${response.status}`);
36 | }
37 | const data = await response.json();
38 | setVideos(data.items || []);
39 | setError(null);
40 | } catch (error) {
41 | console.error('Error while fetching search videos', error);
42 | setError('Failed to fetch videos. Please try again later.');
43 | }
44 | };
45 |
46 | return (
47 | <>
48 |
49 |
50 |
51 |
52 |
53 | {
54 | !error ? (
55 |
Showing Results for : {searchQuery}
56 | ) : (
57 | Unable to show any Results for : {searchQuery}
58 | )
59 | }
60 |
61 |
62 | {
63 | isLoading ? (
64 |
65 | ) : error ? (
66 |
67 |
68 |
69 | ) : (
70 | videos.map((video) => (
71 |
72 |
73 |
74 | ))
75 | )
76 | }
77 |
78 |
79 | >
80 | );
81 | };
82 |
83 | export default SearchResults;
--------------------------------------------------------------------------------
/src/HomePageContainer/SubScriptionPage.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-hooks/exhaustive-deps */
2 | /* eslint-disable no-unused-vars */
3 | import React, { useState, useEffect } from 'react';
4 | import { YOUTUBE_SEARCH_API } from '../utils/APIList';
5 | import { NavLink, useSearchParams } from 'react-router-dom';
6 | import CustomError from './CustomError'
7 | import ChannelVideoPage from '../Components/ChannelVideoPage';
8 | import VideoShimmer from '../Components/ShimmerUI/VideoShimmer'
9 | import CategoryList from '../Components/CategoryList';
10 |
11 | const SubScriptionPage = () => {
12 |
13 | const [videos, setVideos] = useState([]);
14 | const [isLoading, setIsLoading] = useState(true);
15 | const [error, setError] = useState(null);
16 |
17 | const [searchParam] = useSearchParams();
18 | const channel = searchParam.get('cId');
19 |
20 | useEffect(() => {
21 | const timer = setTimeout(() => {
22 | setIsLoading(false);
23 | }, 2000);
24 |
25 | return () => clearTimeout(timer);
26 | }, []);
27 |
28 | useEffect(() => {
29 | getSearchResults();
30 | }, [channel]);
31 |
32 | const getSearchResults = async () => {
33 | try {
34 | if (!channel) return;
35 | const response = await fetch(`${YOUTUBE_SEARCH_API}&q=${channel}®ionCode=IN&type=video`);
36 | if (!response.ok) {
37 | throw new Error(`Failed to fetch search results. Status: ${response.status}`);
38 | }
39 | const data = await response.json();
40 | setVideos(data.items || []);
41 | setError(null);
42 | } catch (error) {
43 | console.error('Error while fetching search videos', error);
44 | setError('Failed to fetch videos. Please try again later.');
45 | }
46 | };
47 |
48 | return (
49 | <>
50 |
51 |
52 |
53 |
54 |
55 | {
56 | isLoading ? (
57 |
58 | ) : error ? (
59 |
60 |
61 |
62 | ) : (
63 | videos.map((video) => (
64 |
65 |
66 |
67 | ))
68 | )
69 | }
70 |
71 |
72 | >
73 | );
74 | };
75 |
76 | export default SubScriptionPage;
--------------------------------------------------------------------------------
/src/HomePageContainer/WatchPage.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-hooks/exhaustive-deps */
2 | /* eslint-disable no-unused-vars */
3 | import React, { useMemo, useState } from 'react';
4 | import ChannelData from './WatchPageData/ChannelData';
5 | import { useSearchParams } from 'react-router-dom'
6 | import CommentsData from './WatchPageData/CommentsData';
7 | import LiveChat from './WatchPageData/LiveChat';
8 | import RelatedVideoPage from './WatchPageData/RelatedVideoPage';
9 |
10 | const WatchPage = () => {
11 |
12 | const [container, setContainer] = useState(true)
13 | // useSearchParams is used for accessing and manipulating URL parameters.
14 | const [searchParams] = useSearchParams();
15 | const videoId = searchParams.get('v');
16 | const videoSrc = useMemo(() => `https://www.youtube.com/embed/${videoId}?autoplay=1&mute=0`, [videoId]);
17 |
18 | return (
19 |
20 |
21 |
22 |
23 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 | );
50 | };
51 |
52 | export default WatchPage;
--------------------------------------------------------------------------------
/src/HomePageContainer/WatchPageData/ChannelData.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-hooks/exhaustive-deps */
2 | /* eslint-disable no-unused-vars */
3 | import React, { useState, useEffect } from 'react'
4 | import PropTypes from 'prop-types';
5 | import { BiLike, BiDislike } from 'react-icons/bi'
6 | import { BiSolidLike, BiSolidDislike } from 'react-icons/bi'
7 | import { CiBellOn } from 'react-icons/ci'
8 | import { PiShareFat, PiDotsThreeBold } from 'react-icons/pi'
9 | import { MdOutlineDownloading } from 'react-icons/md'
10 | import { HiOutlineChevronDown } from 'react-icons/hi2'
11 | import { CHANNEL_INFO_API, VIDEO_DETAILS_API } from '../../utils/APIList'
12 | import { formatNumberWithSuffix, formatTime } from '../../utils/constants'
13 |
14 | const ChannelData = ({ videoId }) => {
15 | const [videoData, setVideoData] = useState({});
16 | const [subscribe, setSubscribe] = useState(false);
17 | const [channelPicture, setChannelPicture] = useState('');
18 | const [subScribers, setSubScribers] = useState(0)
19 | const [like, setLike] = useState(true)
20 | const [disLike, setDisLike] = useState(true)
21 | const [showMore, setShowMore] = useState(false);
22 |
23 | const subscriberCount = formatNumberWithSuffix(subScribers);
24 | const likeCount = formatNumberWithSuffix(videoData?.statistics?.likeCount);
25 | const viewCount = formatNumberWithSuffix(videoData?.statistics?.viewCount);
26 | let calender = formatTime(videoData?.snippet?.publishedAt);
27 |
28 | useEffect(() => {
29 | fetchVideoData();
30 | }, [videoId]);
31 |
32 | const fetchVideoData = async () => {
33 | try {
34 | const data = await fetch(VIDEO_DETAILS_API + '&id=' + videoId);
35 | const response = await data.json();
36 | setVideoData(response?.items?.[0] || {});
37 | console.log(videoData)
38 | } catch (error) {
39 | console.log('Error while fetching video details', error);
40 | }
41 | };
42 |
43 | useEffect(() => {
44 | if (videoData?.snippet?.channelId) {
45 | fetchChannelData();
46 | }
47 | }, [videoData?.snippet?.channelId]);
48 |
49 | const fetchChannelData = async () => {
50 | try {
51 | const data = await fetch(CHANNEL_INFO_API + '&id=' + videoData?.snippet?.channelId);
52 | const response = await data.json();
53 | const profilePictureUrl = response?.items?.[0]?.snippet?.thumbnails?.default?.url || '';
54 | const subScribers = response?.items?.[0]?.statistics?.subscriberCount || ''
55 | setChannelPicture(profilePictureUrl);
56 | setSubScribers(subScribers)
57 | } catch (error) {
58 | console.log("Couldn't fetch channel data", error);
59 | }
60 | };
61 |
62 | const handleLikeToggle = () => {
63 | setLike(!like);
64 | setDisLike(true);
65 | like ? likeCount((prev) => prev - 1) : likeCount((prev) => prev + 1);
66 | };
67 |
68 | const handleDisLikeToggle = () => {
69 | setDisLike(!disLike);
70 | setLike(true);
71 | disLike ? likeCount((prev) => prev + 1) : likeCount((prev) => prev - 1);
72 | };
73 |
74 | return (
75 |
76 |
77 |
{videoData?.snippet?.title}
78 |
79 |
80 |
81 |
82 |
83 |
84 | {videoData?.snippet?.channelTitle}
85 | {subscriberCount} Subscribers
86 |
87 |
88 |
setSubscribe(!subscribe)}
90 | >
91 | {subscribe ? (
92 |
93 |
94 | Subscribed
95 |
96 |
97 | ) : (
98 |
99 | Subscribe
100 |
101 | )}
102 |
103 |
104 |
105 |
106 |
107 | {
108 | like ? (
109 |
110 | ) : (
111 |
112 | )
113 | }
114 | {likeCount}
115 |
116 |
117 |
118 |
119 |
120 | {
121 | disLike ? (
122 |
123 | ) : (
124 |
125 | )
126 | }
127 |
128 |
129 |
133 |
134 |
135 | Download
136 |
137 |
140 |
141 |
142 |
143 |
144 |
{viewCount} views
145 |
146 |
{calender}
147 |
148 |
149 |
150 | {videoData?.snippet?.description}
151 |
152 |
setShowMore(!showMore)}>
153 | {
154 | showMore ? "Show Less" : "Show More..."
155 | }
156 |
157 |
158 |
159 |
160 | )
161 | }
162 |
163 | // 'PropTypes.string.isRequired' specifies that the 'videoId' prop must be a string and is required
164 | ChannelData.propTypes = {
165 | videoId: PropTypes.string.isRequired,
166 | };
167 |
168 | export default ChannelData
169 |
--------------------------------------------------------------------------------
/src/HomePageContainer/WatchPageData/ChatMessage.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/prop-types */
2 | /* eslint-disable no-unused-vars */
3 | import React from 'react'
4 | import { FaCircleUser } from "react-icons/fa6";
5 |
6 | const ChatMessage = ({ info }) => {
7 | return (
8 |
9 |
10 |
11 | {info.name}
12 | {info.message}
13 |
14 |
15 | )
16 | }
17 |
18 | export default ChatMessage
19 |
--------------------------------------------------------------------------------
/src/HomePageContainer/WatchPageData/Comments.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/prop-types */
2 | /* eslint-disable no-unused-vars */
3 | import React, { useState } from 'react';
4 | import { BiLike, BiDislike, BiSolidLike, BiSolidDislike } from 'react-icons/bi';
5 | import { FaCaretUp, FaCaretDown } from "react-icons/fa";
6 |
7 | const Comments = ({ info, isReply }) => {
8 | const [showReply, setShowReply] = useState(false);
9 | const [like, setLike] = useState(false);
10 | const [dislike, setDislike] = useState(false);
11 | const [likeCount, setLikeCount] = useState(0);
12 |
13 | const getComments = (item, isReply) => {
14 | return isReply ? item.snippet : item.snippet.topLevelComment.snippet;
15 | };
16 |
17 | const handleLike = () => {
18 | setLike(!like);
19 | setDislike(false);
20 | if (!like) {
21 | setLikeCount(likeCount + 1);
22 | } else {
23 | setLikeCount(likeCount - 1);
24 | }
25 | };
26 |
27 | const handleDislike = () => {
28 | setDislike(!dislike);
29 | setLike(false);
30 | if (!dislike && likeCount > 0) {
31 | setLikeCount(likeCount - 1);
32 | }
33 | };
34 |
35 | const commentItem = getComments(info, isReply);
36 |
37 | return (
38 |
39 |
40 |
41 |
46 |
47 |
48 |
{commentItem?.authorDisplayName}
49 |
{commentItem?.textOriginal}
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | {like ? : }
58 |
59 | {likeCount > 0 && likeCount}
60 |
61 |
62 | {dislike ? : }
63 |
64 |
65 |
66 | Reply
67 |
68 |
69 |
70 | {info.replies && (
71 | <>
72 |
setShowReply(!showReply)}
75 | >
76 | {showReply ? (
77 |
78 | Hide replies
79 |
80 |
81 | ) : (
82 |
83 | Show replies
84 |
85 |
86 | )}
87 |
88 | {showReply && info.replies.comments && info.replies.comments.map((item) => (
89 |
90 | ))}
91 | >
92 | )}
93 |
94 |
95 |
96 | );
97 | };
98 |
99 | export default Comments;
100 |
--------------------------------------------------------------------------------
/src/HomePageContainer/WatchPageData/CommentsData.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-hooks/exhaustive-deps */
2 | /* eslint-disable react/prop-types */
3 | /* eslint-disable no-unused-vars */
4 | import React, { useEffect, useState } from 'react'
5 | import { BiMenuAltLeft } from "react-icons/bi";
6 | import { YOUTUBE_COMMENTS_API } from '../../utils/APIList';
7 | import Comments from './Comments';
8 |
9 | const CommentsData = ({ videoId }) => {
10 | const [comments, setComments] = useState([])
11 |
12 | useEffect(() => {
13 | fetchComments()
14 | }, [])
15 |
16 | const fetchComments = async () => {
17 | try {
18 | const data = await fetch(`${YOUTUBE_COMMENTS_API}&order=relevance&videoId=${videoId}`);
19 | const response = await data.json();
20 | if (response?.items) {
21 | setComments(response.items);
22 | } else {
23 | console.error('No comments found in the API response');
24 | }
25 | } catch (error) {
26 | console.error("Error while fetching comments", error);
27 | }
28 | };
29 |
30 | return (
31 |
32 |
33 |
{comments.length} Comments
34 |
35 |
36 | Sort by
37 |
38 |
39 |
40 |
41 | {
42 | comments.map((comment, index) => (
43 |
44 | ))
45 | }
46 |
47 |
48 |
49 | )
50 | }
51 |
52 | export default CommentsData
53 |
--------------------------------------------------------------------------------
/src/HomePageContainer/WatchPageData/LiveChat.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-hooks/exhaustive-deps */
2 | /* eslint-disable no-unused-vars */
3 | import React, { useEffect, useState } from 'react';
4 | import ChatMessage from './ChatMessage';
5 | import { useDispatch, useSelector } from 'react-redux'
6 | import { AvatarGenerator } from 'random-avatar-generator'
7 | import { addMessage } from '../../utils/chatSlice';
8 | import { generateRandomComment, generateRandomName } from '../../utils/helper';
9 | import MyPic from '../../assets/MyPic.jpg';
10 | import { VscSend } from 'react-icons/vsc';
11 | import { FaRegFaceSmile } from "react-icons/fa6";
12 |
13 | const LiveChat = () => {
14 | const [message, setMessage] = useState('');
15 | const [isFocused, setIsFocused] = useState(false);
16 | const [isChatVisible, setIsChatVisible] = useState(true);
17 | const dispatch = useDispatch();
18 | const chatMessage = useSelector((store) => store.chat.messages);
19 |
20 | const InputStyle = `relative md:w-[92.5vw] lg:w-[28.4vw] max-sm:w-[100%] outline-none shadow-gray-400 shadow-2xl py-1 text-sm mx-[9px] border-b-2 ${isFocused ? 'border-blue-700 transition-all duration-300 px-9' : 'border-gray-500'} text-gray-600 bg-gray-100`
21 |
22 | const ChatBoxStyle = 'shadow-gray-300 shadow-2xl md:h-[50vh] md:w-[92.5vw] lg:w-[30vw] lg:h-[58vh] max-sm:h-[50vh] bg-gray-100 flex flex-col-reverse md:overflow-y-scroll overflow-x-hidden chatScroll'
23 |
24 | let count = 0;
25 | const generator = new AvatarGenerator();
26 | generator.generateRandomAvatar();
27 |
28 | useEffect(() => {
29 | const timer = setInterval(() => {
30 | // API Polling
31 | dispatch(
32 | addMessage({
33 | imgUrl: `${generator.generateRandomAvatar(count)
34 | }`,
35 | name: generateRandomName(),
36 | message: generateRandomComment() + "...✨",
37 | })
38 | );
39 | count++;
40 | }, 2000);
41 |
42 | return () => clearInterval(timer);
43 | }, []);
44 |
45 | const handleChat = (e) => {
46 | e.preventDefault();
47 |
48 | dispatch(
49 | addMessage({
50 | imgUrl: MyPic,
51 | name: 'Omkar Karale',
52 | message: message,
53 | })
54 | );
55 | setMessage('');
56 | };
57 |
58 | const toggleChatVisibility = () => {
59 | setIsChatVisible(!isChatVisible);
60 | };
61 |
62 | useEffect(() => {
63 | const handleResize = () => {
64 | setIsChatVisible(window.innerWidth >= 768); // Set to false for screen width less than 768px
65 | };
66 | handleResize();
67 | window.addEventListener('resize', handleResize);
68 |
69 | return () => window.removeEventListener('resize', handleResize);
70 |
71 | }, []);
72 |
73 | return (
74 |
75 |
76 | Live Chat
77 |
78 | {
79 | isChatVisible && (
80 |
81 | {chatMessage.map((chat, index) => (
82 |
83 | ))}
84 |
85 | )
86 | }
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 | Omkar Karale
96 |
97 |
98 |
😊
99 |
100 |
101 |
123 |
124 |
125 |
126 |
128 |
129 | {isChatVisible ? 'Hide Chat' : 'Show Chat'}
130 |
131 |
132 |
133 | );
134 | };
135 |
136 | export default LiveChat;
137 |
--------------------------------------------------------------------------------
/src/HomePageContainer/WatchPageData/RelatedVideoPage.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/prop-types */
2 | /* eslint-disable no-unused-vars */
3 | import React, { useEffect, useState } from 'react'
4 | import RelatedVideos from '../../Components/RelatedVideos'
5 | import { NavLink } from 'react-router-dom'
6 | import { YOUTUBE_VIDEO_API } from '../../utils/APIList'
7 |
8 | const RelatedVideoPage = () => {
9 |
10 | const [videos, setVideos] = useState([])
11 | const [loading, setLoading] = useState(true);
12 |
13 | useEffect(() => {
14 | // Simulate loading data
15 | const timer = setTimeout(() => {
16 | setLoading(false);
17 | }, 2000);
18 |
19 | return () => clearTimeout(timer);
20 | }, []);
21 |
22 | useEffect(() => {
23 | getRelatedVideos()
24 | })
25 |
26 | const getRelatedVideos = async () => {
27 | try {
28 | const data = await fetch(YOUTUBE_VIDEO_API)
29 | if (!data.ok) {
30 | throw new Error(`Failed to fetch videos. Status: ${data.status}`);
31 | }
32 | const response = await data.json()
33 | setVideos(response?.items)
34 | console.log(response?.items)
35 | } catch (error) {
36 | console.error('Error while fetching the videos:', error);
37 | }
38 | }
39 |
40 | return (
41 |
42 | {
43 | videos.map((video) => (
44 |
45 |
46 |
47 | ))
48 | }
49 |
50 | )
51 | }
52 |
53 | export default RelatedVideoPage
54 |
--------------------------------------------------------------------------------
/src/assets/MyPic.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ItsOnkar-dev/YouTube-UI/edfd7bf57595433fa5baa4909da2c07bd0d94be0/src/assets/MyPic.jpg
--------------------------------------------------------------------------------
/src/assets/YouTubeWhite.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ItsOnkar-dev/YouTube-UI/edfd7bf57595433fa5baa4909da2c07bd0d94be0/src/assets/YouTubeWhite.png
--------------------------------------------------------------------------------
/src/assets/YouTube_Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ItsOnkar-dev/YouTube-UI/edfd7bf57595433fa5baa4909da2c07bd0d94be0/src/assets/YouTube_Logo.png
--------------------------------------------------------------------------------
/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | * {
6 | margin: 0;
7 | padding: 0;
8 | box-sizing: border-box;
9 | font-family: "Ubuntu";
10 | }
11 |
12 | body {
13 | overflow-x: hidden;
14 | -webkit-font-smoothing: antialiased;
15 | -moz-osx-font-smoothing: grayscale;
16 | }
17 |
18 | /* body::-webkit-scrollbar-track {
19 | cursor: pointer;
20 | } */
21 |
22 | body::-webkit-scrollbar {
23 | width: 8px;
24 | cursor: pointer;
25 | }
26 |
27 | body::-webkit-scrollbar-thumb {
28 | background: rgb(92, 90, 90);
29 | height: 50px !important;
30 | }
31 |
32 | .sidebar::-webkit-scrollbar-track {
33 | border-radius: 12px;
34 | background-color: white;
35 | }
36 |
37 | .sidebar::-webkit-scrollbar {
38 | width: 0;
39 | background-color: white;
40 | }
41 |
42 | .sidebar:hover::-webkit-scrollbar {
43 | width: 6px;
44 | }
45 |
46 | .sidebar::-webkit-scrollbar-thumb {
47 | background-color: rgb(125, 123, 123);
48 | border-radius: 12px;
49 | }
50 |
51 | .scrollBar::-webkit-scrollbar {
52 | width: 0.8rem;
53 | height: 0.8rem;
54 | }
55 |
56 | .scrollBar::-webkit-scrollbar-thumb {
57 | display: none;
58 | }
59 |
60 | .sidebar-open {
61 | animation: slideIn 0.2s ease-in-out forwards;
62 | }
63 |
64 | @keyframes slideIn {
65 | 0% {
66 | left: -100%;
67 | }
68 | 100% {
69 | left: 0;
70 | }
71 | }
72 |
73 | .blur-effect {
74 | filter: blur(2px);
75 | -webkit-filter: blur(2px);
76 | pointer-events: none;
77 | }
78 |
79 | .chatScroll::-webkit-scrollbar-track {
80 | border-radius: 12px;
81 | }
82 |
83 | .chatScroll::-webkit-scrollbar {
84 | width: 0;
85 | background-color: rgb(231, 227, 227);
86 | }
87 |
88 | .chatScroll:hover::-webkit-scrollbar {
89 | width: 7px;
90 | }
91 |
92 | .chatScroll::-webkit-scrollbar-thumb {
93 | background-color: rgb(135, 133, 133);
94 | border-radius: 12px;
95 | }
96 |
--------------------------------------------------------------------------------
/src/main.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom/client'
3 | import App from './App.jsx'
4 | import './index.css'
5 |
6 | ReactDOM.createRoot(document.getElementById('root')).render(
7 |
8 |
9 | ,
10 | )
11 |
--------------------------------------------------------------------------------
/src/utils/APIList.jsx:
--------------------------------------------------------------------------------
1 | const API_KEY = import.meta.env.VITE_GOOGLE_API_KEY;
2 | // Vite uses the 'import.meta.env' object instead of 'process.env' to access environment variables.
3 |
4 | if (!API_KEY) {
5 | console.error("API key is missing. Make sure you have set up the environment variable.");
6 | }
7 |
8 | export const YOUTUBE_VIDEO_API = "https://youtube.googleapis.com/youtube/v3/videos?part=snippet%2CcontentDetails%2Cstatistics&chart=mostPopular&maxResults=50®ionCode=IN&key=" + API_KEY;
9 |
10 | export const YOUTUBE_SEARCH_SUGGESTION_API =
11 | "https://api.codetabs.com/v1/proxy?quest=" + encodeURIComponent("https://suggestqueries.google.com/complete/search?client=youtube&ds=yt&client=firefox&q=");
12 | // 'https://thingproxy.freeboard.io/fetch/http://suggestqueries.google.com/complete/search?client=youtube&ds=yt&client=firefox&'
13 | // "https://corsproxy.org/?https%3A%2F%2Fsuggestqueries.google.com%2Fcomplete%2Fsearch%3Fclient%3Dfirefox%26ds%3Dyt%26"
14 |
15 | export const CHANNEL_INFO_API = "https://youtube.googleapis.com/youtube/v3/channels?part=snippet%2CcontentDetails%2Cstatistics&key=" + API_KEY
16 |
17 | export const VIDEO_DETAILS_API =
18 | "https://youtube.googleapis.com/youtube/v3/videos?part=snippet%2CcontentDetails%2Cstatistics&key=" +
19 | API_KEY;
20 |
21 | export const YOUTUBE_SEARCH_API = "https://youtube.googleapis.com/youtube/v3/search?part=snippet&maxResults=50&q=&key=" + API_KEY;
22 |
23 | export const YOUTUBE_SEARCHCATEGORY_API = "https://youtube.googleapis.com/youtube/v3/search?part=snippet&maxResults=50&type=video&q=&key=" + API_KEY;
24 |
25 | export const fetchTagsUrl =
26 | "https://youtube.googleapis.com/youtube/v3/videoCategories?part=snippet®ionCode=IN&key=" +
27 | API_KEY;
28 |
29 | export const YOUTUBE_COMMENTS_API = `https://youtube.googleapis.com/youtube/v3/commentThreads?part=snippet%2Creplies&maxResults=50&videoId=&key=${API_KEY}`
30 |
31 |
32 |
--------------------------------------------------------------------------------
/src/utils/appSlice.jsx:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit";
2 |
3 | const appSlice = createSlice({
4 | name: 'app',
5 | initialState: {
6 | isMenuOpen: false,
7 | },
8 | reducers: {
9 | toggleMenu: (state) => {
10 | state.isMenuOpen = !state.isMenuOpen
11 | },
12 | },
13 | })
14 |
15 | export const { toggleMenu } = appSlice.actions
16 | export default appSlice.reducer
--------------------------------------------------------------------------------
/src/utils/chatSlice.jsx:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit";
2 | import { LIVE_CHAT_COUNT } from "./constants";
3 |
4 | const chatSlice = createSlice({
5 | name: 'chat',
6 | initialState: {
7 | messages: []
8 | },
9 | reducers: {
10 | addMessage: (state, action) => {
11 | state.messages.splice(LIVE_CHAT_COUNT, 1)
12 | state.messages.unshift(action.payload)
13 | // updates the state by pushing the payload property of the action object into the messages array in the state.
14 | },
15 | },
16 | })
17 | // Overall, this reducer function handles the action of adding a new message to the state. When dispatched, it appends the new message to the existing list of messages in the state.
18 |
19 | export const { addMessage } = chatSlice.actions
20 | export default chatSlice.reducer
--------------------------------------------------------------------------------
/src/utils/constants.jsx:
--------------------------------------------------------------------------------
1 | import { MdHomeFilled } from 'react-icons/md';
2 | import { MdSubscriptions } from 'react-icons/md';
3 | import { SiYoutubeshorts } from 'react-icons/si';
4 | import { IoMdTrendingUp } from "react-icons/io";
5 | import { HiShoppingBag } from "react-icons/hi2";
6 | import { MdMusicNote } from "react-icons/md";
7 | import { BiSolidMoviePlay } from "react-icons/bi";
8 | import { RiLiveFill } from "react-icons/ri";
9 | import { SiYoutubegaming } from "react-icons/si";
10 | import { PiStudentFill } from "react-icons/pi";
11 | import { SiShopify } from "react-icons/si";
12 | import { GiTrophy } from "react-icons/gi";
13 | import { SiGooglenews } from "react-icons/si";
14 | import { FaYoutube } from "react-icons/fa";
15 | import { SiYoutubestudio } from "react-icons/si";
16 | import { SiYoutubemusic } from "react-icons/si";
17 | import { TfiYoutube } from "react-icons/tfi";
18 | import { IoSettingsSharp } from "react-icons/io5";
19 | import { RiChatHistoryFill } from "react-icons/ri";
20 | import { IoMdHelpCircle } from "react-icons/io";
21 | import { MdFeedback } from "react-icons/md";
22 |
23 | export const Home = [
24 | {
25 | icon: ,
26 | name: "Home"
27 | },
28 | {
29 | icon: ,
30 | name: "Subscriptions"
31 | },
32 | {
33 | icon: ,
34 | name: "Shorts"
35 | },
36 | ]
37 |
38 | export const Subscriptions = [
39 | {
40 | src: "https://yt3.googleusercontent.com/eu051krrRNQMMi5h6ynfnvhFJzxzSKulJQ42g5v72MQ9Bvv8KdpNIa6yM-0iGpnDgSF0itAD=s176-c-k-c0x00ffffff-no-rj",
41 | profileId: "UCpEhnqL0y41EpW2TvWAHD7Q",
42 | fullname: "SET India",
43 | },
44 | {
45 | src: "https://yt3.googleusercontent.com/ytc/AOPolaSj48pypV9ilqNUztYjQ8Q760NYCAw3w1LwoWbJYQ=s176-c-k-c0x00ffffff-no-rj",
46 | profileId: "UC3N9i_KvKZYP4F84FPIzgPQ",
47 | fullname: "Akshay Saini",
48 | },
49 | {
50 | src: "https://yt3.googleusercontent.com/XE7Iq8jvJ07ptMc-HxZR_V-2XgXCb0i06i4E_dypl7xSR655WXaQeglfqNuEeuwH3oM9RKVodQ=s176-c-k-c0x00ffffff-no-rj",
51 | profileId: "UCq-Fj5jknLsUf-MWSy4_brA",
52 | fullname: "T-Series",
53 | },
54 | {
55 | src: "https://yt3.googleusercontent.com/ytc/AIf8zZTDkajQxPa4sjDOW-c3er1szXkSAO-H9TiF4-8u_Q=s176-c-k-c0x00ffffff-no-rj",
56 | profileId: "UC8butISFwT-Wl7EV0hUK0BQ",
57 | fullname: "freeCodeCamp",
58 | },
59 | {
60 | src: "https://yt3.googleusercontent.com/l_ZIXrVEQcHTBTsmpt2CFiWJF9_0hwB3rngr1_lxozZ3Lz58Ij5TcDFOp2TYlioU2gI9RlyExw=s176-c-k-c0x00ffffff-no-rj",
61 | profileId: "UCqwUrj10mAEsqezcItqvwEw",
62 | fullname: "BB Ki Vines",
63 | },
64 | {
65 | src: "https://yt3.googleusercontent.com/ytc/AIf8zZTPIL9EkFafJj8HqxgEj9avK-OBX-U6p0V9NrEwSw=s176-c-k-c0x00ffffff-no-rj",
66 | profileId: "UC0RhatS1pyxInC00YKjjBqQ",
67 | fullname: "GeeksforGeeks",
68 | },
69 | {
70 | src: "https://yt3.googleusercontent.com/bFpwiiOB_NLCVsIcVQ9UcwBjb1RzipnMmtNfLSWpeIaHboyGkBCq4KBitmovRbStk9WvIWIZOyo=s176-c-k-c0x00ffffff-no-rj",
71 | profileId: "UCIEv3lZ_tNXHzL3ox-_uUGQ",
72 | fullname: "Gordon Ramsay",
73 | },
74 | ];
75 |
76 | export const Explore = [
77 | {
78 | icon: ,
79 | name: "Trending"
80 | },
81 | {
82 | icon: ,
83 | name: "Shopping"
84 | },
85 | {
86 | icon: ,
87 | name: "Music"
88 | },
89 | {
90 | icon: ,
91 | name: "Movies"
92 | },
93 | {
94 | icon: ,
95 | name: "Live"
96 | },
97 | {
98 | icon: ,
99 | name: "Sports"
100 | },
101 | {
102 | icon: ,
103 | name: "News"
104 | },
105 | {
106 | icon: ,
107 | name: "Gaming"
108 | },
109 | {
110 | icon: ,
111 | name: "Learning"
112 | },
113 | {
114 | icon: ,
115 | name: "Fashion & Beauty"
116 | },
117 | ]
118 |
119 |
120 | export const Premium = [
121 | {
122 | icon: ,
123 | name: "YouTube Premium"
124 | },
125 | {
126 | icon: ,
127 | name: "YouTube Studio"
128 | },
129 | {
130 | icon: ,
131 | name: "YouTube Music"
132 | },
133 | {
134 | icon: ,
135 | name: "YouTube Kids"
136 | },
137 | ]
138 |
139 | export const Setting = [
140 | {
141 | icon: ,
142 | name: "Settings"
143 | },
144 | {
145 | icon: ,
146 | name: "Report History"
147 | },
148 | {
149 | icon: ,
150 | name: "Help"
151 | },
152 | {
153 | icon: ,
154 | name: "Send Feedback"
155 | },
156 | ]
157 |
158 | export const TagNames = [
159 | "All",
160 | "Gaming",
161 | "Mixes",
162 | "React JS",
163 | "Freecodecamp",
164 | "Fortnite",
165 | "Namaste JavaScript",
166 | "Rohit Sharma",
167 | "Comedy",
168 | "T-series",
169 | "Thrillers",
170 | "Indian Premier League",
171 | "Programming",
172 | "Dramedy",
173 | "Cricket",
174 | "Football",
175 | "News",
176 | "JavaScript",
177 | "Prodcasts",
178 | "Comedy Clubs",
179 | "Data Structures",
180 | ];
181 |
182 | export const LIVE_CHAT_COUNT = 25;
183 |
184 | export const formatTime = (time) => {
185 | let currentDate = new Date();
186 | let inputDate = new Date(time);
187 | let differenceInMilliseconds = currentDate - inputDate;
188 | let differenceInDays = differenceInMilliseconds / (24 * 60 * 60 * 1000);
189 |
190 | if (differenceInDays > 30) {
191 | let monthsAgo = Math.ceil(differenceInDays / 30.44);
192 |
193 | if (monthsAgo === 1) {
194 | return {monthsAgo} Month ago ;
195 | } else {
196 | return {monthsAgo} Months ago ;
197 | }
198 | } else {
199 | let daysAgo = Math.ceil(differenceInDays);
200 |
201 | if (daysAgo === 1) {
202 | return {daysAgo} day ago ;
203 | } else {
204 | return {daysAgo} days ago ;
205 | }
206 | }
207 | };
208 |
209 |
210 | export const timeDuration = (isoDuration) => {
211 | let hours = 0;
212 | let minutes = 0;
213 | let seconds = 0;
214 | let minutesIndex;
215 | let hoursIndex;
216 |
217 | if (typeof isoDuration !== 'string' || !isoDuration.trim()) {
218 | return '00:00'; // Default value when isoDuration is not valid
219 | }
220 |
221 | if (isoDuration.includes("H")) {
222 | hoursIndex = isoDuration.indexOf("H");
223 | hours = parseInt(isoDuration.slice(2, hoursIndex));
224 | }
225 |
226 | if (!hoursIndex) {
227 | hoursIndex = 1;
228 | }
229 |
230 | if (isoDuration.includes("M")) {
231 | minutesIndex = isoDuration.indexOf("M");
232 | minutes = parseInt(isoDuration.slice(hoursIndex + 1, minutesIndex));
233 | }
234 | if (!minutesIndex) {
235 | minutesIndex = 1;
236 | }
237 | const secondsIndex = isoDuration.indexOf("S");
238 | if (secondsIndex !== -1) {
239 | seconds = parseInt(isoDuration.slice(minutesIndex + 1, secondsIndex));
240 | }
241 |
242 | // Format the time
243 | if (!hours) {
244 | const formattedTime = `${padZero(minutes)}:${padZero(seconds)}`;
245 | return formattedTime;
246 | } else {
247 | const formattedTime = `${padZero(hours)}:${padZero(minutes)}:${padZero(
248 | seconds
249 | )}`;
250 | return formattedTime;
251 | }
252 | }
253 |
254 | // Function to pad single-digit numbers with leading zeros
255 | function padZero(num) {
256 | return num < 10 ? `0${num}` : num;
257 | }
258 |
259 | export const formatNumberWithSuffix = (x) => {
260 | if (x === undefined) {
261 | return 0;
262 | }
263 | if (x >= 1000000) {
264 | return (x / 1000000).toFixed(1) + "M";
265 | } else if (x >= 1000) {
266 | return (x / 1000).toFixed(1) + "K";
267 | }
268 | return x.toString();
269 | };
270 |
--------------------------------------------------------------------------------
/src/utils/helper.jsx:
--------------------------------------------------------------------------------
1 | var nameList = [
2 | "Josie Suarez",
3 | "Soren Golden",
4 | "Giuliana Maldonado",
5 | "Javier Aguirre",
6 | "Ariah Ortiz",
7 | "Landon Cabrera",
8 | "Daleyza Huff",
9 | "Finnley Fitzpatrick",
10 | "Annabella Randall",
11 | "Trenton Barry",
12 | "Waverly Ahmed",
13 | "Harry Watkins",
14 | "Lola Campbell",
15 | "Christopher Cain",
16 | "Kendra Jones",
17 | "William May",
18 | "Adriana Sanders",
19 | "Jose Franklin",
20 | "Angela Villalobos",
21 | "Reuben Guerra",
22 | "Edith Nash",
23 | "Chandler Browning",
24 | "Princess Sweeney",
25 | "Nixon Santana",
26 | "Myra Weeks",
27 | "Anders Parsons",
28 | "Maia Robbins",
29 | "Finnegan Baxter",
30 | "Lara Truong",
31 | "Ayan Conner",
32 | "Alondra Knox",
33 | "Valentin Cain",
34 | "Kendra Person",
35 | "Moses Pineda",
36 | "Nola Brady",
37 | "Reed Anthony",
38 | "Macy Lim",
39 | "Cal Waller",
40 | "Whitley Barajas",
41 | "Brennan Roberts",
42 | "Paisley Golden",
43 | "Ryann Valdez",
44 | "Kyler Morgan",
45 | "Delilah Kirk",
46 | "Alessandro Orr",
47 | "Alaiya Vasquez",
48 | "Rowan Ramos",
49 | "Alice Holloway",
50 | "Sutton Cabrera",
51 | "Daleyza Kaur",
52 | "Augustine Parsons",
53 | "Maia Gregory",
54 | "Travis Montgomery",
55 | "Evangeline Warner",
56 | "Jaxton McGee",
57 | "Kayleigh Velez",
58 | "Kareem Silva",
59 | "Lucia Delarosa",
60 | "Osiris Hernandez",
61 | "Camila Lim",
62 | "Cal Potter",
63 | "Rory Townsend",
64 | "Alexis Ortega",
65 | "Lilah Clay",
66 | "Yosef Heath",
67 | "Amani Henderson",
68 | "Beau Collins",
69 | "Kinsley Davenport",
70 | "Dariel Bartlett",
71 | "Aubrielle Hayden",
72 | "Leroy Ponce",
73 | "Aileen Barrera",
74 | "Makai Grant",
75 | "Alaina Harrell",
76 | "Nelson Madden",
77 | "Violette Winters",
78 | "Deandre Hess",
79 | "Kaliyah Gibbs",
80 | "Deacon Snow",
81 | "Alexia Garrison",
82 | "Noe Washington",
83 | "Valerie McDaniel",
84 | "Major Weber",
85 | "Alayah Harris",
86 | "Samuel McDowell",
87 | "Rayna Thomas",
88 | "Logan Sexton",
89 | "Ellen Conner",
90 | "Phillip Norman",
91 | "Malani Harrison",
92 | "Gavin Grant",
93 | "Alaina Hess",
94 | "Lawrence Phan",
95 | "Elsa Woodward",
96 | "Jeremias Jennings",
97 | "Palmer Hoffman",
98 | "Steven Hodges",
99 | "Eve Watson",
100 | "Greyson McDaniel",
101 | "Dahlia Greer",
102 | "Koda Nash",
103 | "Novah Lynch",
104 | "Zane Rivera",
105 | "Lillian Hodges",
106 | "Alonzo Lara",
107 | "Heidi Taylor",
108 | "Jackson Knox",
109 | "Kallie Ruiz",
110 | "Austin Guerra",
111 | "Edith McCullough",
112 | "Briar Gross",
113 | "Angel Watts",
114 | "Dakota McClure",
115 | "Estella Warren",
116 | "Abel Flores",
117 | "Emilia Norris",
118 | "Cairo Poole",
119 | "Bonnie Medina",
120 | "George Murphy",
121 | "Bella Wood",
122 | "Carson Bradley",
123 | "Vanessa Richards",
124 | "Holden Howard",
125 | "Sophie Ware",
126 | "Tadeo Wallace",
127 | "Arianna Palmer",
128 | "Theo Conner",
129 | "Alondra Bush",
130 | "Tyson Clay",
131 | "Aliana Yang",
132 | "Malcolm Foley",
133 | "Zaylee Arias",
134 | "Alec Herrera",
135 | "Ximena Best",
136 | "Harlem Rowe",
137 | "Matilda Kerr",
138 | "Louie Jordan",
139 | "Adalynn Arroyo",
140 | "Alberto Knox",
141 | "Kallie Schroeder",
142 | "Izaiah Olsen",
143 | "Oaklyn Carson",
144 | "Ares Burke",
145 | "Vera Estes",
146 | "Hakeem Middleton",
147 | "Madalyn Gillespie",
148 | "Forest Farrell",
149 | "Kassidy Solis",
150 | "Ronin Cortez",
151 | "Haven Snyder",
152 | "Thiago Buckley",
153 | ];
154 |
155 | const comments = [
156 | "Hey everyone, thanks for joining the chat!",
157 | "Let me know if you have any questions?",
158 | "I'm learning so much from this stream.",
159 | "Thanks for sharing your knowledge!",
160 | "I'm so glad I found this stream.",
161 | "Thanks for making this stream so interactive.",
162 | "I'm so glad I subscribed to your channel.",
163 | "I always enjoy your streams.",
164 | "Thanks for the giveaway, I'm so excited to win!",
165 | "I'm really looking forward to your next stream!",
166 | "So helpful!",
167 | "Amazing!",
168 | "Love it!",
169 | "Spot on!",
170 | "Exactly what I needed!",
171 | "Hey, great stream! I'm really enjoying it.",
172 | "Thanks for answering my question!",
173 | "I love your content! Keep up the great work.",
174 | "This is so interesting! I'm learning a lot.",
175 | "I'm having a lot of fun! Thanks for streaming.",
176 | "You're so funny! I'm laughing so hard.",
177 | "You're so talented! I'm amazed.",
178 | "I'm so glad I found your channel.",
179 | "I'm really looking forward to your next stream!",
180 | "Thanks for the giveaway! I'm so excited to win.",
181 | "I'm having a great time in the chat!",
182 | "I'm so glad to be a part of this community.",
183 | "Thanks for making me feel welcome.",
184 | "I'm so happy to be here.",
185 | "I'm having a blast!",
186 | "This is the best stream ever!",
187 | "Brilliant!",
188 | "You nailed it!",
189 | "Your content always brightens my day!",
190 | "I'm hooked on your channel. Can't stop watching!",
191 | "Big fan here! Keep inspiring us.",
192 | "I've been binge-watching your videos all week!",
193 | "Your positivity is contagious.",
194 | "Just joined the stream. What did I miss?",
195 | "I'm amazed by your creativity.",
196 | "Your channel is my go-to for inspiration.",
197 | "Shoutout to the amazing community here!",
198 | "I've learned so much from your videos. Thank you!",
199 | "Your energy is infectious. Love tuning in.",
200 | "Let's keep the chat positive and respectful!",
201 | "I've shared your channel with all my friends.",
202 | "You're making a difference with your content.",
203 | "I appreciate the effort you put into making it.",
204 | "Can't wait to see what's next on your channel!",
205 | "Sending virtual hugs to everyone in the chat!",
206 | "Short and sweet, love it!",
207 | "This is gold!",
208 | "Perfect!",
209 | "Thanks a bunch!",
210 | "Mind blown!",
211 | "Great content! Keep it up!",
212 | "I love this channel!",
213 | "Awesome stream! I'm enjoying it a lot.",
214 | "Inspiring content.Keep it coming!",
215 | "Your videos motivate me daily.",
216 | "You're a positive force online.",
217 | "Grateful for your uplifting messages.",
218 | "Your channel spreads joy effortlessly.",
219 | "Impressive content. Well done!",
220 | "You bring smiles to many faces.",
221 | "Your channel brightens my day.",
222 | "You're making a difference, truly.",
223 | "Your authenticity shines through.",
224 | "Refreshing content. Love it!",
225 | "You're a ray of sunshine.",
226 | "Positive vibes all the way!",
227 | "You're a role model for many.",
228 | "Thanks for being so genuine.",
229 | "Love your creative approach.",
230 | "Your passion is inspiring.",
231 | "Your positivity is contagious.",
232 | "You make the internet better.",
233 | "You're a source of inspiration.",
234 | "Your videos are impactful.",
235 | "You make learning enjoyable.",
236 | "Appreciate your valuable insights.",
237 | "You're a true content creator.",
238 | "Your dedication is admirable.",
239 | "Your channel brings joy to many.",
240 | "Thanks for sharing your wisdom.",
241 | "Your energy is infectious!",
242 | "You create smiles effortlessly.",
243 | "Your presence is uplifting.",
244 | "Hi everyone! How's everyone doing today?",
245 | "This is so interesting! I'm learning a lot.",
246 | "Shoutout to the amazing moderators!",
247 | "Can't wait for the next video!",
248 | "Greetings from [Your Location]!",
249 | "I just subscribed! Your channel is amazing.",
250 | "Thanks for the shoutout! You made my day.",
251 | "Can't get enough!",
252 | "Incredible!",
253 | "You rock!",
254 | "Genius!",
255 | "Spectacular!",
256 | "Top-notch!",
257 | "You make learning enjoyable, thank you for that!",
258 | "Your information and content is excellent",
259 |
260 | ]
261 |
262 | export const truncateText = (text, maxLength) => {
263 | if (text.length <= maxLength) {
264 | return text;
265 | } else {
266 | return text.slice(0, maxLength) + "...";
267 | }
268 | };
269 |
270 | export const generateRandomName = () => {
271 | var finalName = nameList[Math.floor(Math.random() * nameList.length)];
272 | return finalName
273 | }
274 |
275 | export const generateRandomComment = () => {
276 | const randomComment = comments[Math.floor(Math.random() * comments.length)];
277 | return randomComment;
278 | }
279 |
280 | // Generating Random chat for Live Chat
281 | // export function generateRandomComment(length) {
282 | // let result = "";
283 | // const characters =
284 | // "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
285 | // const charactersLength = characters.length;
286 | // let counter = 0;
287 | // while (counter < length) {
288 | // result += characters.charAt(Math.floor(Math.random() * charactersLength));
289 | // counter += 1;
290 | // }
291 | // return result;
292 | // }
--------------------------------------------------------------------------------
/src/utils/searchSlice.jsx:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit";
2 |
3 | const searchSlice = createSlice({
4 | name: 'search',
5 | initialState: {
6 |
7 | },
8 | reducers: {
9 | cacheResults: (state, action) => {
10 | // Using Object.assign to merge the current state with the payload just like spread in ES6
11 | // state = {...state, ...action.payload}
12 | state = Object.assign(state, action.payload)
13 | }
14 | }
15 | })
16 |
17 | export const { cacheResults } = searchSlice.actions
18 | export default searchSlice.reducer
--------------------------------------------------------------------------------
/src/utils/store.jsx:
--------------------------------------------------------------------------------
1 | import { configureStore } from '@reduxjs/toolkit';
2 | import appSlice from './appSlice';
3 | import searchSlice from './searchSlice';
4 | import chatSlice from './chatSlice';
5 |
6 | const store = configureStore({
7 | reducer: {
8 | app: appSlice,
9 | search: searchSlice,
10 | chat: chatSlice,
11 | },
12 | })
13 |
14 | export default store
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-undef */
2 | /** @type {import('tailwindcss').Config} */
3 | export default {
4 | content: [
5 | './index.html',
6 | './src/**/*.{js,ts,jsx,tsx}'
7 | ],
8 | theme: {
9 | extend: {
10 |
11 | }
12 | },
13 | plugins: [ require('tailwind-scrollbar')]
14 | }
15 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 | import dotenv from 'dotenv'
4 |
5 | dotenv.config()
6 |
7 | // https://vitejs.dev/config/
8 | export default defineConfig({
9 | plugins: [react()]
10 | })
11 |
--------------------------------------------------------------------------------