This commit is contained in:
EvanLin3141
2025-02-27 14:33:16 +00:00
58 changed files with 851 additions and 682 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 508 KiB

After

Width:  |  Height:  |  Size: 748 KiB

View File

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 518 KiB

View File

@@ -1,6 +1,5 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { AuthContext } from "./context/AuthContext"; import { AuthContext } from "./context/AuthContext";
import { ContentProvider } from "./context/ContentContext";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import HomePage from "./pages/HomePage"; import HomePage from "./pages/HomePage";
import StreamerRoute from "./components/Stream/StreamerRoute"; import StreamerRoute from "./components/Stream/StreamerRoute";
@@ -57,7 +56,6 @@ function App() {
setUserId, setUserId,
}} }}
> >
<ContentProvider>
<SidebarProvider> <SidebarProvider>
<QuickSettingsProvider> <QuickSettingsProvider>
<BrowserRouter> <BrowserRouter>
@@ -105,7 +103,6 @@ function App() {
</BrowserRouter> </BrowserRouter>
</QuickSettingsProvider> </QuickSettingsProvider>
</SidebarProvider> </SidebarProvider>
</ContentProvider>
</AuthContext.Provider> </AuthContext.Provider>
</Brightness> </Brightness>
); );

View File

@@ -33,11 +33,11 @@ body[data-theme="light"] {
--sideBar-bg: rgb(255, 255, 255); --sideBar-bg: rgb(255, 255, 255);
--sideBar-text: black; --sideBar-text: black;
--sideBar-profile-bg: rgb(132, 0, 255); --sideBar-profile-bg: rgb(224, 205, 241);
--sideBar-profile-text: #ffffff; --sideBar-profile-text: #ffffff;
--profile-border: #ffffff; --profile-border: #ffffff;
--follow-bg: #ff0000; --follow-bg: #aa00ff;
--follow-text: white; --follow-text: white;
--follow-shadow: 0px 0px 15px rgba(94, 94, 94, 0.754); --follow-shadow: 0px 0px 15px rgba(94, 94, 94, 0.754);
@@ -45,7 +45,7 @@ body[data-theme="light"] {
--recommend: rgba(5, 46, 22, 0.6); --recommend: rgba(5, 46, 22, 0.6);
--quickBar-title: #ffffff; --quickBar-title: #ffffff;
--quickBar-title-bg: rgb(132, 0, 255); --quickBar-title-bg: rgb(183, 149, 214);
--quickBar-bg: #ffffff; --quickBar-bg: #ffffff;
--quickBar-text: #000000; --quickBar-text: #000000;
--quickBar-border: #ffffff; --quickBar-border: #ffffff;

View File

@@ -1,6 +1,6 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { ToggleButton } from "../Input/Button"; import { ToggleButton } from "../Input/Button";
import { LogIn as LogInIcon, User as UserIcon } from "lucide-react"; import { LogInIcon, UserIcon } from "lucide-react";
import LoginForm from "./LoginForm"; import LoginForm from "./LoginForm";
import RegisterForm from "./RegisterForm"; import RegisterForm from "./RegisterForm";
import ForgotPasswordForm from "./ForgotPasswordForm"; import ForgotPasswordForm from "./ForgotPasswordForm";

View File

@@ -1,6 +1,6 @@
import React, { useState } from "react"; import React, { useState } from "react";
import Input from "../Input/Input"; import Input from "../Input/Input";
import Button, { ToggleButton } from "../Input/Button"; import Button from "../Input/Button";
import { useAuth } from "../../context/AuthContext"; import { useAuth } from "../../context/AuthContext";
import GoogleLogin from "./OAuth"; import GoogleLogin from "./OAuth";
import { CircleHelp as ForgotIcon } from "lucide-react"; import { CircleHelp as ForgotIcon } from "lucide-react";

View File

@@ -23,23 +23,6 @@ const Button: React.FC<ButtonProps> = ({
); );
}; };
interface EditButtonProps extends ButtonProps {}
export const EditButton: React.FC<EditButtonProps> = ({
children = "",
extraClasses = "",
onClick,
}) => {
return (
<button
className={`${extraClasses} p-[0.5em] bg-yellow-500 hover:bg-black rounded-[3rem] border-2 border-white shadow-lg transition-all duration-300`}
onClick={onClick}
>
{children}
</button>
);
};
interface ToggleButtonProps extends ButtonProps { interface ToggleButtonProps extends ButtonProps {
toggled?: boolean; toggled?: boolean;
} }

View File

