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

View File

@@ -33,11 +33,11 @@ body[data-theme="light"] {
--sideBar-bg: rgb(255, 255, 255);
--sideBar-text: black;
--sideBar-profile-bg: rgb(132, 0, 255);
--sideBar-profile-bg: rgb(224, 205, 241);
--sideBar-profile-text: #ffffff;
--profile-border: #ffffff;
--follow-bg: #ff0000;
--follow-bg: #aa00ff;
--follow-text: white;
--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);
--quickBar-title: #ffffff;
--quickBar-title-bg: rgb(132, 0, 255);
--quickBar-title-bg: rgb(183, 149, 214);
--quickBar-bg: #ffffff;
--quickBar-text: #000000;
--quickBar-border: #ffffff;

View File

@@ -1,6 +1,6 @@
import React, { useState } from "react";
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 RegisterForm from "./RegisterForm";
import ForgotPasswordForm from "./ForgotPasswordForm";

View File

@@ -1,6 +1,6 @@
import React, { useState } from "react";
import Input from "../Input/Input";
import Button, { ToggleButton } from "../Input/Button";
import Button from "../Input/Button";
import { useAuth } from "../../context/AuthContext";
import GoogleLogin from "./OAuth";
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 {
toggled?: boolean;
}

View File

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

View File

