diff --git a/frontend/public/images/category_thumbnails/apex_legends.webp b/frontend/public/images/category_thumbnails/apex_legends.webp new file mode 100644 index 0000000..e3b18a2 Binary files /dev/null and b/frontend/public/images/category_thumbnails/apex_legends.webp differ diff --git a/frontend/public/images/category_thumbnails/call_of_duty.webp b/frontend/public/images/category_thumbnails/call_of_duty.webp new file mode 100644 index 0000000..6f7d715 Binary files /dev/null and b/frontend/public/images/category_thumbnails/call_of_duty.webp differ diff --git a/frontend/public/images/category_thumbnails/counter-strike_2.webp b/frontend/public/images/category_thumbnails/counter-strike_2.webp new file mode 100644 index 0000000..6aff47b Binary files /dev/null and b/frontend/public/images/category_thumbnails/counter-strike_2.webp differ diff --git a/frontend/public/images/category_thumbnails/dota_2.webp b/frontend/public/images/category_thumbnails/dota_2.webp new file mode 100644 index 0000000..d1d093e Binary files /dev/null and b/frontend/public/images/category_thumbnails/dota_2.webp differ diff --git a/frontend/public/images/category_thumbnails/elden_ring.webp b/frontend/public/images/category_thumbnails/elden_ring.webp new file mode 100644 index 0000000..caa0d3b Binary files /dev/null and b/frontend/public/images/category_thumbnails/elden_ring.webp differ diff --git a/frontend/public/images/category_thumbnails/grand_theft_auto_v.webp b/frontend/public/images/category_thumbnails/grand_theft_auto_v.webp new file mode 100644 index 0000000..33b97a9 Binary files /dev/null and b/frontend/public/images/category_thumbnails/grand_theft_auto_v.webp differ diff --git a/frontend/public/images/category_thumbnails/minecraft.webp b/frontend/public/images/category_thumbnails/minecraft.webp new file mode 100644 index 0000000..4d50d3b Binary files /dev/null and b/frontend/public/images/category_thumbnails/minecraft.webp differ diff --git a/frontend/public/images/category_thumbnails/the_legend_of_zelda_breath_of_the_wild.webp b/frontend/public/images/category_thumbnails/the_legend_of_zelda_breath_of_the_wild.webp new file mode 100644 index 0000000..d009a1d Binary files /dev/null and b/frontend/public/images/category_thumbnails/the_legend_of_zelda_breath_of_the_wild.webp differ diff --git a/frontend/public/images/category_thumbnails/valorant.webp b/frontend/public/images/category_thumbnails/valorant.webp new file mode 100644 index 0000000..793233d Binary files /dev/null and b/frontend/public/images/category_thumbnails/valorant.webp differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4f90c7d..b2d0c64 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -62,7 +62,7 @@ function App() { element={} > } > }> diff --git a/frontend/src/components/Layout/ListItem.tsx b/frontend/src/components/Layout/ListItem.tsx index 01c406d..f56e6e4 100644 --- a/frontend/src/components/Layout/ListItem.tsx +++ b/frontend/src/components/Layout/ListItem.tsx @@ -65,7 +65,7 @@ const ListItem: React.FC = ({ )}
-

{title}

+

{title}