@@ -1,6 +1,6 @@
import React, { useState } from "react"; import React, { useState } from "react";
import Input from "./Input"; import Input from "./Input";
import { Search as SearchIcon } from "lucide-react"; import { SearchIcon } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
interface SearchBarProps { interface SearchBarProps {

View File

@@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import Navbar from "../Navigation/Navbar"; import Navbar from "../Navigation/Navbar";
import { useSidebar } from "../../context/SidebarContext"; import { useSidebar } from "../../context/SidebarContext";
import Footer from "./Footer";
interface DynamicPageContentProps extends React.HTMLProps<HTMLDivElement> { interface DynamicPageContentProps extends React.HTMLProps<HTMLDivElement> {
children: React.ReactNode; children: React.ReactNode;
@@ -20,13 +21,16 @@ const DynamicPageContent: React.FC<DynamicPageContentProps> = ({
const { showSideBar } = useSidebar(); const { showSideBar } = useSidebar();
return ( return (
<div className={`${className} bg-[url(/images/background-pattern.svg)]`} style={style}> <div
className={`${className} bg-[url(/images/background-pattern.svg)]`}
style={style}
>
<Navbar variant={navbarVariant} /> <Navbar variant={navbarVariant} />
<div <div
id="content" id="content"
className={`min-w-[850px] ${ className={`min-w-[850px] ${
showSideBar ? "w-[85vw] translate-x-[15vw]" : "w-[100vw]" showSideBar ? "w-[85vw] translate-x-[15vw]" : "w-[100vw]"
} items-start transition-all duration-[500ms] ease-in-out ${contentClassName}`} } items-start pb-[12vh] transition-all duration-[500ms] ease-in-out ${contentClassName}`}
> >
{children} {children}
</div> </div>

View File

@@ -1,11 +1,17 @@
import { MailIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Mail, Facebook, Twitter, Instagram, Linkedin } from "lucide-react";
const Footer = () => { const Footer = () => {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const handleKeyDown = async (event) => { const handleKeyDown = async (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") { if (event.key === "Enter") {
send_newsletter();
}
}
const send_newsletter = async () => {
if (email) {
if (email.trim() === "") return; if (email.trim() === "") return;
try { try {
const response = await fetch(`/api/send_newsletter/${email}`, const response = await fetch(`/api/send_newsletter/${email}`,
@@ -26,41 +32,26 @@ const Footer = () => {
} catch (error) { } catch (error) {
console.error("Error subscribing:", error); console.error("Error subscribing:", error);
} }
} }
}; };
return ( return (
<footer className="bg-gradient-to-r from-[#1a1a2e] to-[#3a0ca3] text-white p-10"> <footer className="absolute bottom-0 h-[12vh] w-full p-4 bg-gradient-to-b from-white/0 via-[#3a0ca3]/50 to-[#3a0ca3] text-white">
<div className="flex flex-wrap justify-between gap-x-10 gap-y-6"> <div className="flex flex-wrap justify-between gap-x-10 gap-y-6">
{/* About Section */} {/* About Section */}
<div className="flex-1 min-w-[250px]"> <div className="flex-1 min-w-[250px]">
<h2 className="text-2xl font-bold">Gander</h2> <h2 className="text-2xl font-bold">Gander</h2>
<p className="text-sm mt-2">Your very favourite streaming service</p> <p className="text-sm mt-2">
Your very favourite streaming service
</p>
</div> </div>
{/* Office Section */} {/* Office Section */}
<div className="flex-1 min-w-[200px]"> <div className="flex-1 min-w-[200px]">
<h3 className="text-lg font-semibold mb-2">Some Street</h3> <h3 className="text-lg font-semibold mb-2">Contact Us!</h3>
<p className="text-sm">On Some Road</p> <a href="mailto:response.gander@gmail.com" className="underline">
<p className="text-sm">Near Some Country</p> response.gander@gmail.com
<p className="text-sm">That is definitely on Earth</p> </a>
<p className="text-sm mt-2">
<a href="mailto:xyzemail@gmail.com" className="underline">info@gander.com</a>
</p>
<p className="text-sm">+69-280690345</p>
</div>
{/* Links Section */}
<div className="flex-1 min-w-[150px]">
<h3 className="text-lg font-semibold mb-2">Links</h3>
<ul className="space-y-1">
<li><a href="#" className="hover:underline">Home</a></li>
<li><a href="#" className="hover:underline">Categories</a></li>
<li><a href="#" className="hover:underline">Live Now</a></li>
<li><a href="#" className="hover:underline">User Page</a></li>
<li><a href="#" className="hover:underline">Contact</a></li>
</ul>
</div> </div>
{/* Newsletter Section */} {/* Newsletter Section */}
@@ -75,23 +66,15 @@ const Footer = () => {
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
/> />
<Mail className="text-white cursor-pointer" onClick={() => handleKeyDown({ key: "Enter" })} /> <MailIcon className="text-white cursor-pointer" onClick={send_newsletter} />
</div>
</div> </div>
{/* Social Icons */} {/* Footer Bottom */}
<div className="flex gap-4 mt-4"> <div className="text-center text-xs border-t border-gray-600 mt-6 pt-4">
<Facebook className="cursor-pointer hover:opacity-80" /> Group 11
<Twitter className="cursor-pointer hover:opacity-80" />
<Instagram className="cursor-pointer hover:opacity-80" />
<Linkedin className="cursor-pointer hover:opacity-80" />
</div>
</div> </div>
</div> </div>
{/* Footer Bottom */}
<div className="text-center text-xs border-t border-gray-600 mt-6 pt-4">
Group 11
</div>
</footer> </footer>
); );
}; };

View File

@@ -1,19 +1,18 @@
import React from "react"; import React from "react";
import { StreamType } from "../../types/StreamType";
import { CategoryType } from "../../types/CategoryType";
import { UserType } from "../../types/UserType";
export interface ListItemProps { // Base props that all item types share
type: "stream" | "category" | "user"; interface BaseListItemProps {
id: number;
title: string;
username?: string;
streamCategory?: string;
viewers: number;
thumbnail?: string;
onItemClick?: () => void; onItemClick?: () => void;
extraClasses?: string; extraClasses?: string;
} }
const ListItem: React.FC<ListItemProps> = ({ // Stream item component
type, interface StreamListItemProps extends BaseListItemProps, Omit<StreamType, 'type'> {}
const StreamListItem: React.FC<StreamListItemProps> = ({
title, title,
username, username,
streamCategory, streamCategory,
@@ -22,31 +21,6 @@ const ListItem: React.FC<ListItemProps> = ({
onItemClick, onItemClick,
extraClasses = "", extraClasses = "",
}) => { }) => {
if (type === "user") {
return (
<div className="p-4 pb-10">
<div
className={`group relative w-fit flex flex-col bg-purple-900 rounded-tl-xl rounded-xl min-h-[calc((20vw+20vh)/3)] max-w-[calc((27vw+27vh)/2)] justify-end items-center cursor-pointer mx-auto hover:bg-purple-600 hover:scale-105 z-50 transition-all`}
onClick={onItemClick}
>
<img
src="/images/monkey.png"
alt={`user ${username}`}
className="rounded-xl border-[0.15em] border-[var(--bg-color)] cursor-pointer"
/>
<button className="text-[calc((2vw+2vh)/2)] font-bold hover:underline w-full py-2">
{title}
</button>
{title.includes("🔴") && (
<p className="absolute font-black bottom-5 opacity-0 group-hover:translate-y-full group-hover:opacity-100 group-hover:-bottom-1 transition-all">
Currently Live!
</p>
)}
</div>
</div>
);
}
return ( return (
<div className="p-4"> <div className="p-4">
<div <div
@@ -68,10 +42,8 @@ const ListItem: React.FC<ListItemProps> = ({
<h3 className="font-semibold text-lg text-center truncate max-w-full"> <h3 className="font-semibold text-lg text-center truncate max-w-full">
{title} {title}
</h3> </h3>
{type === "stream" && <p className="font-bold">{username}</p>} <p className="font-bold">{username}</p>
{type === "stream" && ( <p className="text-sm text-gray-300">{streamCategory}</p>
<p className="text-sm text-gray-300">{streamCategory}</p>
)}
<p className="text-sm text-gray-300">{viewers} viewers</p> <p className="text-sm text-gray-300">{viewers} viewers</p>
</div> </div>
</div> </div>
@@ -79,4 +51,91 @@ const ListItem: React.FC<ListItemProps> = ({
); );
}; };
export default ListItem; // Category item component
interface CategoryListItemProps extends BaseListItemProps, Omit<CategoryType, 'type'> {}
const CategoryListItem: React.FC<CategoryListItemProps> = ({
title,
viewers,
thumbnail,
onItemClick,
extraClasses = "",
}) => {
return (
<div className="p-4">
<div
className={`${extraClasses} overflow-hidden flex-shrink-0 flex flex-col bg-purple-900 rounded-lg cursor-pointer mx-auto hover:bg-purple-600 hover:scale-105 transition-all`}
onClick={onItemClick}
>
<div className="relative w-full aspect-video overflow-hidden rounded-t-lg">
{thumbnail ? (
<img
src={thumbnail}
alt={title}
className="absolute top-0 left-0 w-full h-full object-cover"
/>
) : (
<div className="absolute top-0 left-0 w-full h-full bg-gray-600" />
)}
</div>
<div className="p-3">
<h3 className="font-semibold text-lg text-center truncate max-w-full">
{title}
</h3>
<p className="text-sm text-gray-300">{viewers} viewers</p>
</div>
</div>
</div>
);
};
// User item component
interface UserListItemProps extends BaseListItemProps, Omit<UserType, 'type'> {}
const UserListItem: React.FC<UserListItemProps> = ({
title,
username,
isLive,
onItemClick,
extraClasses = "",
}) => {
return (
<div className="p-4 pb-10">
<div
className={`group relative w-fit flex flex-col bg-purple-900 rounded-tl-xl rounded-xl min-h-[calc((20vw+20vh)/3)] max-w-[calc((27vw+27vh)/2)] justify-end items-center cursor-pointer mx-auto hover:bg-purple-600 hover:scale-105 z-50 transition-all ${extraClasses}`}
onClick={onItemClick}
>
<img
src="/images/monkey.png"
alt={`user ${username}`}
className="rounded-xl border-[0.15em] border-[var(--bg-color)] cursor-pointer"
/>
<button className="text-[calc((2vw+2vh)/2)] font-bold hover:underline w-full py-2">
{title}
</button>
{isLive && (
<p className="absolute font-black bottom-5 opacity-0 group-hover:translate-y-full group-hover:opacity-100 group-hover:-bottom-1 transition-all">
Currently Live!
</p>
)}
</div>
</div>
);
};
// Legacy wrapper component for backward compatibility
export interface ListItemProps {
type: "stream" | "category" | "user";
id: number;
title: string;
username?: string;
streamCategory?: string;
viewers: number;
thumbnail?: string;
onItemClick?: () => void;
extraClasses?: string;
isLive?: boolean;
}
export { StreamListItem, CategoryListItem, UserListItem };

View File

@@ -1,6 +1,6 @@
import { import {
ArrowLeft as ArrowLeftIcon, ArrowLeftIcon,
ArrowRight as ArrowRightIcon, ArrowRightIcon,
} from "lucide-react"; } from "lucide-react";
import React, { import React, {
forwardRef, forwardRef,
@@ -10,14 +10,19 @@ import React, {
} from "react"; } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import "../../assets/styles/listRow.css"; import "../../assets/styles/listRow.css";
import ListItem, { ListItemProps } from "./ListItem"; import { StreamListItem, CategoryListItem, UserListItem } from "./ListItem";
import { StreamType } from "../../types/StreamType";
import { CategoryType } from "../../types/CategoryType";
import { UserType } from "../../types/UserType";
type ItemType = StreamType | CategoryType | UserType;
interface ListRowProps { interface ListRowProps {
variant?: "default" | "search"; variant?: "default" | "search";
type: "stream" | "category" | "user"; type: "stream" | "category" | "user";
title?: string; title?: string;
description?: string; description?: string;
items: ListItemProps[]; items: ItemType[];
wrap?: boolean; wrap?: boolean;
onItemClick: (itemName: string) => void; onItemClick: (itemName: string) => void;
titleClickable?: boolean; titleClickable?: boolean;
@@ -27,151 +32,179 @@ interface ListRowProps {
children?: React.ReactNode; children?: React.ReactNode;
} }
const ListRow = forwardRef< interface ListRowRef {
{ addMoreItems: (newItems: ListItemProps[]) => void }, addMoreItems: (newItems: ItemType[]) => void;
ListRowProps }
>(
(
{
variant = "default",
type,
title = "",
description = "",
items,
onItemClick,
titleClickable = false,
wrap = false,
extraClasses = "",
itemExtraClasses = "",
amountForScroll = 4,
children,
},
ref
) => {
const [currentItems, setCurrentItems] = useState(items);
const slider = useRef<HTMLDivElement>(null);
const scrollAmount = window.innerWidth * 0.3;
const navigate = useNavigate();
const addMoreItems = (newItems: ListItemProps[]) => { const ListRow = forwardRef<ListRowRef, ListRowProps>((props, ref) => {
setCurrentItems((prevItems) => [...prevItems, ...newItems]); const {
}; variant = "default",
type,
title = "",
description = "",
items,
onItemClick,
titleClickable = false,
wrap = false,
extraClasses = "",
itemExtraClasses = "",
amountForScroll = 4,
children,
} = props;
useImperativeHandle(ref, () => ({ const [currentItems, setCurrentItems] = useState<ItemType[]>(items);
addMoreItems, const slider = useRef<HTMLDivElement>(null);
})); const scrollAmount = window.innerWidth * 0.3;
const navigate = useNavigate();
const slideRight = () => { const addMoreItems = (newItems: ItemType[]) => {
if (!wrap && slider.current) { setCurrentItems((prevItems) => [...prevItems, ...newItems]);
slider.current.scrollBy({ left: +scrollAmount, behavior: "smooth" }); };
}
};
const slideLeft = () => { useImperativeHandle(ref, () => ({
if (!wrap && slider.current) { addMoreItems,
slider.current.scrollBy({ left: -scrollAmount, behavior: "smooth" }); }));
}
};
const handleTitleClick = (type: string) => { const slideRight = () => {
switch (type) { if (!wrap && slider.current) {
case "stream": slider.current.scrollBy({ left: +scrollAmount, behavior: "smooth" });
break; }
case "category": };
navigate("/categories");
break;
case "user":
break;
default:
break;
}
};
return ( const slideLeft = () => {
if (!wrap && slider.current) {
slider.current.scrollBy({ left: -scrollAmount, behavior: "smooth" });
}
};
const handleTitleClick = () => {
switch (type) {
case "stream":
break;
case "category":
navigate("/categories");
break;
case "user":
break;
default:
break;
}
};
const isStreamType = (item: ItemType): item is StreamType =>
item.type === "stream";
const isCategoryType = (item: ItemType): item is CategoryType =>
item.type === "category";
const isUserType = (item: ItemType): item is UserType =>
item.type === "user";
return (
<div
className={`${extraClasses} flex relative rounded-[1.5rem] text-white transition-all ${
variant === "search"
? "items-center"
: "flex-col space-y-4 py-6 px-5 mx-2 mt-5"
}`}
>
{/* List Details */}
<div <div
className={`${extraClasses} flex relative rounded-[1.5rem] text-white transition-all ${ className={`text-center ${
variant === "search" variant === "search" ? "min-w-fit px-auto w-[15vw]" : ""
? "items-center"
: "flex-col space-y-4 py-6 px-5 mx-2 mt-5"
}`} }`}
> >
{/* List Details */} <h2
<div className={`${
className={`text-center ${ titleClickable
variant === "search" ? "min-w-fit px-auto w-[15vw]" : "" ? "cursor-pointer hover:underline"
}`} : "cursor-default"
} text-2xl font-bold`}
onClick={titleClickable ? handleTitleClick : undefined}
> >
<h2 {title}
className={`${ </h2>
titleClickable <p>{description}</p>
? "cursor-pointer hover:underline" </div>
: "cursor-default"
} text-2xl font-bold`}
onClick={titleClickable ? () => handleTitleClick(type) : undefined}
>
{title}
</h2>
<p>{description}</p>
</div>
{/* List Items */} {/* List Items */}
<div className="relative overflow-hidden flex flex-grow items-center z-0"> <div className="relative overflow-hidden flex flex-grow items-center z-0">
{!wrap && currentItems.length > amountForScroll && ( {!wrap && currentItems.length > amountForScroll && (
<>
<ArrowLeftIcon
onClick={slideLeft}
size={30}
className="absolute left-0 cursor-pointer z-[999]"
/>
<ArrowRightIcon
onClick={slideRight}
size={30}
className="absolute right-0 cursor-pointer z-[999]"
/>
</>
)}
<div
ref={slider}
className={`flex ${
wrap ? "flex-wrap justify-between" : "overflow-x-scroll whitespace-nowrap"
} max-w-[95%] items-center w-full mx-auto scroll-smooth scrollbar-hide`}
>
{currentItems.length === 0 ? (
<h1 className="mx-auto">Nothing Found</h1>
) : (
<> <>
<ArrowLeftIcon {currentItems.map((item) => {
onClick={slideLeft} if (type === "stream" && isStreamType(item)) {
size={30} return (
className="absolute left-0 cursor-pointer z-[999]" <StreamListItem
/> key={`stream-${item.id}`}
<ArrowRightIcon id={item.id}
onClick={slideRight} title={item.title}
size={30} username={item.username}
className="absolute right-0 cursor-pointer z-[999]" streamCategory={item.streamCategory}
/> viewers={item.viewers}
thumbnail={item.thumbnail}
onItemClick={() => onItemClick(item.username)}
extraClasses={itemExtraClasses}
/>
);
} else if (type === "category" && isCategoryType(item)) {
return (
<CategoryListItem
key={`category-${item.id}`}
id={item.id}
title={item.title}
viewers={item.viewers}
thumbnail={item.thumbnail}
onItemClick={() => onItemClick(item.title)}
extraClasses={itemExtraClasses}
/>
);
} else if (type === "user" && isUserType(item)) {
return (
<UserListItem
key={`user-${item.id}`}
id={item.id}
title={item.title}
username={item.username}
isLive={item.isLive}
viewers={item.viewers}
thumbnail={item.thumbnail}
onItemClick={() => onItemClick(item.username)}
extraClasses={itemExtraClasses}
/>
);
}
return null;
})}
</> </>
)} )}
<div
ref={slider}
className={`flex ${
wrap ? "flex-wrap justify-between" : "overflow-x-scroll whitespace-nowrap"
} max-w-[95%] items-center w-full mx-auto scroll-smooth scrollbar-hide`}
>
{currentItems.length === 0 ? (
<h1 className="mx-auto">Nothing Found</h1>
) : (
<>
{currentItems.map((item) => (
<ListItem
key={`${item.type}-${item.id}`}
id={item.id}
type={type}
title={item.title}
username={
item.type === "category" ? undefined : item.username
}
streamCategory={
item.type === "stream" ? item.streamCategory : undefined
}
viewers={item.viewers}
thumbnail={item.thumbnail}
onItemClick={() =>
(item.type === "stream" || item.type === "user") &&
item.username
? onItemClick?.(item.username)
: onItemClick?.(item.title)
}
extraClasses={`${itemExtraClasses}`}
/>
))}
</>
)}
</div>
</div> </div>
{children}
</div> </div>
); {children}
} </div>
); );
});
export default ListRow; export default ListRow;

View File

@@ -2,9 +2,9 @@ import React, { useEffect } from "react";
import Logo from "../Layout/Logo"; import Logo from "../Layout/Logo";
import Button, { ToggleButton } from "../Input/Button"; import Button, { ToggleButton } from "../Input/Button";
import { import {
LogIn as LogInIcon, LogInIcon,
LogOut as LogOutIcon, LogOutIcon,
Settings as SettingsIcon, SettingsIcon,
Radio as LiveIcon, Radio as LiveIcon,
} from "lucide-react"; } from "lucide-react";
import SearchBar from "../Input/SearchBar"; import SearchBar from "../Input/SearchBar";

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Sidebar as SidebarIcon } from "lucide-react"; import { SidebarIcon } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useAuth } from "../../context/AuthContext"; import { useAuth } from "../../context/AuthContext";
import { useSidebar } from "../../context/SidebarContext"; import { useSidebar } from "../../context/SidebarContext";

View File

@@ -1,38 +1,38 @@
import React from "react"; import React from "react";
import { Sun, Moon, Droplet, Leaf, Flame } from "lucide-react"; import { SunIcon, MoonIcon, DropletIcon, LeafIcon, FlameIcon } from "lucide-react";
import { useTheme } from "../../context/ThemeContext"; import { useTheme } from "../../context/ThemeContext";
const themeConfig = { const themeConfig = {
light: { light: {
icon: Sun, icon: SunIcon,
color: "text-yellow-400", color: "text-yellow-400",
background: "bg-white", background: "bg-white",
hoverBg: "hover:bg-gray-100", hoverBg: "hover:bg-gray-100",
label: "Light Theme", label: "Light Theme",
}, },
dark: { dark: {
icon: Moon, icon: MoonIcon,
color: "text-white", color: "text-white",
background: "bg-gray-800", background: "bg-gray-800",
hoverBg: "hover:bg-gray-700", hoverBg: "hover:bg-gray-700",
label: "Dark Theme", label: "Dark Theme",
}, },
blue: { blue: {
icon: Droplet, icon: DropletIcon,
color: "text-blue-500", color: "text-blue-500",
background: "bg-blue-50", background: "bg-blue-50",
hoverBg: "hover:bg-blue-100", hoverBg: "hover:bg-blue-100",
label: "Blue Theme", label: "Blue Theme",
}, },
green: { green: {
icon: Leaf, icon: LeafIcon,
color: "text-green-500", color: "text-green-500",
background: "bg-green-50", background: "bg-green-50",
hoverBg: "hover:bg-green-100", hoverBg: "hover:bg-green-100",
label: "Green Theme", label: "Green Theme",
}, },
orange: { orange: {
icon: Flame, icon: FlameIcon,
color: "text-orange-500", color: "text-orange-500",
background: "bg-orange-50", background: "bg-orange-50",
hoverBg: "hover:bg-orange-100", hoverBg: "hover:bg-orange-100",

View File

@@ -6,7 +6,7 @@ import { useAuthModal } from "../../hooks/useAuthModal";
import { useAuth } from "../../context/AuthContext"; import { useAuth } from "../../context/AuthContext";
import { useSocket } from "../../context/SocketContext"; import { useSocket } from "../../context/SocketContext";
import { useChat } from "../../context/ChatContext"; import { useChat } from "../../context/ChatContext";
import { ArrowLeftFromLineIcon, ArrowRightFromLine } from "lucide-react"; import { ArrowLeftFromLineIcon, ArrowRightFromLineIcon } from "lucide-react";
interface ChatMessage { interface ChatMessage {
chatter_username: string; chatter_username: string;
@@ -149,7 +149,7 @@ const ChatPanel: React.FC<ChatPanelProps> = ({
onClick={toggleChat} onClick={toggleChat}
className={`group cursor-pointer p-2 hover:bg-gray-800 rounded-md absolute top-[1vh] left-[1vw] ${showChat ? "" : "delay-[0.75s] -translate-x-[3.3vw]"} text-[1rem] text-purple-500 flex items-center flex-nowrap z-[50] duration-[0.3s] transition-all`} className={`group cursor-pointer p-2 hover:bg-gray-800 rounded-md absolute top-[1vh] left-[1vw] ${showChat ? "" : "delay-[0.75s] -translate-x-[3.3vw]"} text-[1rem] text-purple-500 flex items-center flex-nowrap z-[50] duration-[0.3s] transition-all`}
> >
{showChat ? <ArrowRightFromLine /> : <ArrowLeftFromLineIcon />} {showChat ? <ArrowRightFromLineIcon /> : <ArrowLeftFromLineIcon />}
<small className={`absolute ${showChat ? "right-0 group-hover:-right-[4vw]" : "left-0 group-hover:-left-[4vw]"} p-1 rounded-md group-hover:bg-white/10 w-fit opacity-0 group-hover:opacity-100 text-white transition-all`}> <small className={`absolute ${showChat ? "right-0 group-hover:-right-[4vw]" : "left-0 group-hover:-left-[4vw]"} p-1 rounded-md group-hover:bg-white/10 w-fit opacity-0 group-hover:opacity-100 text-white transition-all`}>
Press C Press C

View File

@@ -1,145 +0,0 @@
import { createContext, useContext, useState, useEffect } from "react";
import { useAuth } from "./AuthContext";
// Base interfaces
interface Item {
id: number;
title: string;
viewers: number;
thumbnail?: string;
}
interface StreamItem extends Item {
type: "stream";
username: string;
streamCategory: string;
}
interface CategoryItem extends Item {
type: "category";
}
interface UserItem extends Item {
type: "user";
username: string;
isLive: boolean;
}
// Context type
interface ContentContextType {
streams: StreamItem[];
categories: CategoryItem[];
users: UserItem[];
setStreams: (streams: StreamItem[]) => void;
setCategories: (categories: CategoryItem[]) => void;
setUsers: (users: UserItem[]) => void;
}
const ContentContext = createContext<ContentContextType | undefined>(undefined);
export function ContentProvider({ children }: { children: React.ReactNode }) {
const [streams, setStreams] = useState<StreamItem[]>([]);
const [categories, setCategories] = useState<CategoryItem[]>([]);
const [users, setUsers] = useState<UserItem[]>([]);
const { isLoggedIn } = useAuth();
useEffect(() => {
// Fetch streams
const streamsUrl = isLoggedIn
? "/api/streams/recommended"
: "/api/streams/popular/4";
fetch(streamsUrl)
.then((response) => response.json())
.then((data: any[]) => {
const processedStreams: StreamItem[] = data.map((stream) => ({
type: "stream",
id: stream.user_id,
title: stream.title,
username: stream.username,
streamCategory: stream.category_name,
viewers: stream.num_viewers,
thumbnail:
stream.thumbnail ||
`/images/category_thumbnails/${stream.category_name
.toLowerCase()
.replace(/ /g, "_")}.webp`,
}));
setStreams(processedStreams);
});
// Fetch categories
const categoriesUrl = isLoggedIn
? "/api/categories/recommended"
: "/api/categories/popular/4";
console.log("Fetching categories from", categoriesUrl);
fetch(categoriesUrl)
.then((response) => response.json())
.then((data: any[]) => {
const processedCategories: CategoryItem[] = data.map((category) => ({
type: "category",
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);
console.log("Categories fetched", processedCategories);
});
}, [isLoggedIn]);
return (
<ContentContext.Provider
value={{
streams,
categories,
users,
setStreams,
setCategories,
setUsers,
}}
>
{children}
</ContentContext.Provider>
);
}
// Custom hooks for specific content types
export function useStreams() {
const context = useContext(ContentContext);
if (!context) {
throw new Error("useStreams must be used within a ContentProvider");
}
return { streams: context.streams, setStreams: context.setStreams };
}
export function useCategories() {
const context = useContext(ContentContext);
if (!context) {
throw new Error("useCategories must be used within a ContentProvider");
}
return {
categories: context.categories,
setCategories: context.setCategories,
};
}
export function useUsers() {
const context = useContext(ContentContext);
if (!context) {
throw new Error("useUsers must be used within a ContentProvider");
}
return { users: context.users, setUsers: context.setUsers };
}
// General hook for all content
export function useContent() {
const context = useContext(ContentContext);
if (!context) {
throw new Error("useContent must be used within a ContentProvider");
}
return context;
}

View File

@@ -2,20 +2,29 @@ import { useEffect } from "react";
export function fetchContentOnScroll(callback: () => void, hasMoreData: boolean) { export function fetchContentOnScroll(callback: () => void, hasMoreData: boolean) {
useEffect(() => { useEffect(() => {
const root = document.querySelector("#root") as HTMLElement;
const handleScroll = () => { const handleScroll = () => {
if (!hasMoreData) return; // Don't trigger scroll if no more data if (!hasMoreData) return; // Don't trigger scroll if no more data
const scrollPosition = window.innerHeight + document.documentElement.scrollTop;
const scrollHeight = document.documentElement.scrollHeight; // Use properties of the element itself, not document
const scrollPosition = root.scrollTop + root.clientHeight;
const scrollHeight = root.scrollHeight;
if (scrollPosition >= scrollHeight * 0.9) { if (scrollPosition >= scrollHeight * 0.9) {
callback(); // Trigger data fetching when 90% scroll is reached callback(); // Trigger data fetching when 90% scroll is reached
setTimeout(() => {
// Delay to prevent multiple fetches
root.scrollTop = root.scrollTop - 1;
}, 100);
} }
}; };
window.addEventListener("scroll", handleScroll); // Add scroll event listener to the root element
root.addEventListener("scroll", handleScroll);
return () => { return () => {
window.removeEventListener("scroll", handleScroll); // Cleanup on unmount root.removeEventListener("scroll", handleScroll); // Cleanup on unmount
}; };
}, [callback, hasMoreData]); }, [callback, hasMoreData]);
} }

View File

@@ -0,0 +1,135 @@
// hooks/useContent.ts
import { useState, useEffect } from "react";
import { useAuth } from "../context/AuthContext";
import { StreamType } from "../types/StreamType";
import { CategoryType } from "../types/CategoryType";
import { UserType } from "../types/UserType";
import { getCategoryThumbnail } from "../utils/thumbnailUtils";
// Helper function to process API data into our consistent types
const processStreamData = (data: any[]): StreamType[] => {
return data.map((stream) => ({
type: "stream",
id: stream.user_id,
title: stream.title,
username: stream.username,
streamCategory: stream.category_name,
viewers: stream.num_viewers,
thumbnail: getCategoryThumbnail(stream.category_name, stream.thumbnail),
}))
};
const processCategoryData = (data: any[]): CategoryType[] => {
return data.map((category) => ({
type: "category",
id: category.category_id,
title: category.category_name,
viewers: category.num_viewers,
thumbnail: getCategoryThumbnail(category.category_name)
}));
};
const processUserData = (data: any[]): UserType[] => {
return data.map((user) => ({
type: "user",
id: user.user_id,
title: user.username,
username: user.username,
isLive: user.is_live,
viewers: 0, // This may need to be updated based on your API
thumbnail: user.thumbnail || "/images/pfps/default.webp",
}));
};
// Generic fetch hook that can be used for any content type
export function useFetchContent<T>(
url: string,
processor: (data: any[]) => T[],
dependencies: any[] = []
): { data: T[]; isLoading: boolean; error: string | null } {
const [data, setData] = useState<T[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Error fetching data: ${response.status}`);
}
const rawData = await response.json();
const processedData = processor(rawData);
setData(processedData);
setError(null);
} catch (err) {
console.error("Error fetching content:", err);
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setIsLoading(false);
}
};
fetchData();
}, dependencies);
return { data, isLoading, error };
}
// Specific hooks for each content type
export function useStreams(customUrl?: string): {
streams: StreamType[];
isLoading: boolean;
error: string | null
} {
const { isLoggedIn } = useAuth();
const url = customUrl || (isLoggedIn
? "/api/streams/recommended"
: "/api/streams/popular/4");
const { data, isLoading, error } = useFetchContent<StreamType>(
url,
processStreamData,
[isLoggedIn, customUrl]
);
return { streams: data, isLoading, error };
}
export function useCategories(customUrl?: string): {
categories: CategoryType[];
isLoading: boolean;
error: string | null
} {
const { isLoggedIn } = useAuth();
const url = customUrl || (isLoggedIn
? "/api/categories/recommended"
: "/api/categories/popular/4");
const { data, isLoading, error } = useFetchContent<CategoryType>(
url,
processCategoryData,
[isLoggedIn, customUrl]
);
return { categories: data, isLoading, error };
}
export function useUsers(customUrl?: string): {
users: UserType[];
isLoading: boolean;
error: string | null
} {
const url = customUrl || "/api/users/popular";
const { data, isLoading, error } = useFetchContent<UserType>(
url,
processUserData,
[customUrl]
);
return { users: data, isLoading, error };
}

View File

@@ -4,30 +4,27 @@ import ListRow from "../components/Layout/ListRow";
import DynamicPageContent from "../components/Layout/DynamicPageContent"; import DynamicPageContent from "../components/Layout/DynamicPageContent";
import { fetchContentOnScroll } from "../hooks/fetchContentOnScroll"; import { fetchContentOnScroll } from "../hooks/fetchContentOnScroll";
import LoadingScreen from "../components/Layout/LoadingScreen"; import LoadingScreen from "../components/Layout/LoadingScreen";
import { CategoryType } from "../types/CategoryType";
import { getCategoryThumbnail } from "../utils/thumbnailUtils";
interface CategoryData {
type: "category";
id: number;
title: string;
viewers: number;
thumbnail: string;
}
const AllCategoriesPage: React.FC = () => { const AllCategoriesPage: React.FC = () => {
const [categories, setCategories] = useState<CategoryData[]>([]); const [categories, setCategories] = useState<CategoryType[]>([]);
const navigate = useNavigate(); const navigate = useNavigate();
const [categoryOffset, setCategoryOffset] = useState(0); const [categoryOffset, setCategoryOffset] = useState(0);
const [noCategories, setNoCategories] = useState(12); const [noCategories, setNoCategories] = useState(12);
const [hasMoreData, setHasMoreData] = useState(true); const [hasMoreData, setHasMoreData] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const listRowRef = useRef<any>(null); const listRowRef = useRef<any>(null);
const isLoading = useRef(false);
const fetchCategories = async () => { const fetchCategories = async () => {
if (isLoading) return; // If already loading, skip this fetch
if (isLoading.current) return;
isLoading.current = true;
try { try {
const response = await fetch( const response = await fetch(`/api/categories/popular/${noCategories}/${categoryOffset}`);
`/api/categories/popular/${noCategories}/${categoryOffset}`
);
if (!response.ok) { if (!response.ok) {
throw new Error("Failed to fetch categories"); throw new Error("Failed to fetch categories");
} }
@@ -38,25 +35,23 @@ const AllCategoriesPage: React.FC = () => {
return []; return [];
} }
setCategoryOffset((prev) => prev + data.length); setCategoryOffset(prev => prev + data.length);
const processedCategories = data.map((category: any) => ({ const processedCategories = data.map((category: any) => ({
type: "category" as const, type: "category" as const,
id: category.category_id, id: category.category_id,
title: category.category_name, title: category.category_name,
viewers: category.num_viewers, viewers: category.num_viewers,
thumbnail: `/images/category_thumbnails/${category.category_name thumbnail: getCategoryThumbnail(category.category_name)
.toLowerCase()
.replace(/ /g, "_")}.webp`,
})); }));
setCategories((prev) => [...prev, ...processedCategories]); setCategories(prev => [...prev, ...processedCategories]);
return processedCategories; return processedCategories;
} catch (error) { } catch (error) {
console.error("Error fetching categories:", error); console.error("Error fetching categories:", error);
return []; return [];
} finally { } finally {
setIsLoading(false); isLoading.current = false;
} }
}; };
@@ -83,17 +78,25 @@ const AllCategoriesPage: React.FC = () => {
}; };
return ( return (
<DynamicPageContent className="min-h-screen"> <DynamicPageContent
className="min-h-screen bg-gradient-radial from-[#ff00f1] via-[#0400ff] to-[#ff0000]"
style={{ backgroundImage: "url(/images/background-pattern.svg)" }}
>
<ListRow <ListRow
ref={listRowRef} ref={listRowRef}
type="category" type="category"
title="All Categories" title="All Categories"
items={categories} items={categories}
onItemClick={handleCategoryClick} onClick={handleCategoryClick}
extraClasses="bg-[var(--recommend)] text-center" extraClasses="bg-[var(--recommend)] text-center"
itemExtraClasses="w-[20vw]" itemExtraClasses="w-[20vw]"
wrap={true} wrap={true}
/> />
{!hasMoreData && !categories.length && (
<div className="text-center text-gray-500 p-4">
No more categories to load
</div>
)}
</DynamicPageContent> </DynamicPageContent>
); );
}; };

View File

@@ -6,12 +6,13 @@ import { fetchContentOnScroll } from "../hooks/fetchContentOnScroll";
import Button from "../components/Input/Button"; import Button from "../components/Input/Button";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { useCategoryFollow } from "../hooks/useCategoryFollow"; import { useCategoryFollow } from "../hooks/useCategoryFollow";
import { ListItemProps as StreamData } from "../components/Layout/ListItem";
import LoadingScreen from "../components/Layout/LoadingScreen"; import LoadingScreen from "../components/Layout/LoadingScreen";
import { StreamType } from "../types/StreamType";
import { getCategoryThumbnail } from "../utils/thumbnailUtils";
const CategoryPage: React.FC = () => { const CategoryPage: React.FC = () => {
const { categoryName } = useParams<{ categoryName: string }>(); const { categoryName } = useParams<{ categoryName: string }>();
const [streams, setStreams] = useState<StreamData[]>([]); const [streams, setStreams] = useState<StreamType[]>([]);
const listRowRef = useRef<any>(null); const listRowRef = useRef<any>(null);
const isLoading = useRef(false); const isLoading = useRef(false);
const [streamOffset, setStreamOffset] = useState(0); const [streamOffset, setStreamOffset] = useState(0);
@@ -50,19 +51,14 @@ const CategoryPage: React.FC = () => {
setStreamOffset((prev) => prev + data.length); setStreamOffset((prev) => prev + data.length);
const processedStreams: StreamData[] = data.map((stream: any) => ({ const processedStreams = data.map((stream: any) => ({
type: "stream", type: "stream",
id: stream.user_id, id: stream.user_id,
title: stream.title, title: stream.title,
username: stream.username, username: stream.username,
streamCategory: categoryName, streamCategory: categoryName,
viewers: stream.num_viewers, viewers: stream.num_viewers,
thumbnail: thumbnail: getCategoryThumbnail(categoryName, stream.thumbnail),
stream.thumbnail ||
(categoryName &&
`/images/category_thumbnails/${categoryName
.toLowerCase()
.replace(/ /g, "_")}.webp`),
})); }));
setStreams((prev) => [...prev, ...processedStreams]); setStreams((prev) => [...prev, ...processedStreams]);
@@ -78,16 +74,16 @@ const CategoryPage: React.FC = () => {
fetchCategoryStreams(); fetchCategoryStreams();
}, []); }, []);
const logOnScroll = async () => { const loadOnScroll = async () => {
if (hasMoreData && listRowRef.current) { if (hasMoreData && listRowRef.current) {
const newCategories = await fetchCategoryStreams(); const newStreams = await fetchCategoryStreams();
if (newCategories && newCategories.length > 0) { if (newStreams?.length > 0) {
listRowRef.current.addMoreItems(newCategories); listRowRef.current.addMoreItems(newStreams);
} else console.log("No more data to fetch"); } else console.log("No more data to fetch");
} }
}; };
fetchContentOnScroll(logOnScroll, hasMoreData); fetchContentOnScroll(loadOnScroll, hasMoreData);
const handleStreamClick = (streamerName: string) => { const handleStreamClick = (streamerName: string) => {
window.location.href = `/${streamerName}`; window.location.href = `/${streamerName}`;
@@ -99,6 +95,7 @@ const CategoryPage: React.FC = () => {
<DynamicPageContent className="min-h-screen bg-gradient-radial from-[#ff00f1] via-[#0400ff] to-[#ff0000]"> <DynamicPageContent className="min-h-screen bg-gradient-radial from-[#ff00f1] via-[#0400ff] to-[#ff0000]">
<div className="pt-8"> <div className="pt-8">
<ListRow <ListRow
ref={listRowRef}
type="stream" type="stream"
title={`${categoryName} Streams`} title={`${categoryName} Streams`}
description={`Live streams in the ${categoryName} category`} description={`Live streams in the ${categoryName} category`}
@@ -106,6 +103,7 @@ const CategoryPage: React.FC = () => {
wrap={true} wrap={true}
onItemClick={handleStreamClick} onItemClick={handleStreamClick}
extraClasses="bg-[var(--recommend)]" extraClasses="bg-[var(--recommend)]"
itemExtraClasses="w-[20vw]"
> >
{isLoggedIn && ( {isLoggedIn && (
<Button <Button

View File

@@ -1,15 +1,14 @@
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef } from "react";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { useSidebar } from "../context/SidebarContext"; import { CircleMinus as RemoveIcon, CirclePlus as AddIcon } from "lucide-react";
import { CircleMinus, CirclePlus, Sidebar as SidebarIcon } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import DynamicPageContent from "../components/Layout/DynamicPageContent"; import DynamicPageContent from "../components/Layout/DynamicPageContent";
import { fetchContentOnScroll } from "../hooks/fetchContentOnScroll";
import Button from "../components/Input/Button"; import Button from "../components/Input/Button";
import { useCategoryFollow } from "../hooks/useCategoryFollow"; import { useCategoryFollow } from "../hooks/useCategoryFollow";
import { ListItemProps as StreamData } from "../components/Layout/ListItem"; import { ListItemProps as StreamData } from "../components/Layout/ListItem";
import LoadingScreen from "../components/Layout/LoadingScreen"; import LoadingScreen from "../components/Layout/LoadingScreen";
import { getCategoryThumbnail } from "../utils/thumbnailUtils";
interface Category { interface Category {
isFollowing: any; isFollowing: any;
@@ -125,14 +124,14 @@ const FollowedCategories: React.FC<FollowedCategoryProps> = ({ extraClasses = ""
onClick={() => toggleFollow(category.category_id, category.category_name)} onClick={() => toggleFollow(category.category_id, category.category_name)}
> >
{category.isFollowing ? ( {category.isFollowing ? (
<CircleMinus className="text-white w-5 h-5" /> <RemoveIcon className="text-white w-5 h-5" />
) : ( ) : (
<CirclePlus className="text-white w-5 h-5" /> <AddIcon className="text-white w-5 h-5" />
)} )}
</Button> </Button>
<img <img
src={`/images/category_thumbnails/${category.category_name.toLowerCase().replace(/ /g, "_")}.webp`} src={getCategoryThumbnail(category.category_name)}
alt={category.category_name} alt={category.category_name}
className="w-full h-28 object-cover" className="w-full h-28 object-cover"
/> />

View File

@@ -4,125 +4,140 @@ import { useSidebar } from "../context/SidebarContext";
import { ToggleButton } from "../components/Input/Button"; import { ToggleButton } from "../components/Input/Button";
import { Sidebar as SidebarIcon } from "lucide-react"; import { Sidebar as SidebarIcon } from "lucide-react";
import { useNavigate } from "react-router-dom"; // Import useNavigate import { useNavigate } from "react-router-dom"; // Import useNavigate
import { CategoryType } from "../types/CategoryType";
// Define TypeScript interfaces // Define TypeScript interfaces
interface Streamer { interface Streamer {
user_id: number; user_id: number;
username: string; username: string;
} }
interface FollowingProps { interface FollowingProps {
extraClasses?: string; extraClasses?: string;
} }
const Following: React.FC<FollowingProps> = ({ extraClasses = "" }) => { const Following: React.FC<FollowingProps> = ({ extraClasses = "" }) => {
const { showSideBar, setShowSideBar } = useSidebar(); const { showSideBar, setShowSideBar } = useSidebar();
const navigate = useNavigate(); const navigate = useNavigate();
const { username, isLoggedIn } = useAuth(); const { username, isLoggedIn } = useAuth();
const [followedStreamers, setFollowedStreamers] = useState<Streamer[]>([]); const [followedStreamers, setFollowedStreamers] = useState<Streamer[]>([]);
// Fetch followed streamers // Fetch followed streamers
useEffect(() => { useEffect(() => {
const fetchFollowedStreamers = async () => { const fetchFollowedStreamers = async () => {
try { try {
const response = await fetch("/api/user/following"); const response = await fetch("/api/user/following");
if (!response.ok) throw new Error("Failed to fetch followed streamers"); if (!response.ok) throw new Error("Failed to fetch followed streamers");
const data = await response.json(); const data = await response.json();
setFollowedStreamers(data.streamers || []); setFollowedStreamers(data.streamers || []);
} catch (error) { } catch (error) {
console.error("Error fetching followed streamers:", error); console.error("Error fetching followed streamers:", error);
} }
};
if (isLoggedIn) {
fetchFollowedStreamers();
}
}, [isLoggedIn]);
// Handle sidebar toggle
const handleSideBar = () => {
setShowSideBar(!showSideBar);
}; };
return ( if (isLoggedIn) {
<> fetchFollowedStreamers();
{/* Sidebar Toggle Button */} }
<ToggleButton }, [isLoggedIn]);
onClick={handleSideBar}
extraClasses={`absolute group text-[1rem] top-[9vh] ${showSideBar ? "left-[16vw] duration-[0.5s]" : "left-[20px] duration-[1s]" // Handle sidebar toggle
} ease-in-out cursor-pointer z-[50]`} const handleSideBar = () => {
toggled={showSideBar} setShowSideBar(!showSideBar);
};
return (
<>
{/* Sidebar Toggle Button */}
<ToggleButton
onClick={handleSideBar}
extraClasses={`absolute group text-[1rem] top-[9vh] ${
showSideBar
? "left-[16vw] duration-[0.5s]"
: "left-[20px] duration-[1s]"
} ease-in-out cursor-pointer z-[50]`}
toggled={showSideBar}
>
<SidebarIcon className="h-[2vw] w-[2vw]" />
{showSideBar && (
<small className="absolute flex items-center top-0 ml-4 left-0 h-full w-full my-auto group-hover:left-full opacity-0 group-hover:opacity-100 text-white transition-all delay-200">
Press S
</small>
)}
</ToggleButton>
{/* Sidebar Container */}
<div
id="sidebar"
className={`top-0 left-0 w-screen h-screen overflow-x-hidden flex flex-col bg-[var(--sideBar-bg)] text-[var(--sideBar-text)] text-center overflow-y-auto scrollbar-hide
transition-all duration-500 ease-in-out ${
showSideBar ? "translate-x-0" : "-translate-x-full"
} ${extraClasses}`}
>
{/* Profile Info */}
<div className="flex flex-row items-center border-b-4 border-[var(--profile-border)] justify-evenly bg-[var(--sideBar-profile-bg)] py-[1em]">
<img
src="/images/monkey.png"
alt="profile picture"
className="w-[3em] h-[3em] rounded-full border-[0.15em] border-purple-500 cursor-pointer"
onClick={() => navigate(`/user/${username}`)}
/>
<div className="text-center flex flex-col items-center justify-center">
<h5 className="font-thin text-[0.85rem] cursor-default text-[var(--sideBar-profile-text)]">
Logged in as
</h5>
<button
className="font-black text-[1.4rem] hover:underline"
onClick={() => navigate(`/user/${username}`)}
> >
<SidebarIcon className="h-[2vw] w-[2vw]" /> <div className="text-[var(--sideBar-profile-text)]">
{username}
</div>
</button>
</div>
</div>
{showSideBar && ( {/* Following Section */}
<small className="absolute flex items-center top-0 ml-4 left-0 h-full w-full my-auto group-hover:left-full opacity-0 group-hover:opacity-100 text-white transition-all delay-200"> <div
Press S id="following"
</small> className="flex flex-col flex-grow justify-evenly p-4 gap-4"
)} >
</ToggleButton> <div
className="bg-[var(--follow-bg)] rounded-[1em] hover:scale-105 transition-all ease-in-out duration-300"
onMouseEnter={(e) =>
(e.currentTarget.style.boxShadow = "var(--follow-shadow)")
}
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = "none")}
>
<h1 className="text-[2vw] font-bold text-2xl p-[0.75rem] cursor-default">
Following
</h1>
</div>
{/* Sidebar Container */} {/* Streamers Followed */}
<div <div
id="sidebar" id="streamers-followed"
className={`top-0 left-0 w-screen h-screen overflow-x-hidden flex flex-col bg-[var(--sideBar-bg)] text-[var(--sideBar-text)] text-center overflow-y-auto scrollbar-hide className="flex flex-col flex-grow items-center"
transition-all duration-500 ease-in-out ${showSideBar ? "translate-x-0" : "-translate-x-full" >
} ${extraClasses}`} <h2 className="border-b-4 border-t-4 w-[125%] text-2xl cursor-default">
> Streamers
{/* Profile Info */} </h2>
<div className="flex flex-row items-center border-b-4 border-[var(--profile-border)] justify-evenly bg-[var(--sideBar-profile-bg)] py-[1em]"> <div className="flex flex-col flex-grow justify-evenly w-full">
<img {followedStreamers.map((streamer) => (
src="/images/monkey.png" <button
alt="profile picture" key={`streamer-${streamer.username}`}
className="w-[3em] h-[3em] rounded-full border-[0.15em] border-purple-500 cursor-pointer" className="cursor-pointer bg-black w-full py-2 border border-[--text-color] rounded-lg text-white hover:text-purple-500 transition-colors"
onClick={() => navigate(`/user/${username}`)} onClick={() => navigate(`/user/${streamer.username}`)}
/> >
<div className="text-center flex flex-col items-center justify-center"> {streamer.username}
<h5 className="font-thin text-[0.85rem] cursor-default text-[var(--sideBar-profile-text)]"> </button>
Logged in as ))}
</h5>
<button
className="font-black text-[1.4rem] hover:underline"
onClick={() => navigate(`/user/${username}`)}
>
<div className="text-[var(--sideBar-profile-text)]">{username}</div>
</button>
</div>
</div>
{/* Following Section */}
<div id="following" className="flex flex-col flex-grow justify-evenly p-4 gap-4">
<div
className="bg-[var(--follow-bg)] rounded-[1em] hover:scale-105 transition-all ease-in-out duration-300"
onMouseEnter={(e) => (e.currentTarget.style.boxShadow = "var(--follow-shadow)")}
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = "none")}
>
<h1 className="text-[2vw] font-bold text-2xl p-[0.75rem] cursor-default">
Following
</h1>
</div>
{/* Streamers Followed */}
<div id="streamers-followed" className="flex flex-col flex-grow items-center">
<h2 className="border-b-4 border-t-4 w-[125%] text-2xl cursor-default">
Streamers
</h2>
<div className="flex flex-col flex-grow justify-evenly w-full">
{followedStreamers.map((streamer) => (
<button
key={`streamer-${streamer.username}`}
className="cursor-pointer bg-black w-full py-2 border border-[--text-color] rounded-lg text-white hover:text-purple-500 transition-colors"
onClick={() => navigate(`/user/${streamer.username}`)}
>
{streamer.username}
</button>
))}
</div>
</div>
</div>
</div> </div>
</> </div>
); </div>
</div>
</>
);
}; };
export default Following; export default Following;

View File

@@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import ListRow from "../components/Layout/ListRow"; import ListRow from "../components/Layout/ListRow";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useStreams, useCategories } from "../context/ContentContext"; import { useStreams, useCategories } from "../hooks/useContent";
import Button from "../components/Input/Button"; import Button from "../components/Input/Button";
import DynamicPageContent from "../components/Layout/DynamicPageContent"; import DynamicPageContent from "../components/Layout/DynamicPageContent";
import LoadingScreen from "../components/Layout/LoadingScreen"; import LoadingScreen from "../components/Layout/LoadingScreen";
@@ -12,8 +12,8 @@ interface HomePageProps {
} }
const HomePage: React.FC<HomePageProps> = ({ variant = "default" }) => { const HomePage: React.FC<HomePageProps> = ({ variant = "default" }) => {
const { streams } = useStreams(); const { streams, isLoading: isLoadingStreams } = useStreams();
const { categories } = useCategories(); const { categories, isLoading: isLoadingCategories } = useCategories();
const navigate = useNavigate(); const navigate = useNavigate();
const handleStreamClick = (streamerName: string) => { const handleStreamClick = (streamerName: string) => {
@@ -24,15 +24,15 @@ const HomePage: React.FC<HomePageProps> = ({ variant = "default" }) => {
navigate(`/category/${categoryName}`); navigate(`/category/${categoryName}`);
}; };
if (!categories || categories.length === 0) { if (isLoadingStreams || isLoadingCategories) {
console.log("No categories found yet"); console.log("No content found yet");
return <LoadingScreen>Loading Categories...</LoadingScreen>; return <LoadingScreen>Loading Content...</LoadingScreen>;
} }
return ( return (
<DynamicPageContent <DynamicPageContent
navbarVariant="home" navbarVariant="home"
className="min-h-screen animate-moving_bg" className="relative min-h-screen animate-moving_bg"
> >
<ListRow <ListRow
type="stream" type="stream"
@@ -79,7 +79,7 @@ const HomePage: React.FC<HomePageProps> = ({ variant = "default" }) => {
Show More Show More
</Button> </Button>
</ListRow> </ListRow>
<Footer/> <Footer />
</DynamicPageContent> </DynamicPageContent>
); );
}; };

View File

@@ -1,9 +1,9 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import Button from "../components/Input/Button";
import SearchBar from "../components/Input/SearchBar"; import SearchBar from "../components/Input/SearchBar";
import ListRow from "../components/Layout/ListRow"; import ListRow from "../components/Layout/ListRow";
import DynamicPageContent from "../components/Layout/DynamicPageContent"; import DynamicPageContent from "../components/Layout/DynamicPageContent";
import { getCategoryThumbnail } from "../utils/thumbnailUtils";
const ResultsPage: React.FC = ({ }) => { const ResultsPage: React.FC = ({ }) => {
const [overflow, setOverflow] = useState(false); const [overflow, setOverflow] = useState(false);
@@ -65,9 +65,7 @@ const ResultsPage: React.FC = ({ }) => {
type: "category", type: "category",
title: category.category_name, title: category.category_name,
viewers: 0, viewers: 0,
thumbnail: `/images/category_thumbnails/${category.category_name thumbnail: getCategoryThumbnail(category.category_name),
.toLowerCase()
.replace(/ /g, "_")}.webp`,
}))} }))}
title="Categories" title="Categories"
onItemClick={(category_name: string) => onItemClick={(category_name: string) =>

View File

@@ -2,11 +2,18 @@ import React, { useState, useEffect } from "react";
import DynamicPageContent from "../components/Layout/DynamicPageContent"; import DynamicPageContent from "../components/Layout/DynamicPageContent";
import Button from "../components/Input/Button"; import Button from "../components/Input/Button";
import Input from "../components/Input/Input"; import Input from "../components/Input/Input";
import ListItem from "../components/Layout/ListItem"; import { useCategories } from "../hooks/useContent";
import { X as XIcon, Eye as ShowIcon, EyeOff as HideIcon } from "lucide-react"; import {
X as CloseIcon,
Eye as ShowIcon,
EyeOff as HideIcon,
} from "lucide-react";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { debounce } from "lodash"; import { debounce } from "lodash";
import VideoPlayer from "../components/Stream/VideoPlayer"; import VideoPlayer from "../components/Stream/VideoPlayer";
import { CategoryType } from "../types/CategoryType";
import { StreamListItem } from "../components/Layout/ListItem";
import { getCategoryThumbnail } from "../utils/thumbnailUtils";
interface StreamData { interface StreamData {
title: string; title: string;
@@ -16,11 +23,6 @@ interface StreamData {
stream_key: string; stream_key: string;
} }
interface Category {
category_id: number;
category_name: string;
}
const StreamDashboardPage: React.FC = () => { const StreamDashboardPage: React.FC = () => {
const { username } = useAuth(); const { username } = useAuth();
const [isStreaming, setIsStreaming] = useState(false); const [isStreaming, setIsStreaming] = useState(false);
@@ -33,9 +35,10 @@ const StreamDashboardPage: React.FC = () => {
}); });
const [streamDetected, setStreamDetected] = useState(false); const [streamDetected, setStreamDetected] = useState(false);
const [timeStarted, setTimeStarted] = useState(""); const [timeStarted, setTimeStarted] = useState("");
const [categories, setCategories] = useState<Category[]>([]);
const [isCategoryFocused, setIsCategoryFocused] = useState(false); const [isCategoryFocused, setIsCategoryFocused] = useState(false);
const [filteredCategories, setFilteredCategories] = useState<Category[]>([]); const [filteredCategories, setFilteredCategories] = useState<CategoryType[]>(
[]
);
const [thumbnail, setThumbnail] = useState<File | null>(null); const [thumbnail, setThumbnail] = useState<File | null>(null);
const [thumbnailPreview, setThumbnailPreview] = useState<{ const [thumbnailPreview, setThumbnailPreview] = useState<{
url: string; url: string;
@@ -44,16 +47,27 @@ const StreamDashboardPage: React.FC = () => {
const [debouncedCheck, setDebouncedCheck] = useState<Function | null>(null); const [debouncedCheck, setDebouncedCheck] = useState<Function | null>(null);
const [showKey, setShowKey] = useState(false); const [showKey, setShowKey] = useState(false);
const {
categories,
isLoading: categoriesLoading,
error: categoriesError,
} = useCategories("/api/categories/popular/100");
useEffect(() => {
// Set filtered categories when categories load
if (categories.length > 0) {
setFilteredCategories(categories);
}
}, [categories]);
useEffect(() => { useEffect(() => {
const categoryCheck = debounce((categoryName: string) => { const categoryCheck = debounce((categoryName: string) => {
const isValidCategory = categories.some( const isValidCategory = categories.some(
(cat) => cat.category_name.toLowerCase() === categoryName.toLowerCase() (cat) => cat.title.toLowerCase() === categoryName.toLowerCase()
); );
if (isValidCategory && !thumbnailPreview.isCustom) { if (isValidCategory && !thumbnailPreview.isCustom) {
const defaultThumbnail = `/images/thumbnails/categories/${categoryName const defaultThumbnail = getCategoryThumbnail(categoryName);
.toLowerCase()
.replace(/ /g, "_")}.webp`;
setThumbnailPreview({ url: defaultThumbnail, isCustom: false }); setThumbnailPreview({ url: defaultThumbnail, isCustom: false });
} }
}, 300); }, 300);
@@ -66,11 +80,14 @@ const StreamDashboardPage: React.FC = () => {
}, [categories, thumbnailPreview.isCustom]); }, [categories, thumbnailPreview.isCustom]);
useEffect(() => { useEffect(() => {
checkStreamStatus(); if (username) {
fetchCategories(); checkStreamStatus();
}
}, [username]); }, [username]);
const checkStreamStatus = async () => { const checkStreamStatus = async () => {
if (!username) return;
try { try {
const response = await fetch(`/api/user/${username}/status`); const response = await fetch(`/api/user/${username}/status`);
const data = await response.json(); const data = await response.json();
@@ -119,24 +136,13 @@ const StreamDashboardPage: React.FC = () => {
} }
}; };
const fetchCategories = async () => {
try {
const response = await fetch("/api/categories/popular/100");
const data = await response.json();
setCategories(data);
setFilteredCategories(data);
} catch (error) {
console.error("Error fetching categories:", error);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target; const { name, value } = e.target;
setStreamData((prev) => ({ ...prev, [name]: value })); setStreamData((prev) => ({ ...prev, [name]: value }));
if (name === "category_name") { if (name === "category_name") {
const filtered = categories.filter((cat) => const filtered = categories.filter((cat) =>
cat.category_name.toLowerCase().includes(value.toLowerCase()) cat.title.toLowerCase().includes(value.toLowerCase())
); );
setFilteredCategories(filtered); setFilteredCategories(filtered);
if (debouncedCheck) { if (debouncedCheck) {
@@ -178,9 +184,7 @@ const StreamDashboardPage: React.FC = () => {
console.log( console.log(
"Clearing thumbnail as category is set and default category thumbnail will be used" "Clearing thumbnail as category is set and default category thumbnail will be used"
); );
const defaultThumbnail = `/images/thumbnails/categories/${streamData.category_name const defaultThumbnail = getCategoryThumbnail(streamData.category_name);
.toLowerCase()
.replace(/ /g, "_")}.webp`;
setThumbnailPreview({ url: defaultThumbnail, isCustom: false }); setThumbnailPreview({ url: defaultThumbnail, isCustom: false });
} else { } else {
setThumbnailPreview({ url: "", isCustom: false }); setThumbnailPreview({ url: "", isCustom: false });
@@ -193,8 +197,7 @@ const StreamDashboardPage: React.FC = () => {
streamData.category_name.trim() !== "" && streamData.category_name.trim() !== "" &&
categories.some( categories.some(
(cat) => (cat) =>
cat.category_name.toLowerCase() === cat.title.toLowerCase() === streamData.category_name.toLowerCase()
streamData.category_name.toLowerCase()
) && ) &&
streamDetected streamDetected
); );
@@ -325,13 +328,11 @@ const StreamDashboardPage: React.FC = () => {
<div className="absolute z-10 w-full bg-gray-700 mt-1 rounded-md shadow-lg max-h-48 overflow-y-auto"> <div className="absolute z-10 w-full bg-gray-700 mt-1 rounded-md shadow-lg max-h-48 overflow-y-auto">
{filteredCategories.map((category) => ( {filteredCategories.map((category) => (
<div <div
key={category.category_id} key={category.title}
className="px-4 py-2 hover:bg-gray-600 cursor-pointer text-white" className="px-4 py-2 hover:bg-gray-600 cursor-pointer text-white"
onClick={() => onClick={() => handleCategorySelect(category.title)}
handleCategorySelect(category.category_name)
}
> >
{category.category_name} {category.title}
</div> </div>
))} ))}
</div> </div>
@@ -364,7 +365,7 @@ const StreamDashboardPage: React.FC = () => {
onClick={clearThumbnail} onClick={clearThumbnail}
className="absolute right-0 top-0 p-1 bg-red-500 rounded-full hover:bg-red-600 transition-colors" className="absolute right-0 top-0 p-1 bg-red-500 rounded-full hover:bg-red-600 transition-colors"
> >
<XIcon size={16} className="text-white" /> <CloseIcon size={16} className="text-white" />
</button> </button>
)} )}
</div> </div>
@@ -457,8 +458,7 @@ const StreamDashboardPage: React.FC = () => {
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<p className="text-white text-center">List Item</p> <p className="text-white text-center">List Item</p>
<ListItem <StreamListItem
type="stream"
id={1} id={1}
title={streamData.title || "Stream Title"} title={streamData.title || "Stream Title"}
username={username || ""} username={username || ""}

View File

@@ -3,12 +3,14 @@ import AuthModal from "../components/Auth/AuthModal";
import { useAuthModal } from "../hooks/useAuthModal"; import { useAuthModal } from "../hooks/useAuthModal";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import ListItem from "../components/Layout/ListItem";
import { useFollow } from "../hooks/useFollow"; import { useFollow } from "../hooks/useFollow";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import Button, { EditButton } from "../components/Input/Button"; import Button from "../components/Input/Button";
import DynamicPageContent from "../components/Layout/DynamicPageContent"; import DynamicPageContent from "../components/Layout/DynamicPageContent";
import LoadingScreen from "../components/Layout/LoadingScreen"; import LoadingScreen from "../components/Layout/LoadingScreen";
import { StreamListItem } from "../components/Layout/ListItem";
import { CameraIcon } from "lucide-react";
import { getCategoryThumbnail } from "../utils/thumbnailUtils";
interface UserProfileData { interface UserProfileData {
id: number; id: number;
@@ -34,14 +36,16 @@ const UserPage: React.FC = () => {
const { showAuthModal, setShowAuthModal } = useAuthModal(); const { showAuthModal, setShowAuthModal } = useAuthModal();
const { username: loggedInUsername } = useAuth(); const { username: loggedInUsername } = useAuth();
const { username } = useParams(); const { username } = useParams();
const [isUser, setIsUser] = useState(true);
const navigate = useNavigate(); const navigate = useNavigate();
const bgColors = { const bgColors = {
personal: "", personal: "",
streamer: "bg-gradient-radial from-[#ff00f1] via-[#0400ff] to-[#ff0000]", // offline streamer streamer:
user: "bg-gradient-radial from-[#ff00f1] via-[#0400ff] to-[#ff00f1]", "bg-gradient-radial from-[rgba(255, 0, 241, 0.5)] via-[rgba(4, 0, 255, 0.5)] to-[rgba(255, 0, 0, 0.5)]", // offline streamer
user: "bg-gradient-radial from-[rgba(255, 0, 241, 0.5)] via-[rgba(4, 0, 255, 0.5)] to-[rgba(255, 0, 241, 0.5)]",
admin: admin:
"bg-gradient-to-r from-[rgb(255,0,0)] via-transparent to-[rgb(0,0,255)]", "bg-gradient-to-r from-[rgba(255,100,100,0.5)] via-transparent to-[rgba(100,100,255,0.5)]",
}; };
useEffect(() => { useEffect(() => {
@@ -75,11 +79,10 @@ const UserPage: React.FC = () => {
currentStreamCategory: streamData.category_id, currentStreamCategory: streamData.category_id,
currentStreamViewers: streamData.num_viewers, currentStreamViewers: streamData.num_viewers,
currentStreamStartTime: streamData.start_time, currentStreamStartTime: streamData.start_time,
currentStreamThumbnail: currentStreamThumbnail: getCategoryThumbnail(
streamData.thumbnail || streamData.category_name,
`/images/category_thumbnails/${streamData.category_name streamData.thumbnail
.toLowerCase() ),
.replace(/ /g, "_")}.webp`,
}; };
}); });
let variant: "user" | "streamer" | "personal" | "admin"; let variant: "user" | "streamer" | "personal" | "admin";
@@ -105,12 +108,12 @@ const UserPage: React.FC = () => {
return ( return (
<DynamicPageContent <DynamicPageContent
className={`min-h-screen ${profileData.isLive className={`min-h-screen ${
? "bg-gradient-radial from-[#ff00f1] via-[#0400ff] to-[#2efd2d]" profileData.isLive
: bgColors[userPageVariant] ? "bg-gradient-radial from-[#1a6600] via-[#66ff66] to-[#003900]"
} text-white flex flex-col`} : bgColors[userPageVariant]
} text-white flex flex-col`}
> >
<div className="flex justify-evenly justify-self-center items-center h-full px-4 py-8 max-w-[80vw] w-full"> <div className="flex justify-evenly justify-self-center items-center h-full px-4 py-8 max-w-[80vw] w-full">
<div className="grid grid-cols-4 grid-rows-[0.1fr_4fr] w-full gap-8"> <div className="grid grid-cols-4 grid-rows-[0.1fr_4fr] w-full gap-8">
{/* Profile Section - TOP */} {/* Profile Section - TOP */}
@@ -138,12 +141,28 @@ const UserPage: React.FC = () => {
rounded-full overflow-hidden flex-shrink-0 border-4 border-[var(--user-pfp-border)] inset-0 z-20" rounded-full overflow-hidden flex-shrink-0 border-4 border-[var(--user-pfp-border)] inset-0 z-20"
style={{ boxShadow: "var(--user-pfp-border-shadow)" }} style={{ boxShadow: "var(--user-pfp-border-shadow)" }}
> >
<img <label
src="/images/monkey.png" className={`relative ${isUser ? "cursor-pointer group" : ""}`}
alt={`${profileData.username}'s profile`} >
className="sm:w-[full] h-full object-cover rounded-full <img
" src="/images/monkey.png"
/> alt={`${profileData.username}'s profile`}
className="sm:w-full h-full object-cover rounded-full"
/>
{isUser && (
<>
<div className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-full"></div>
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<CameraIcon
size={32}
className="text-white bg-black/50 p-1 rounded-full"
/>
</div>
<input type="file" className="hidden" />
</>
)}
</label>
</div> </div>
{/* Username - Now Directly Below PFP */} {/* Username - Now Directly Below PFP */}
@@ -218,10 +237,10 @@ const UserPage: React.FC = () => {
<h2 className="text-2xl bg-[#ff0000] border py-4 px-12 font-black mb-4 rounded-[4rem]"> <h2 className="text-2xl bg-[#ff0000] border py-4 px-12 font-black mb-4 rounded-[4rem]">
Currently Live! Currently Live!
</h2> </h2>
<ListItem <StreamListItem
id={profileData.id} id={profileData.id}
type="stream"
title={profileData.currentStreamTitle || ""} title={profileData.currentStreamTitle || ""}
streamCategory=""
username="" username=""
viewers={profileData.currentStreamViewers || 0} viewers={profileData.currentStreamViewers || 0}
thumbnail={profileData.currentStreamThumbnail} thumbnail={profileData.currentStreamThumbnail}
@@ -270,7 +289,8 @@ const UserPage: React.FC = () => {
} }
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = "none")} onMouseLeave={(e) => (e.currentTarget.style.boxShadow = "none")}
> >
<button className="text-[var(--follow-text)] whitespace-pre-wrap" <button
className="text-[var(--follow-text)] whitespace-pre-wrap"
onClick={() => navigate(`/user/${username}/following`)} onClick={() => navigate(`/user/${username}/following`)}
> >
Following Following
@@ -298,10 +318,11 @@ const UserPage: React.FC = () => {
} }
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = "none")} onMouseLeave={(e) => (e.currentTarget.style.boxShadow = "none")}
> >
<button onClick={() => navigate(`/user/${username}/yourCategories`)}> <button
onClick={() => navigate(`/user/${username}/yourCategories`)}
>
Categories Categories
</button> </button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,8 @@
// types/CategoryType.ts
export interface CategoryType {
type: "category";
id: number;
title: string;
viewers: number;
thumbnail?: string;
}

View File

@@ -0,0 +1,9 @@
export interface StreamType {
type: "stream";
id: number;
title: string;
username: string;
streamCategory: string;
viewers: number;
thumbnail?: string;
}

View File

@@ -0,0 +1,10 @@
// types/UserType.ts
export interface UserType {
type: "user";
id: number;
title: string;
username: string;
isLive: boolean;
viewers: number;
thumbnail?: string;
}

View File

@@ -0,0 +1,24 @@
/**
* Generates a thumbnail path for a given category name
*
* @param categoryName - The name of the category
* @param customThumbnail - Optional custom thumbnail path that takes precedence if provided
* @returns The path to the category thumbnail image
*/
export function getCategoryThumbnail(categoryName?: string, customThumbnail?: string): string {
if (customThumbnail) {
return customThumbnail;
}
if (!categoryName) {
return '/images/category_thumbnails/default.webp';
}
// Convert to lowercase, replace spaces with underscores, and remove all other special characters
const formattedName = categoryName
.toLowerCase()
.replace(/ /g, '_') // Replace spaces with underscores
.replace(/[^a-z0-9_]/g, ''); // Remove all other non-alphanumeric characters except underscores
return `/images/category_thumbnails/${formattedName}.webp`;
}

View File

@@ -1,6 +1,6 @@
from flask import Blueprint, session from flask import Blueprint, session
from database.database import Database
from utils.utils import sanitize from utils.utils import sanitize
from utils.admin_utils import *
admin_bp = Blueprint("admin", __name__) admin_bp = Blueprint("admin", __name__)
@@ -9,21 +9,13 @@ def admin_delete_user(banned_user):
# Sanitise the user input # Sanitise the user input
banned_user = sanitize(banned_user) banned_user = sanitize(banned_user)
# Create a connection to the database
db = Database()
db.create_connection()
# Check if the user is an admin # Check if the user is an admin
username = session.get("username") username = session.get("username")
is_admin = db.fetchone(""" is_admin = check_if_admin(username)
SELECT is_admin
FROM users
WHERE username = ?;
""", (username,))
# Check if the user exists # Check if the user exists
user_exists = db.fetchone("""SELECT user_id from users WHERE username = ?;""", (banned_user)) user_exists = check_if_user_exists(banned_user)
# If the user is an admin, try to delete the account # If the user is an admin, try to delete the account
if is_admin and user_exists: if is_admin and user_exists:
db.execute("""DELETE FROM users WHERE username = ?;""", (banned_user)) ban_user(banned_user)

View File

@@ -215,8 +215,7 @@ def publish_stream():
with Database() as db: with Database() as db:
user_info = db.fetchone("""SELECT user_id, username, current_stream_title, user_info = db.fetchone("""SELECT user_id, username, is_live
current_selected_category_id, is_live
FROM users FROM users
WHERE stream_key = ?""", (data['stream_key'],)) WHERE stream_key = ?""", (data['stream_key'],))

Binary file not shown.

View File

@@ -1,10 +1,10 @@
-- Sample Data for users -- Sample Data for users
INSERT INTO users (username, password, email, num_followers, stream_key, is_partnered, bio, is_live, is_admin, current_stream_title, current_selected_category_id) VALUES INSERT INTO users (username, password, email, num_followers, stream_key, is_partnered, bio, is_live, is_admin) VALUES
('GamerDude', 'password123', 'gamerdude@example.com', 500, '1234', 0, 'Streaming my gaming adventures!', 1, 0, 'Game On!', 1), ('GamerDude', 'password123', 'gamerdude@example.com', 500, '1234', 0, 'Streaming my gaming adventures!', 1, 0),
('MusicLover', 'music4life', 'musiclover@example.com', 1200, '2345', 0, 'I share my favorite tunes.', 1, 0, 'Live Music Jam', 2), ('MusicLover', 'music4life', 'musiclover@example.com', 1200, '2345', 0, 'I share my favorite tunes.', 1, 0),
('ArtFan', 'artistic123', 'artfan@example.com', 300, '3456', 0, 'Exploring the world of art.', 1, 0, 'Sketching Live', 3), ('ArtFan', 'artistic123', 'artfan@example.com', 300, '3456', 0, 'Exploring the world of art.', 1, 0),
('EduGuru', 'learn123', 'eduguru@example.com', 800, '4567', 0, 'Teaching everything I know.', 1, 0, 'Math Made Easy', 4), ('EduGuru', 'learn123', 'eduguru@example.com', 800, '4567', 0, 'Teaching everything I know.', 1, 0),
('SportsStar', 'sports123', 'sportsstar@example.com', 2000, '5678', 0, 'Join me for live sports updates!', 1, 0, 'Sports Highlights', 5); ('SportsStar', 'sports123', 'sportsstar@example.com', 2000, '5678', 0, 'Join me for live sports updates!', 1, 0);
INSERT INTO users (username, password, email, num_followers, stream_key, is_partnered, bio, is_live, is_admin) VALUES INSERT INTO users (username, password, email, num_followers, stream_key, is_partnered, bio, is_live, is_admin) VALUES
('GamerDude2', 'password123', 'gamerdude3@gmail.com', 3200, '7890', 0, 'Streaming my gaming adventures!', 0, 0), ('GamerDude2', 'password123', 'gamerdude3@gmail.com', 3200, '7890', 0, 'Streaming my gaming adventures!', 0, 0),
@@ -60,16 +60,16 @@ INSERT INTO categories (category_name) VALUES
('Dota 2'), ('Dota 2'),
('Apex Legends'), ('Apex Legends'),
('Grand Theft Auto V'), ('Grand Theft Auto V'),
('The Legend of Zelda Breath of the Wild'), ('The Legend of Zelda: Tears of the Kingdom'),
('Elden Ring'), ('Elden Ring'),
('Red Dead Redemption 2'), ('Red Dead Redemption 2'),
('Cyberpunk 2077'), ('Cyberpunk 2077'),
('Super Smash Bros Ultimate'), ('Super Smash Bros. Ultimate'),
('Overwatch 2'), ('Overwatch 2'),
('Genshin Impact'), ('Genshin Impact'),
('World of Warcraft'), ('World of Warcraft'),
('Rocket League'), ('Rocket League'),
('FIFA 24'), ('EA Sports FC 25'),
('The Sims 4'), ('The Sims 4'),
('Among Us'), ('Among Us'),
('Dead by Daylight'), ('Dead by Daylight'),

View File

@@ -10,10 +10,7 @@ CREATE TABLE users
is_partnered BOOLEAN NOT NULL DEFAULT 0, is_partnered BOOLEAN NOT NULL DEFAULT 0,
is_live BOOLEAN NOT NULL DEFAULT 0, is_live BOOLEAN NOT NULL DEFAULT 0,
bio VARCHAR(1024) DEFAULT 'This user does not have a Bio.', bio VARCHAR(1024) DEFAULT 'This user does not have a Bio.',
is_admin BOOLEAN NOT NULL DEFAULT 0, is_admin BOOLEAN NOT NULL DEFAULT 0
current_stream_title VARCHAR(100) DEFAULT 'Stream',
current_selected_category_id INTEGER DEFAULT 1
); );
SELECT * FROM users; SELECT * FROM users;

View File

@@ -0,0 +1,38 @@
from database.database import Database
def check_if_admin(username):
"""
Returns whether user is admin
"""
with Database() as db:
is_admin = db.fetchone("""
SELECT is_admin
FROM users
WHERE username = ?;
""", (username,))
return bool(is_admin)
def check_if_user_exists(banned_user):
"""
Returns whether user exists
"""
with Database() as db:
user_exists = db.fetchone("""
SELECT user_id
FROM users
WHERE username = ?;""",
(banned_user,))
return bool(user_exists)
def ban_user(banned_user):
"""
Bans a user
"""
with Database() as db:
db.execute("""
DELETE FROM users
WHERE username = ?;""",
(banned_user)
)