@@ -1,6 +1,7 @@
import React from "react";
import Navbar from "../Navigation/Navbar";
import { useSidebar } from "../../context/SidebarContext";
import Footer from "./Footer";
interface DynamicPageContentProps extends React.HTMLProps<HTMLDivElement> {
children: React.ReactNode;
@@ -20,13 +21,16 @@ const DynamicPageContent: React.FC<DynamicPageContentProps> = ({
const { showSideBar } = useSidebar();
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} />
<div
id="content"
className={`min-w-[850px] ${
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}
</div>

View File

@@ -1,11 +1,17 @@
import { MailIcon } from "lucide-react";
import { useState } from "react";
import { Mail, Facebook, Twitter, Instagram, Linkedin } from "lucide-react";
const Footer = () => {
const [email, setEmail] = useState("");
const handleKeyDown = async (event) => {
const handleKeyDown = async (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
send_newsletter();
}
}
const send_newsletter = async () => {
if (email) {
if (email.trim() === "") return;
try {
const response = await fetch(`/api/send_newsletter/${email}`,
@@ -26,41 +32,26 @@ const Footer = () => {
} catch (error) {
console.error("Error subscribing:", error);
}
}
};
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">
{/* About Section */}
<div className="flex-1 min-w-[250px]">
<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>
{/* Office Section */}
<div className="flex-1 min-w-[200px]">
<h3 className="text-lg font-semibold mb-2">Some Street</h3>
<p className="text-sm">On Some Road</p>
<p className="text-sm">Near Some Country</p>
<p className="text-sm">That is definitely on Earth</p>
<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>
<h3 className="text-lg font-semibold mb-2">Contact Us!</h3>
<a href="mailto:response.gander@gmail.com" className="underline">
response.gander@gmail.com
</a>
</div>
{/* Newsletter Section */}
@@ -75,23 +66,15 @@ const Footer = () => {
onChange={(e) => setEmail(e.target.value)}
onKeyDown={handleKeyDown}
/>
<Mail className="text-white cursor-pointer" onClick={() => handleKeyDown({ key: "Enter" })} />
<MailIcon className="text-white cursor-pointer" onClick={send_newsletter} />
</div>
</div>
{/* Social Icons */}
<div className="flex gap-4 mt-4">
<Facebook className="cursor-pointer hover:opacity-80" />
<Twitter className="cursor-pointer hover:opacity-80" />
<Instagram className="cursor-pointer hover:opacity-80" />
<Linkedin className="cursor-pointer hover:opacity-80" />
</div>
{/* Footer Bottom */}
<div className="text-center text-xs border-t border-gray-600 mt-6 pt-4">
Group 11
</div>
</div>
{/* Footer Bottom */}
<div className="text-center text-xs border-t border-gray-600 mt-6 pt-4">
Group 11
</div>
</footer>
);
};

View File

@@ -1,19 +1,18 @@
import React from "react";
import { StreamType } from "../../types/StreamType";
import { CategoryType } from "../../types/CategoryType";
import { UserType } from "../../types/UserType";
export interface ListItemProps {
type: "stream" | "category" | "user";
id: number;
title: string;
username?: string;
streamCategory?: string;
viewers: number;
thumbnail?: string;
// Base props that all item types share
interface BaseListItemProps {
onItemClick?: () => void;
extraClasses?: string;
}
const ListItem: React.FC<ListItemProps> = ({
type,
// Stream item component
interface StreamListItemProps extends BaseListItemProps, Omit<StreamType, 'type'> {}
const StreamListItem: React.FC<StreamListItemProps> = ({
title,
username,
streamCategory,
@@ -22,31 +21,6 @@ const ListItem: React.FC<ListItemProps> = ({
onItemClick,
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 (
<div className="p-4">
<div
@@ -68,10 +42,8 @@ const ListItem: React.FC<ListItemProps> = ({
<h3 className="font-semibold text-lg text-center truncate max-w-full">
{title}
</h3>
{type === "stream" && <p className="font-bold">{username}</p>}
{type === "stream" && (
<p className="text-sm text-gray-300">{streamCategory}</p>
)}
<p className="font-bold">{username}</p>
<p className="text-sm text-gray-300">{streamCategory}</p>
<p className="text-sm text-gray-300">{viewers} viewers</p>
</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 {
ArrowLeft as ArrowLeftIcon,
ArrowRight as ArrowRightIcon,
ArrowLeftIcon,
ArrowRightIcon,
} from "lucide-react";
import React, {
forwardRef,
@@ -10,14 +10,19 @@ import React, {
} from "react";
import { useNavigate } from "react-router-dom";
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 {
variant?: "default" | "search";
type: "stream" | "category" | "user";
title?: string;
description?: string;
items: ListItemProps[];
items: ItemType[];
wrap?: boolean;
onItemClick: (itemName: string) => void;
titleClickable?: boolean;
@@ -27,151 +32,179 @@ interface ListRowProps {
children?: React.ReactNode;
}
const ListRow = forwardRef<
{ addMoreItems: (newItems: ListItemProps[]) => 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();
interface ListRowRef {
addMoreItems: (newItems: ItemType[]) => void;
}
const addMoreItems = (newItems: ListItemProps[]) => {
setCurrentItems((prevItems) => [...prevItems, ...newItems]);
};
const ListRow = forwardRef<ListRowRef, ListRowProps>((props, ref) => {
const {
variant = "default",
type,
title = "",
description = "",
items,
onItemClick,
titleClickable = false,
wrap = false,
extraClasses = "",
itemExtraClasses = "",
amountForScroll = 4,
children,
} = props;
useImperativeHandle(ref, () => ({
addMoreItems,
}));
const [currentItems, setCurrentItems] = useState<ItemType[]>(items);
const slider = useRef<HTMLDivElement>(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: ItemType[]) => {
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 = () => {
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
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"
className={`text-center ${
variant === "search" ? "min-w-fit px-auto w-[15vw]" : ""
}`}
>
{/* List Details */}
<div
className={`text-center ${
variant === "search" ? "min-w-fit px-auto w-[15vw]" : ""
}`}
<h2
className={`${
titleClickable
? "cursor-pointer hover:underline"
: "cursor-default"
} text-2xl font-bold`}
onClick={titleClickable ? handleTitleClick : undefined}
>
<h2
className={`${
titleClickable
? "cursor-pointer hover:underline"
: "cursor-default"
} text-2xl font-bold`}
onClick={titleClickable ? () => handleTitleClick(type) : undefined}
>
{title}
</h2>
<p>{description}</p>
</div>
{title}
</h2>
<p>{description}</p>
</div>
{/* List Items */}
<div className="relative overflow-hidden flex flex-grow items-center z-0">
{!wrap && currentItems.length > amountForScroll && (
{/* List Items */}
<div className="relative overflow-hidden flex flex-grow items-center z-0">
{!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
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]"
/>
{currentItems.map((item) => {
if (type === "stream" && isStreamType(item)) {
return (
<StreamListItem
key={`stream-${item.id}`}
id={item.id}
title={item.title}
username={item.username}
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>
{children}
</div>
);
}
);
{children}
</div>
);
});
export default ListRow;

View File

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

View File

@@ -1,5 +1,5 @@
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 { useAuth } from "../../context/AuthContext";
import { useSidebar } from "../../context/SidebarContext";

View File

@@ -1,38 +1,38 @@
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";
const themeConfig = {
light: {
icon: Sun,
icon: SunIcon,
color: "text-yellow-400",
background: "bg-white",
hoverBg: "hover:bg-gray-100",
label: "Light Theme",
},
dark: {
icon: Moon,
icon: MoonIcon,
color: "text-white",
background: "bg-gray-800",
hoverBg: "hover:bg-gray-700",
label: "Dark Theme",
},
blue: {
icon: Droplet,
icon: DropletIcon,
color: "text-blue-500",
background: "bg-blue-50",
hoverBg: "hover:bg-blue-100",
label: "Blue Theme",
},
green: {
icon: Leaf,
icon: LeafIcon,
color: "text-green-500",
background: "bg-green-50",
hoverBg: "hover:bg-green-100",
label: "Green Theme",
},
orange: {
icon: Flame,
icon: FlameIcon,
color: "text-orange-500",
background: "bg-orange-50",
hoverBg: "hover:bg-orange-100",

View File

@@ -6,7 +6,7 @@ import { useAuthModal } from "../../hooks/useAuthModal";
import { useAuth } from "../../context/AuthContext";
import { useSocket } from "../../context/SocketContext";
import { useChat } from "../../context/ChatContext";
import { ArrowLeftFromLineIcon, ArrowRightFromLine } from "lucide-react";
import { ArrowLeftFromLineIcon, ArrowRightFromLineIcon } from "lucide-react";
interface ChatMessage {
chatter_username: string;
@@ -149,7 +149,7 @@ const ChatPanel: React.FC<ChatPanelProps> = ({
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`}
>
{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`}>
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) {
useEffect(() => {
const root = document.querySelector("#root") as HTMLElement;
const handleScroll = () => {
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) {
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 () => {
window.removeEventListener("scroll", handleScroll); // Cleanup on unmount
root.removeEventListener("scroll", handleScroll); // Cleanup on unmount
};
}, [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 { fetchContentOnScroll } from "../hooks/fetchContentOnScroll";
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 [categories, setCategories] = useState<CategoryData[]>([]);
const [categories, setCategories] = useState<CategoryType[]>([]);
const navigate = useNavigate();
const [categoryOffset, setCategoryOffset] = useState(0);
const [noCategories, setNoCategories] = useState(12);
const [hasMoreData, setHasMoreData] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const listRowRef = useRef<any>(null);
const isLoading = useRef(false);
const fetchCategories = async () => {
if (isLoading) return;
// If already loading, skip this fetch
if (isLoading.current) return;
isLoading.current = true;
try {
const response = await fetch(
`/api/categories/popular/${noCategories}/${categoryOffset}`
);
const response = await fetch(`/api/categories/popular/${noCategories}/${categoryOffset}`);
if (!response.ok) {
throw new Error("Failed to fetch categories");
}
@@ -38,25 +35,23 @@ const AllCategoriesPage: React.FC = () => {
return [];
}
setCategoryOffset((prev) => prev + data.length);
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`,
thumbnail: getCategoryThumbnail(category.category_name)
}));
setCategories((prev) => [...prev, ...processedCategories]);
setCategories(prev => [...prev, ...processedCategories]);
return processedCategories;
} catch (error) {
console.error("Error fetching categories:", error);
return [];
} finally {
setIsLoading(false);
isLoading.current = false;
}
};
@@ -83,17 +78,25 @@ const AllCategoriesPage: React.FC = () => {
};
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
ref={listRowRef}
type="category"
title="All Categories"
items={categories}
onItemClick={handleCategoryClick}
onClick={handleCategoryClick}
extraClasses="bg-[var(--recommend)] text-center"
itemExtraClasses="w-[20vw]"
wrap={true}
/>
{!hasMoreData && !categories.length && (
<div className="text-center text-gray-500 p-4">
No more categories to load
</div>
)}
</DynamicPageContent>
);
};