{type === "stream" &&

{username}

} {type === "stream" && (

{streamCategory}

diff --git a/frontend/src/components/Layout/ListRow.tsx b/frontend/src/components/Layout/ListRow.tsx index 1f3e6cb..22af965 100644 --- a/frontend/src/components/Layout/ListRow.tsx +++ b/frontend/src/components/Layout/ListRow.tsx @@ -1,4 +1,4 @@ -import React, { useRef } from "react"; +import React, { forwardRef, useImperativeHandle, useRef, useState } from "react"; import { ArrowLeft as ArrowLeftIcon, ArrowRight as ArrowRightIcon, @@ -22,124 +22,120 @@ interface ListRowProps { children?: React.ReactNode; } -// Row of entries -const ListRow: React.FC = ({ - variant = "default", - type, - title = "", - description = "", - items, - wrap = false, - titleClickable = false, - onClick, - extraClasses = "", - itemExtraClasses = "", - amountForScroll = 4, - children, -}) => { - const slider = useRef(null); - const scrollAmount = window.innerWidth * 0.3; - const navigate = useNavigate(); +const ListRow = forwardRef<{ addMoreItems: (newItems: ListItemProps[]) => void }, ListRowProps>( + ({ variant, type, title = "", description = "", items, wrap, onClick, titleClickable, extraClasses = "", itemExtraClasses = "", amountForScroll, children }, ref) => { + const [currentItems, setCurrentItems] = useState(items); + const slider = useRef(null); + const scrollAmount = window.innerWidth * 0.3; + const navigate = useNavigate(); - const slideRight = () => { - if (!wrap && slider.current) { - slider.current.scrollBy({ left: +scrollAmount, behavior: "smooth" }); - } - }; + const addMoreItems = (newItems: ListItemProps[]) => { + setCurrentItems((prevItems) => [...prevItems, ...newItems]); + }; - const slideLeft = () => { - if (!wrap && slider.current) { - slider.current.scrollBy({ left: -scrollAmount, behavior: "smooth" }); - } - }; + useImperativeHandle(ref, () => ({ + addMoreItems, + })); - const handleTitleClick = (type: string) => { - switch (type) { - case "stream": - break; - case "category": - navigate("/categories"); - break; - case "user": - break; - default: - break; - } - }; + const slideRight = () => { + if (!wrap && slider.current) { + slider.current.scrollBy({ left: +scrollAmount, behavior: "smooth" }); + } + }; - return ( -
+ const slideLeft = () => { + if (!wrap && slider.current) { + slider.current.scrollBy({ left: -scrollAmount, behavior: "smooth" }); + } + }; + + const handleTitleClick = (type: string) => { + switch (type) { + case "stream": + break; + case "category": + navigate("/categories"); + break; + case "user": + break; + default: + break; + } + }; + + return (
-

handleTitleClick(type) : undefined} - > - {title} -

-

{description}

-
- -
- {!wrap && items.length > amountForScroll && ( - <> - - - - )} -
- {items.map((item) => ( - - (item.type === "stream" || item.type === "user") && - item.username - ? onClick?.(item.username) - : onClick?.(item.title) - } - extraClasses={`${itemExtraClasses} min-w-[20vw]`} - /> - ))} +

handleTitleClick(type) : undefined} + > + {title} +

+

{description}

+ +
+ {!wrap && currentItems.length > (amountForScroll || 0) && ( + <> + + + + )} + +
+ {currentItems.map((item) => ( + + (item.type === "stream" || item.type === "user") && + item.username + ? onClick?.(item.username) + : onClick?.(item.title) + } + extraClasses={`${itemExtraClasses} min-w-[20vw] max-w-[20vw]`} + /> + ))} +
+
+ {children}
+ ); + } +); - {children} -
- ); -}; - -export default ListRow; +export default ListRow; \ No newline at end of file diff --git a/frontend/src/components/Stream/ChatPanel.tsx b/frontend/src/components/Stream/ChatPanel.tsx index 21f2ea7..a9140f2 100644 --- a/frontend/src/components/Stream/ChatPanel.tsx +++ b/frontend/src/components/Stream/ChatPanel.tsx @@ -21,7 +21,7 @@ const ChatPanel: React.FC = ({ streamId, onViewerCountChange, }) => { - const { isLoggedIn, username, userId} = useAuth(); + const { isLoggedIn, username, userId } = useAuth(); const { showAuthModal, setShowAuthModal } = useAuthModal(); const { socket, isConnected } = useSocket(); const [messages, setMessages] = useState([]); @@ -40,10 +40,11 @@ const ChatPanel: React.FC = ({ // Handle beforeunload event const handleBeforeUnload = () => { - socket.emit("leave", { + socket.emit("leave", { userId: userId ? userId : null, username: username ? username : "Guest", - stream_id: streamId, }); + stream_id: streamId, + }); socket.disconnect(); }; @@ -135,13 +136,12 @@ const ChatPanel: React.FC = ({ {messages.map((msg, index) => (
{/* User avatar with image */}
msg.chatter_username === username ? null @@ -160,11 +160,10 @@ const ChatPanel: React.FC = ({
{/* Username */} msg.chatter_username === username ? null @@ -181,8 +180,8 @@ const ChatPanel: React.FC = ({
{/* Time sent */} -
- {new Date(msg.time_sent).toLocaleTimeString()} +
+ {new Date(msg.time_sent).toLocaleTimeString('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' })}
))} diff --git a/frontend/src/hooks/useCategoryFollow.ts b/frontend/src/hooks/useCategoryFollow.ts new file mode 100644 index 0000000..bce79db --- /dev/null +++ b/frontend/src/hooks/useCategoryFollow.ts @@ -0,0 +1,63 @@ +import { useState } from 'react'; +import { useAuth } from '../context/AuthContext'; + +export function useCategoryFollow() { + const [isCategoryFollowing, setIsCategoryFollowing] = useState(false); + const { isLoggedIn } = useAuth(); + + const checkCategoryFollowStatus = async (categoryName: string) => { + try { + const response = await fetch(`/api/user/category/following/${categoryName}`); + const data = await response.json(); + setIsCategoryFollowing(data.following); + } catch (error) { + console.error("Error checking follow status:", error); + } + }; + + const followCategory = async (categoryName: number) => { + if (!isLoggedIn) { + return; + } + + try { + const response = await fetch(`/api/user/category/follow/${categoryName}`); + const data = await response.json(); + if (data.success) { + console.log(`Now following category ${categoryName}`); + setIsCategoryFollowing(true); + } else { + console.error(`Failed to follow category ${categoryName}`); + } + } catch (error) { + console.error("Error following category:", error); + } + }; + + const unfollowCategory = async (categoryName: number) => { + if (!isLoggedIn) { + return; + } + + try { + const response = await fetch(`/api/user/category/unfollow/${categoryName}`); + const data = await response.json(); + if (data.success) { + console.log(`Unfollowed category ${categoryName}`); + setIsCategoryFollowing(false); + } else { + console.error(`Failed to unfollow category ${categoryName}`); + } + } catch (error) { + console.error("Error unfollowing category:", error); + } + }; + + return { + isCategoryFollowing, + setIsCategoryFollowing, + checkCategoryFollowStatus, + followCategory, + unfollowCategory + }; +} diff --git a/frontend/src/pages/AllCategoriesPage.tsx b/frontend/src/pages/AllCategoriesPage.tsx index 45f2a42..257aed0 100644 --- a/frontend/src/pages/AllCategoriesPage.tsx +++ b/frontend/src/pages/AllCategoriesPage.tsx @@ -1,58 +1,82 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useRef } from "react"; import { useNavigate } from "react-router-dom"; import ListRow from "../components/Layout/ListRow"; -import { useCategories } from "../context/ContentContext"; import DynamicPageContent from "../components/Layout/DynamicPageContent"; import { fetchContentOnScroll } from "../hooks/fetchContentOnScroll"; +interface categoryData { + type: "category"; + id: number; + title: string; + viewers: number; + thumbnail: string; +} const AllCategoriesPage: React.FC = () => { - const { categories, setCategories } = useCategories(); + const [categories, setCategories] = useState([]); const navigate = useNavigate(); const [categoryOffset, setCategoryOffset] = useState(0); const [noCategories, setNoCategories] = useState(12); const [hasMoreData, setHasMoreData] = useState(true); + + const listRowRef = useRef(null); + const isLoading = useRef(false); + + const fetchCategories = async () => { + // If already loading, skip this fetch + if (isLoading.current) return; + + isLoading.current = true; + + try { + const response = await fetch(`/api/categories/popular/${noCategories}/${categoryOffset}`); + if (!response.ok) { + throw new Error("Failed to fetch categories"); + } + const data = await response.json(); + + if (data.length === 0) { + setHasMoreData(false); + return []; + } + + setCategoryOffset(prev => prev + data.length); + + const processedCategories = data.map((category: any) => ({ + type: "category" as const, + id: category.category_id, + title: category.category_name, + viewers: category.num_viewers, + thumbnail: `/images/category_thumbnails/${category.category_name + .toLowerCase() + .replace(/ /g, "_")}.webp`, + })); + + setCategories(prev => [...prev, ...processedCategories]); + return processedCategories; + } catch (error) { + console.error("Error fetching categories:", error); + return []; + } finally { + isLoading.current = false; + } + }; useEffect(() => { - const fetchCategories = async () => { - try { - const response = await fetch(`/api/categories/popular/${noCategories}/${categoryOffset}`); - if (!response.ok) { - throw new Error("Failed to fetch categories"); - } - const data = await response.json(); - // Adds to offset once data is returned - if (data.length > 0) { - setCategoryOffset(prev => prev + data.length); - } else { - setHasMoreData(false); - } - - // Transform the data to match CategoryItem interface - const processedCategories = data.map((category: any) => ({ - type: "category" as const, - id: category.category_id, - title: category.category_name, - viewers: category.num_viewers, - thumbnail: `/images/category_thumbnails/${category.category_name - .toLowerCase() - .replace(/ /g, "_")}.webp`, - })); - - setCategories(processedCategories); - } catch (error) { - console.error("Error fetching categories:", error); - } - }; - fetchCategories(); - }, [setCategories]); + }, []); - const logOnScroll = () => { - console.log("hi") + const loadOnScroll = async () => { + if (hasMoreData && listRowRef.current) { + const newCategories = await fetchCategories(); + if (newCategories?.length > 0) { + listRowRef.current.addMoreItems(newCategories); + } + } }; - fetchContentOnScroll(logOnScroll,hasMoreData) - if (!categories.length) { + fetchContentOnScroll(loadOnScroll, hasMoreData); + + if (hasMoreData && !categories.length) { return (
Loading... @@ -71,16 +95,16 @@ const AllCategoriesPage: React.FC = () => { style={{ backgroundImage: "url(/images/background-pattern.svg)" }} > ); }; -export default AllCategoriesPage; +export default AllCategoriesPage; \ No newline at end of file diff --git a/frontend/src/pages/CategoryPage.tsx b/frontend/src/pages/CategoryPage.tsx index 88b50cc..4464f9c 100644 --- a/frontend/src/pages/CategoryPage.tsx +++ b/frontend/src/pages/CategoryPage.tsx @@ -1,7 +1,11 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { useParams } from "react-router-dom"; import ListRow from "../components/Layout/ListRow"; import DynamicPageContent from "../components/Layout/DynamicPageContent"; +import { fetchContentOnScroll } from "../hooks/fetchContentOnScroll"; +import Button from "../components/Input/Button"; +import { useAuth } from "../context/AuthContext"; +import { useCategoryFollow } from "../hooks/useCategoryFollow"; interface StreamData { type: "stream"; @@ -14,48 +18,84 @@ interface StreamData { } const CategoryPage: React.FC = () => { - const { category_name } = useParams<{ category_name: string }>(); + const { categoryName } = useParams<{ categoryName: string }>(); const [streams, setStreams] = useState([]); - const [isLoading, setIsLoading] = useState(true); + const listRowRef = useRef(null); + const isLoading = useRef(false); + const [streamOffset, setStreamOffset] = useState(0); + const [noStreams, setNoStreams] = useState(12); + const [hasMoreData, setHasMoreData] = useState(true); + const { isLoggedIn } = useAuth(); + const { isCategoryFollowing, checkCategoryFollowStatus, followCategory, unfollowCategory } = useCategoryFollow() useEffect(() => { - const fetchCategoryStreams = async () => { - try { - const response = await fetch(`/api/streams/popular/${category_name}`); - if (!response.ok) { - throw new Error("Failed to fetch category streams"); - } - const data = await response.json(); - const formattedData = data.map((stream: any) => ({ - type: "stream", - id: stream.user_id, - title: stream.title, - streamer: stream.username, - streamCategory: category_name, - viewers: stream.num_viewers, - thumbnail: - stream.thumbnail || - (category_name && - `/images/category_thumbnails/${category_name - .toLowerCase() - .replace(/ /g, "_")}.webp`), - })); - setStreams(formattedData); - } catch (error) { - console.error("Error fetching category streams:", error); - } finally { - setIsLoading(false); - } - }; + checkCategoryFollowStatus(categoryName); + }, [categoryName]); + const fetchCategoryStreams = async () => { + // If already loading, skip this fetch + if (isLoading.current) return; + + isLoading.current = true; + try { + const response = await fetch(`/api/streams/popular/${categoryName}/${noStreams}/${streamOffset}`); + if (!response.ok) { + throw new Error("Failed to fetch category streams"); + } + const data = await response.json(); + + if (data.length === 0) { + setHasMoreData(false); + return []; + } + + setStreamOffset(prev => prev + data.length); + + const processedStreams = data.map((stream: any) => ({ + type: "stream", + id: stream.user_id, + title: stream.title, + streamer: stream.username, + streamCategory: categoryName, + viewers: stream.num_viewers, + thumbnail: + stream.thumbnail || + (categoryName && + `/images/category_thumbnails/${categoryName + .toLowerCase() + .replace(/ /g, "_")}.webp`), + })); + + setStreams(prev => [...prev, ...processedStreams]); + return processedStreams + } catch (error) { + console.error("Error fetching category streams:", error); + } finally { + isLoading.current = false; + } + }; + + useEffect(() => { fetchCategoryStreams(); - }, [category_name]); + }, []); + + const logOnScroll = async () => { + if (hasMoreData && listRowRef.current) { + const newCategories = await fetchCategoryStreams(); + if (newCategories?.length > 0) { + listRowRef.current.addMoreItems(newCategories); + } + } + }; + + fetchContentOnScroll(logOnScroll, hasMoreData); + const handleStreamClick = (streamerName: string) => { window.location.href = `/${streamerName}`; }; - if (isLoading) { + if (hasMoreData && !streams.length) { return (
Loading... @@ -71,13 +111,24 @@ const CategoryPage: React.FC = () => {
+ > + {isLoggedIn && ( + + )} +
{streams.length === 0 && !isLoading && ( diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 5084bd2..eca2b0f 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useRef, useEffect } from "react"; import ListRow from "../components/Layout/ListRow"; import { useNavigate } from "react-router-dom"; import { useStreams, useCategories } from "../context/ContentContext"; @@ -22,13 +22,16 @@ const HomePage: React.FC = ({ variant = "default" }) => { navigate(`/category/${categoryName}`); }; + if (!categories || categories.length === 0) { + return
Loading categories...
; + } + return ( - {/* If Personalised_HomePage, display Streams recommended for the logged-in user. Else, live streams with the most viewers. */} = ({ variant = "default" }) => { wrap={false} onClick={handleStreamClick} extraClasses="bg-[var(--liveNow)]" - > - {/* */} - + /> {/* If Personalised_HomePage, display Categories the logged-in user follows. Else, trending categories. */} = ({ variant = "default" }) => { ); }; -export default HomePage; +export default HomePage; \ No newline at end of file