View File

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

View File

@@ -1,15 +1,14 @@
import React, { useState, useEffect, useRef } from "react";
import { useAuth } from "../context/AuthContext";
import { useSidebar } from "../context/SidebarContext";
import { CircleMinus, CirclePlus, Sidebar as SidebarIcon } from "lucide-react";
import { CircleMinus as RemoveIcon, CirclePlus as AddIcon } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useParams } from "react-router-dom";
import DynamicPageContent from "../components/Layout/DynamicPageContent";
import { fetchContentOnScroll } from "../hooks/fetchContentOnScroll";
import Button from "../components/Input/Button";
import { useCategoryFollow } from "../hooks/useCategoryFollow";
import { ListItemProps as StreamData } from "../components/Layout/ListItem";
import LoadingScreen from "../components/Layout/LoadingScreen";
import { getCategoryThumbnail } from "../utils/thumbnailUtils";
interface Category {
isFollowing: any;
@@ -125,14 +124,14 @@ const FollowedCategories: React.FC<FollowedCategoryProps> = ({ extraClasses = ""
onClick={() => toggleFollow(category.category_id, category.category_name)}
>
{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>
<img
src={`/images/category_thumbnails/${category.category_name.toLowerCase().replace(/ /g, "_")}.webp`}
src={getCategoryThumbnail(category.category_name)}
alt={category.category_name}
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 { Sidebar as SidebarIcon } from "lucide-react";
import { useNavigate } from "react-router-dom"; // Import useNavigate
import { CategoryType } from "../types/CategoryType";
// Define TypeScript interfaces
interface Streamer {
user_id: number;
username: string;
user_id: number;
username: string;
}
interface FollowingProps {
extraClasses?: string;
extraClasses?: string;
}
const Following: React.FC<FollowingProps> = ({ extraClasses = "" }) => {
const { showSideBar, setShowSideBar } = useSidebar();
const navigate = useNavigate();
const { username, isLoggedIn } = useAuth();
const [followedStreamers, setFollowedStreamers] = useState<Streamer[]>([]);
const { showSideBar, setShowSideBar } = useSidebar();
const navigate = useNavigate();
const { username, isLoggedIn } = useAuth();
const [followedStreamers, setFollowedStreamers] = useState<Streamer[]>([]);
// Fetch followed streamers
useEffect(() => {
const fetchFollowedStreamers = async () => {
try {
const response = await fetch("/api/user/following");
if (!response.ok) throw new Error("Failed to fetch followed streamers");
const data = await response.json();
setFollowedStreamers(data.streamers || []);
} catch (error) {
console.error("Error fetching followed streamers:", error);
}
};
if (isLoggedIn) {
fetchFollowedStreamers();
}
}, [isLoggedIn]);
// Handle sidebar toggle
const handleSideBar = () => {
setShowSideBar(!showSideBar);
// Fetch followed streamers
useEffect(() => {
const fetchFollowedStreamers = async () => {
try {
const response = await fetch("/api/user/following");
if (!response.ok) throw new Error("Failed to fetch followed streamers");
const data = await response.json();
setFollowedStreamers(data.streamers || []);
} catch (error) {
console.error("Error fetching followed streamers:", error);
}
};
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}
if (isLoggedIn) {
fetchFollowedStreamers();
}
}, [isLoggedIn]);
// Handle sidebar toggle
const handleSideBar = () => {
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 && (
<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>
{/* 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>
{/* 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}`)}
>
<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>
{/* 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>
</>
);
};
export default Following;

View File

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

View File

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

View File

@@ -2,11 +2,18 @@ import React, { useState, useEffect } from "react";
import DynamicPageContent from "../components/Layout/DynamicPageContent";
import Button from "../components/Input/Button";
import Input from "../components/Input/Input";
import ListItem from "../components/Layout/ListItem";
import { X as XIcon, Eye as ShowIcon, EyeOff as HideIcon } from "lucide-react";
import { useCategories } from "../hooks/useContent";
import {
X as CloseIcon,
Eye as ShowIcon,
EyeOff as HideIcon,
} from "lucide-react";
import { useAuth } from "../context/AuthContext";
import { debounce } from "lodash";
import VideoPlayer from "../components/Stream/VideoPlayer";
import { CategoryType } from "../types/CategoryType";
import { StreamListItem } from "../components/Layout/ListItem";
import { getCategoryThumbnail } from "../utils/thumbnailUtils";
interface StreamData {
title: string;
@@ -16,11 +23,6 @@ interface StreamData {
stream_key: string;
}
interface Category {
category_id: number;
category_name: string;
}
const StreamDashboardPage: React.FC = () => {
const { username } = useAuth();
const [isStreaming, setIsStreaming] = useState(false);
@@ -33,9 +35,10 @@ const StreamDashboardPage: React.FC = () => {
});
const [streamDetected, setStreamDetected] = useState(false);
const [timeStarted, setTimeStarted] = useState("");
const [categories, setCategories] = useState<Category[]>([]);
const [isCategoryFocused, setIsCategoryFocused] = useState(false);
const [filteredCategories, setFilteredCategories] = useState<Category[]>([]);
const [filteredCategories, setFilteredCategories] = useState<CategoryType[]>(
[]
);
const [thumbnail, setThumbnail] = useState<File | null>(null);
const [thumbnailPreview, setThumbnailPreview] = useState<{
url: string;
@@ -44,16 +47,27 @@ const StreamDashboardPage: React.FC = () => {
const [debouncedCheck, setDebouncedCheck] = useState<Function | null>(null);
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(() => {
const categoryCheck = debounce((categoryName: string) => {
const isValidCategory = categories.some(
(cat) => cat.category_name.toLowerCase() === categoryName.toLowerCase()
(cat) => cat.title.toLowerCase() === categoryName.toLowerCase()
);
if (isValidCategory && !thumbnailPreview.isCustom) {
const defaultThumbnail = `/images/thumbnails/categories/${categoryName
.toLowerCase()
.replace(/ /g, "_")}.webp`;
const defaultThumbnail = getCategoryThumbnail(categoryName);
setThumbnailPreview({ url: defaultThumbnail, isCustom: false });
}
}, 300);
@@ -66,11 +80,14 @@ const StreamDashboardPage: React.FC = () => {
}, [categories, thumbnailPreview.isCustom]);
useEffect(() => {
checkStreamStatus();
fetchCategories();
if (username) {
checkStreamStatus();
}
}, [username]);
const checkStreamStatus = async () => {
if (!username) return;
try {
const response = await fetch(`/api/user/${username}/status`);
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 { name, value } = e.target;
setStreamData((prev) => ({ ...prev, [name]: value }));
if (name === "category_name") {
const filtered = categories.filter((cat) =>
cat.category_name.toLowerCase().includes(value.toLowerCase())
cat.title.toLowerCase().includes(value.toLowerCase())
);
setFilteredCategories(filtered);
if (debouncedCheck) {
@@ -178,9 +184,7 @@ const StreamDashboardPage: React.FC = () => {
console.log(
"Clearing thumbnail as category is set and default category thumbnail will be used"
);
const defaultThumbnail = `/images/thumbnails/categories/${streamData.category_name
.toLowerCase()
.replace(/ /g, "_")}.webp`;
const defaultThumbnail = getCategoryThumbnail(streamData.category_name);
setThumbnailPreview({ url: defaultThumbnail, isCustom: false });
} else {
setThumbnailPreview({ url: "", isCustom: false });
@@ -193,8 +197,7 @@ const StreamDashboardPage: React.FC = () => {
streamData.category_name.trim() !== "" &&
categories.some(
(cat) =>
cat.category_name.toLowerCase() ===
streamData.category_name.toLowerCase()
cat.title.toLowerCase() === streamData.category_name.toLowerCase()
) &&
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">
{filteredCategories.map((category) => (
<div
key={category.category_id}
key={category.title}
className="px-4 py-2 hover:bg-gray-600 cursor-pointer text-white"
onClick={() =>
handleCategorySelect(category.category_name)
}
onClick={() => handleCategorySelect(category.title)}
>
{category.category_name}
{category.title}
</div>
))}
</div>
@@ -364,7 +365,7 @@ const StreamDashboardPage: React.FC = () => {
onClick={clearThumbnail}
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>
)}
</div>
@@ -457,8 +458,7 @@ const StreamDashboardPage: React.FC = () => {
</div>
<div className="flex flex-col">
<p className="text-white text-center">List Item</p>
<ListItem
type="stream"
<StreamListItem
id={1}
title={streamData.title || "Stream Title"}
username={username || ""}

View File

@@ -3,12 +3,14 @@ import AuthModal from "../components/Auth/AuthModal";
import { useAuthModal } from "../hooks/useAuthModal";
import { useAuth } from "../context/AuthContext";
import { useParams } from "react-router-dom";
import ListItem from "../components/Layout/ListItem";
import { useFollow } from "../hooks/useFollow";
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 LoadingScreen from "../components/Layout/LoadingScreen";
import { StreamListItem } from "../components/Layout/ListItem";
import { CameraIcon } from "lucide-react";
import { getCategoryThumbnail } from "../utils/thumbnailUtils";
interface UserProfileData {
id: number;
@@ -34,14 +36,16 @@ const UserPage: React.FC = () => {
const { showAuthModal, setShowAuthModal } = useAuthModal();
const { username: loggedInUsername } = useAuth();
const { username } = useParams();
const [isUser, setIsUser] = useState(true);
const navigate = useNavigate();
const bgColors = {
personal: "",
streamer: "bg-gradient-radial from-[#ff00f1] via-[#0400ff] to-[#ff0000]", // offline streamer
user: "bg-gradient-radial from-[#ff00f1] via-[#0400ff] to-[#ff00f1]",
streamer:
"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:
"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(() => {
@@ -75,11 +79,10 @@ const UserPage: React.FC = () => {
currentStreamCategory: streamData.category_id,
currentStreamViewers: streamData.num_viewers,
currentStreamStartTime: streamData.start_time,
currentStreamThumbnail:
streamData.thumbnail ||
`/images/category_thumbnails/${streamData.category_name
.toLowerCase()
.replace(/ /g, "_")}.webp`,
currentStreamThumbnail: getCategoryThumbnail(
streamData.category_name,
streamData.thumbnail
),
};
});
let variant: "user" | "streamer" | "personal" | "admin";
@@ -105,12 +108,12 @@ const UserPage: React.FC = () => {
return (
<DynamicPageContent
className={`min-h-screen ${profileData.isLive
? "bg-gradient-radial from-[#ff00f1] via-[#0400ff] to-[#2efd2d]"
: bgColors[userPageVariant]
} text-white flex flex-col`}
className={`min-h-screen ${
profileData.isLive
? "bg-gradient-radial from-[#1a6600] via-[#66ff66] to-[#003900]"
: 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="grid grid-cols-4 grid-rows-[0.1fr_4fr] w-full gap-8">
{/* 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"
style={{ boxShadow: "var(--user-pfp-border-shadow)" }}
>
<img
src="/images/monkey.png"
alt={`${profileData.username}'s profile`}
className="sm:w-[full] h-full object-cover rounded-full
"
/>
<label
className={`relative ${isUser ? "cursor-pointer group" : ""}`}
>
<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>
{/* 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]">
Currently Live!
</h2>
<ListItem
<StreamListItem
id={profileData.id}
type="stream"
title={profileData.currentStreamTitle || ""}
streamCategory=""
username=""
viewers={profileData.currentStreamViewers || 0}
thumbnail={profileData.currentStreamThumbnail}
@@ -270,7 +289,8 @@ const UserPage: React.FC = () => {
}
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`)}
>
Following
@@ -298,10 +318,11 @@ const UserPage: React.FC = () => {
}
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = "none")}
>
<button onClick={() => navigate(`/user/${username}/yourCategories`)}>
<button
onClick={() => navigate(`/user/${username}/yourCategories`)}
>
Categories
</button>
</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 database.database import Database
from utils.utils import sanitize
from utils.admin_utils import *
admin_bp = Blueprint("admin", __name__)
@@ -9,21 +9,13 @@ def admin_delete_user(banned_user):
# Sanitise the user input
banned_user = sanitize(banned_user)
# Create a connection to the database
db = Database()
db.create_connection()
# Check if the user is an admin
username = session.get("username")
is_admin = db.fetchone("""
SELECT is_admin
FROM users
WHERE username = ?;
""", (username,))
is_admin = check_if_admin(username)
# 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 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:
user_info = db.fetchone("""SELECT user_id, username, current_stream_title,
current_selected_category_id, is_live
user_info = db.fetchone("""SELECT user_id, username, is_live
FROM users
WHERE stream_key = ?""", (data['stream_key'],))

Binary file not shown.

View File

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

View File

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