From 0a4bbc73e523962224f574ad376afead629f713e Mon Sep 17 00:00:00 2001 From: Chris-1010 <122332721@umail.ucc.ie> Date: Sun, 2 Mar 2025 14:45:37 +0000 Subject: [PATCH] MAJOR UPDATE/FEAT: Overhaul of `DashboardPage` to include VODs; REFACTOR: General formatting; UPDATE: Shrink Logo upon opening sidebar on certain pages --- frontend/src/App.tsx | 148 +++--- frontend/src/components/Auth/RegisterForm.tsx | 1 - .../components/Layout/DynamicPageContent.tsx | 3 +- frontend/src/components/Layout/ListItem.tsx | 298 +++++------ frontend/src/components/Layout/ListRow.tsx | 377 +++++++------- frontend/src/components/Navigation/Navbar.tsx | 186 ++++--- frontend/src/components/Stream/ChatPanel.tsx | 453 ++++++++--------- .../src/components/Stream/StreamDashboard.tsx | 389 ++++++++++++++ .../src/components/Stream/VodsDashboard.tsx | 37 ++ frontend/src/context/AuthContext.tsx | 28 +- frontend/src/hooks/useContent.ts | 249 +++++---- frontend/src/pages/DashboardPage.tsx | 100 ++++ frontend/src/pages/StreamDashboardPage.tsx | 480 ------------------ frontend/src/types/VodType.ts | 11 +- web_server/utils/stream_utils.py | 4 +- 15 files changed, 1333 insertions(+), 1431 deletions(-) create mode 100644 frontend/src/components/Stream/StreamDashboard.tsx create mode 100644 frontend/src/components/Stream/VodsDashboard.tsx create mode 100644 frontend/src/pages/DashboardPage.tsx delete mode 100644 frontend/src/pages/StreamDashboardPage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0017039..8861fe8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,101 +11,77 @@ import CategoriesPage from "./pages/AllCategoriesPage"; import ResultsPage from "./pages/ResultsPage"; import { SidebarProvider } from "./context/SidebarContext"; import { QuickSettingsProvider } from "./context/QuickSettingsContext"; -import StreamDashboardPage from "./pages/StreamDashboardPage"; +import DashboardPage from "./pages/DashboardPage"; import { Brightness } from "./context/BrightnessContext"; import LoadingScreen from "./components/Layout/LoadingScreen"; import Following from "./pages/Following"; import FollowedCategories from "./pages/FollowedCategories"; function App() { - const [isLoggedIn, setIsLoggedIn] = useState(false); - const [userId, setUserId] = useState(null); - const [username, setUsername] = useState(null); - const [isLoading, setIsLoading] = useState(true); + const [isLoggedIn, setIsLoggedIn] = useState(false); + const [username, setUsername] = useState(null); + const [userId, setUserId] = useState(null); + const [isLive, setIsLive] = useState(false); + const [isLoading, setIsLoading] = useState(true); - useEffect(() => { - fetch("/api/user/login_status") - .then((response) => response.json()) - .then((data) => { - setUserId(data.user_id); - setIsLoggedIn(data.status); - setUsername(data.username); - }) - .catch((error) => { - console.error("Error fetching login status:", error); - setIsLoggedIn(false); - }) - .finally(() => { - setIsLoading(false); - }); - }, []); + useEffect(() => { + fetch("/api/user/login_status") + .then((response) => response.json()) + .then((data) => { + setUserId(data.user_id); + setIsLoggedIn(data.status); + setUsername(data.username); + }) + .catch((error) => { + console.error("Error fetching login status:", error); + setIsLoggedIn(false); + }) + .finally(() => { + setIsLoading(false); + }); + }, []); - if (isLoading) { - return ; - } + if (isLoading) { + return ; + } - return ( - - - - - - - - ) : ( - - ) - } - /> - - ) : ( - - ) - } - /> - } /> - } /> - } - > - } - > - } - > - }> - } /> - } /> - } /> - } /> - - - - - - - ); + return ( + + + + + + + } /> + : } /> + } /> + } /> + }> + }> + }> + }> + } /> + } /> + } /> + } /> + + + + + + + ); } export default App; diff --git a/frontend/src/components/Auth/RegisterForm.tsx b/frontend/src/components/Auth/RegisterForm.tsx index 43b3bef..87015f1 100644 --- a/frontend/src/components/Auth/RegisterForm.tsx +++ b/frontend/src/components/Auth/RegisterForm.tsx @@ -78,7 +78,6 @@ const RegisterForm: React.FC = ({ onSubmit }) => { const data = await response.json(); if (data.account_created) { - //TODO Handle successful registration (e.g., redirect or show success message) console.log("Registration Successful! Account created successfully"); setIsLoggedIn(true); window.location.reload(); diff --git a/frontend/src/components/Layout/DynamicPageContent.tsx b/frontend/src/components/Layout/DynamicPageContent.tsx index 8a8049f..4779643 100644 --- a/frontend/src/components/Layout/DynamicPageContent.tsx +++ b/frontend/src/components/Layout/DynamicPageContent.tsx @@ -1,7 +1,6 @@ import React from "react"; import Navbar from "../Navigation/Navbar"; import { useSidebar } from "../../context/SidebarContext"; -import Footer from "./Footer"; interface DynamicPageContentProps extends React.HTMLProps { children: React.ReactNode; @@ -28,7 +27,7 @@ const DynamicPageContent: React.FC = ({
diff --git a/frontend/src/components/Layout/ListItem.tsx b/frontend/src/components/Layout/ListItem.tsx index dca0440..2fb8713 100644 --- a/frontend/src/components/Layout/ListItem.tsx +++ b/frontend/src/components/Layout/ListItem.tsx @@ -3,186 +3,164 @@ import { StreamType } from "../../types/StreamType"; import { CategoryType } from "../../types/CategoryType"; import { UserType } from "../../types/UserType"; import { VodType } from "../../types/VodType"; +import { DownloadIcon, UploadIcon } from "lucide-react"; // Base props that all item types share interface BaseListItemProps { - onItemClick?: () => void; - extraClasses?: string; + onItemClick?: () => void; + extraClasses?: string; } - + // Stream item component -interface StreamListItemProps extends BaseListItemProps, Omit {} +interface StreamListItemProps extends BaseListItemProps, Omit {} const StreamListItem: React.FC = ({ - title, - username, - streamCategory, - viewers, - thumbnail, - onItemClick, - extraClasses = "", + title, + username, + streamCategory, + viewers, + thumbnail, + onItemClick, + extraClasses = "", }) => { - return ( -
-
-
- {thumbnail ? ( - {title} - ) : ( -
- )} -
-
-

- {title} -

-

{username}

-

{streamCategory}

-

{viewers} viewers

-
-
-
- ); + return ( +
+
+
+ {thumbnail ? ( + {title} + ) : ( +
+ )} +
+
+

{title}

+

{username}

+

{streamCategory}

+

{viewers} viewers

+
+
+
+ ); }; // Category item component -interface CategoryListItemProps extends BaseListItemProps, Omit {} +interface CategoryListItemProps extends BaseListItemProps, Omit {} -const CategoryListItem: React.FC = ({ - title, - viewers, - thumbnail, - onItemClick, - extraClasses = "", -}) => { - return ( -
-
-
- {thumbnail ? ( - {title} - ) : ( -
- )} -
-
-

- {title} -

-

{viewers} viewers

-
-
-
- ); +const CategoryListItem: React.FC = ({ title, viewers, thumbnail, onItemClick, extraClasses = "" }) => { + return ( +
+
+
+ {thumbnail ? ( + {title} + ) : ( +
+ )} +
+
+

{title}

+

{viewers} viewers

+
+
+
+ ); }; // User item component -interface UserListItemProps extends BaseListItemProps, Omit {} +interface UserListItemProps extends BaseListItemProps, Omit {} -const UserListItem: React.FC = ({ - title, - username, - isLive, - onItemClick, - extraClasses = "", -}) => { - return ( -
-
- = ({ title, username, isLive, onItemClick, extraClasses = "" }) => { + return ( +
+
+ {`user - + alt={`user ${username}`} + className="rounded-xl border-[0.15em] border-[var(--bg-color)] cursor-pointer" + /> + - {isLive && ( -

- Currently Live! -

- )} -
-
- ); + {isLive && ( +

+ Currently Live! +

+ )} +
+
+ ); }; // VODs item component -interface VodListItemProps extends BaseListItemProps, Omit {} - -const VodListItem: React.FC = ({ - title, - streamer, - datetime, - category, - length, - views, - thumbnail, - url, - onItemClick, - extraClasses = "", -}) => { - return ( -
-
window.open(url, "_blank")} - > -
- {thumbnail ? ( - {title} - ) : ( -
- )} -
-
-

- {title} -

-

{streamer}

-

{category}

-

{new Date(datetime).toLocaleDateString()} | {length} mins

-

{views} views

-
-
-
- ); -}; - -// 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; +interface VodListItemProps extends BaseListItemProps, Omit { + variant?: string; } -export { StreamListItem, CategoryListItem, UserListItem, VodListItem }; \ No newline at end of file +const VodListItem: React.FC = ({ + title, + username, + category_name, + views, + length, + datetime, + thumbnail, + onItemClick, + extraClasses = "", + variant, +}) => { + return ( +
+
+
+ {/* Thumbnail */} + {title} + + {/* Duration badge */} +
{length}
+
+ +
+

{title}

+

{username}

+

{category_name}

+
+

{datetime}

+

{views} views

+
+
+
+ {variant === "vodDashboard" && ( +
+ + +
+ )} +
+ ); +}; + +export { StreamListItem, CategoryListItem, UserListItem, VodListItem }; diff --git a/frontend/src/components/Layout/ListRow.tsx b/frontend/src/components/Layout/ListRow.tsx index 0f7af82..b44c54f 100644 --- a/frontend/src/components/Layout/ListRow.tsx +++ b/frontend/src/components/Layout/ListRow.tsx @@ -1,232 +1,201 @@ -import { - ArrowLeftIcon, - ArrowRightIcon, -} from "lucide-react"; -import React, { - forwardRef, - useImperativeHandle, - useRef, - useState, -} from "react"; +import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; +import React, { forwardRef, useImperativeHandle, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import "../../assets/styles/listRow.css"; import { StreamListItem, CategoryListItem, UserListItem, VodListItem } from "./ListItem"; import { StreamType } from "../../types/StreamType"; import { CategoryType } from "../../types/CategoryType"; import { UserType } from "../../types/UserType"; -import { VodType } from "../../types/VodType" +import { VodType } from "../../types/VodType"; type ItemType = StreamType | CategoryType | UserType | VodType; interface ListRowProps { - variant?: "default" | "search"; - type: "stream" | "category" | "user" | "vod"; - title?: string; - description?: string; - items: ItemType[]; - wrap?: boolean; - onItemClick: (itemName: string) => void; - titleClickable?: boolean; - extraClasses?: string; - itemExtraClasses?: string; - amountForScroll?: number; - children?: React.ReactNode; + variant?: "default" | "search" | "vodDashboard"; + type: "stream" | "category" | "user" | "vod"; + title?: string; + description?: string; + items: ItemType[]; + wrap?: boolean; + onItemClick: (itemName: string) => void; + titleClickable?: boolean; + extraClasses?: string; + itemExtraClasses?: string; + amountForScroll?: number; + children?: React.ReactNode; } interface ListRowRef { - addMoreItems: (newItems: ItemType[]) => void; + addMoreItems: (newItems: ItemType[]) => void; } const ListRow = forwardRef((props, ref) => { - const { - variant = "default", - type, - title = "", - description = "", - items, - onItemClick, - titleClickable = false, - wrap = false, - extraClasses = "", - itemExtraClasses = "", - amountForScroll = 4, - children, - } = props; - - const [currentItems, setCurrentItems] = useState(items); - const slider = useRef(null); - const scrollAmount = window.innerWidth * 0.3; - const navigate = useNavigate(); + const { + variant = "default", + type, + title = "", + description = "", + items, + onItemClick, + titleClickable = false, + wrap = false, + extraClasses = "", + itemExtraClasses = "", + amountForScroll = 4, + children, + } = props; - const addMoreItems = (newItems: ItemType[]) => { - setCurrentItems((prevItems) => [...prevItems, ...newItems]); - }; + const [currentItems, setCurrentItems] = useState(items); + const slider = useRef(null); + const scrollAmount = window.innerWidth * 0.3; + const navigate = useNavigate(); - useImperativeHandle(ref, () => ({ - addMoreItems, - })); + const addMoreItems = (newItems: ItemType[]) => { + setCurrentItems((prevItems) => [...prevItems, ...newItems]); + }; - const slideRight = () => { - if (!wrap && slider.current) { - slider.current.scrollBy({ left: +scrollAmount, behavior: "smooth" }); - } - }; + useImperativeHandle(ref, () => ({ + addMoreItems, + })); - const slideLeft = () => { - if (!wrap && slider.current) { - slider.current.scrollBy({ left: -scrollAmount, behavior: "smooth" }); - } - }; + const slideRight = () => { + 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 slideLeft = () => { + if (!wrap && slider.current) { + slider.current.scrollBy({ left: -scrollAmount, behavior: "smooth" }); + } + }; - 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"; + const handleTitleClick = () => { + switch (type) { + case "stream": + break; + case "category": + navigate("/categories"); + break; + case "user": + break; + default: + break; + } + }; - const isVodType = (item: ItemType): item is VodType => - item.type === "vod"; + const isStreamType = (item: ItemType): item is StreamType => item.type === "stream"; - return ( -
- {/* List Details */} -
-

- {title} -

-

{description}

-
+ const isCategoryType = (item: ItemType): item is CategoryType => item.type === "category"; - {/* List Items */} -
- {!wrap && currentItems.length > amountForScroll && ( - <> - - - - )} + const isUserType = (item: ItemType): item is UserType => item.type === "user"; -
- {currentItems.length === 0 ? ( -

Nothing Found

- ) : ( - <> - {currentItems.map((item) => { - if (type === "stream" && isStreamType(item)) { - return ( - onItemClick(item.username)} - extraClasses={itemExtraClasses} - /> - ); - } else if (type === "category" && isCategoryType(item)) { - return ( - onItemClick(item.title)} - extraClasses={itemExtraClasses} - /> - ); - } else if (type === "user" && isUserType(item)) { - return ( - onItemClick(item.username)} - extraClasses={itemExtraClasses} - /> - ); - } - else if (type === "vod" && isVodType(item)) { - return ( - window.open(item.url, "_blank")} - extraClasses={itemExtraClasses} - /> - ); - } - return null; - })} - - )} -
-
- {children} -
- ); + const isVodType = (item: ItemType): item is VodType => item.type === "vod"; + + return ( +
+ {/* List Details */} +
+

+ {title} +

+

{description}

+
+ + {/* List Items */} +
+ {!wrap && currentItems.length > amountForScroll && ( + <> + + + + )} + +
+ {currentItems.length === 0 ? ( +

Nothing Found

+ ) : ( + <> + {currentItems.map((item) => { + if (type === "stream" && isStreamType(item)) { + return ( + onItemClick(item.username)} + extraClasses={itemExtraClasses} + /> + ); + } else if (type === "category" && isCategoryType(item)) { + return ( + onItemClick(item.title)} + extraClasses={itemExtraClasses} + /> + ); + } else if (type === "user" && isUserType(item)) { + return ( + onItemClick(item.username)} + extraClasses={itemExtraClasses} + /> + ); + } else if (type === "vod" && isVodType(item)) { + return ( + onItemClick(item.vod_id.toString())} + extraClasses={itemExtraClasses} + variant={variant} + /> + ); + } + return null; + })} + + )} +
+
+ {children} +
+ ); }); -export default ListRow; \ No newline at end of file +export default ListRow; diff --git a/frontend/src/components/Navigation/Navbar.tsx b/frontend/src/components/Navigation/Navbar.tsx index 77f9347..542f93b 100644 --- a/frontend/src/components/Navigation/Navbar.tsx +++ b/frontend/src/components/Navigation/Navbar.tsx @@ -1,12 +1,7 @@ import React, { useEffect } from "react"; import Logo from "../Layout/Logo"; import Button, { ToggleButton } from "../Input/Button"; -import { - LogInIcon, - LogOutIcon, - SettingsIcon, - Radio as LiveIcon, -} from "lucide-react"; +import { LogInIcon, LogOutIcon, SettingsIcon, Radio as LiveIcon } from "lucide-react"; import SearchBar from "../Input/SearchBar"; import AuthModal from "../Auth/AuthModal"; import { useAuthModal } from "../../hooks/useAuthModal"; @@ -17,113 +12,102 @@ import { useQuickSettings } from "../../context/QuickSettingsContext"; import Sidebar from "./Sidebar"; interface NavbarProps { - variant?: "home" | "no-searchbar" | "default"; + variant?: "home" | "no-searchbar" | "default"; } const Navbar: React.FC = ({ variant = "default" }) => { - const { isLoggedIn } = useAuth(); - const { showAuthModal, setShowAuthModal } = useAuthModal(); - const { showSideBar } = useSidebar(); - const { showQuickSettings, setShowQuickSettings } = useQuickSettings(); - const [justToggled, setJustToggled] = React.useState(false); + const { isLoggedIn } = useAuth(); + const { showAuthModal, setShowAuthModal } = useAuthModal(); + const { showSideBar } = useSidebar(); + const { showQuickSettings, setShowQuickSettings } = useQuickSettings(); + const [justToggled, setJustToggled] = React.useState(false); - const handleLogout = () => { - console.log("Logging out..."); - fetch("/api/logout") - .then((response) => response.json()) - .then((data) => { - console.log(data); - window.location.reload(); - }); - }; + const handleLogout = () => { + console.log("Logging out..."); + fetch("/api/logout") + .then((response) => response.json()) + .then((data) => { + console.log(data); + window.location.reload(); + }); + }; - const handleQuickSettings = () => { - setShowQuickSettings(!showQuickSettings); - setJustToggled(true); - setTimeout(() => setJustToggled(false), 200); - }; + const handleQuickSettings = () => { + setShowQuickSettings(!showQuickSettings); + setJustToggled(true); + setTimeout(() => setJustToggled(false), 200); + }; - // Keyboard shortcut to toggle sidebar - useEffect(() => { - const handleKeyPress = (e: KeyboardEvent) => { - if (e.key === "q" && document.activeElement == document.body) - handleQuickSettings(); - }; + // Keyboard shortcut to toggle sidebar + useEffect(() => { + const handleKeyPress = (e: KeyboardEvent) => { + if (e.key === "q" && document.activeElement == document.body) handleQuickSettings(); + }; - document.addEventListener("keydown", handleKeyPress); + document.addEventListener("keydown", handleKeyPress); - return () => { - document.removeEventListener("keydown", handleKeyPress); - }; - }, [showQuickSettings]); + return () => { + document.removeEventListener("keydown", handleKeyPress); + }; + }, [showQuickSettings]); - return ( - + ); }; export default Navbar; diff --git a/frontend/src/components/Stream/ChatPanel.tsx b/frontend/src/components/Stream/ChatPanel.tsx index 0e3eb46..42e51f7 100644 --- a/frontend/src/components/Stream/ChatPanel.tsx +++ b/frontend/src/components/Stream/ChatPanel.tsx @@ -6,282 +6,247 @@ import { useAuthModal } from "../../hooks/useAuthModal"; import { useAuth } from "../../context/AuthContext"; import { useSocket } from "../../context/SocketContext"; import { useChat } from "../../context/ChatContext"; -import { - ArrowLeftFromLineIcon, - ArrowRightFromLineIcon, - CrownIcon, -} from "lucide-react"; +import { ArrowLeftFromLineIcon, ArrowRightFromLineIcon, CrownIcon } from "lucide-react"; interface ChatMessage { - chatter_username: string; - message: string; - time_sent: string; - is_subscribed: boolean; + chatter_username: string; + message: string; + time_sent: string; + is_subscribed: boolean; } interface ChatPanelProps { - streamId: number; - onViewerCountChange?: (count: number) => void; + streamId: number; + onViewerCountChange?: (count: number) => void; } -const ChatPanel: React.FC = ({ - streamId, - onViewerCountChange, -}) => { - const { isLoggedIn, username, userId } = useAuth(); - const { showChat, setShowChat } = useChat(); - const { showAuthModal, setShowAuthModal } = useAuthModal(); - const { socket, isConnected } = useSocket(); - const [messages, setMessages] = useState([]); - const [inputMessage, setInputMessage] = useState(""); - const chatContainerRef = useRef(null); - const [justToggled, setJustToggled] = useState(false); +const ChatPanel: React.FC = ({ streamId, onViewerCountChange }) => { + const { isLoggedIn, username, userId } = useAuth(); + const { showChat, setShowChat } = useChat(); + const { showAuthModal, setShowAuthModal } = useAuthModal(); + const { socket, isConnected } = useSocket(); + const [messages, setMessages] = useState([]); + const [inputMessage, setInputMessage] = useState(""); + const chatContainerRef = useRef(null); + const [justToggled, setJustToggled] = useState(false); - // Join chat room when component mounts - useEffect(() => { - if (socket && isConnected) { - // Add username check - socket.emit("join", { - userId: userId ? userId : null, - username: username ? username : "Guest", - stream_id: streamId, - }); + // Join chat room when component mounts + useEffect(() => { + if (socket && isConnected) { + // Add username check + socket.emit("join", { + userId: userId ? userId : null, + username: username ? username : "Guest", + stream_id: streamId, + }); - // Handle beforeunload event - const handleBeforeUnload = () => { - socket.emit("leave", { - userId: userId ? userId : null, - username: username ? username : "Guest", - stream_id: streamId, - }); - socket.disconnect(); - }; + // Handle beforeunload event + const handleBeforeUnload = () => { + socket.emit("leave", { + userId: userId ? userId : null, + username: username ? username : "Guest", + stream_id: streamId, + }); + socket.disconnect(); + }; - window.addEventListener("beforeunload", handleBeforeUnload); + window.addEventListener("beforeunload", handleBeforeUnload); - // Load initial chat history - fetch(`/api/chat/${streamId}`) - .then((response) => { - if (!response.ok) throw new Error("Failed to fetch chat history"); - return response.json(); - }) - .then((data) => { - if (data.chat_history) { - setMessages(data.chat_history); - } - }) - .catch((error) => { - console.error("Error loading chat history:", error); - }); + // Load initial chat history + fetch(`/api/chat/${streamId}`) + .then((response) => { + if (!response.ok) throw new Error("Failed to fetch chat history"); + return response.json(); + }) + .then((data) => { + if (data.chat_history) { + setMessages(data.chat_history); + } + }) + .catch((error) => { + console.error("Error loading chat history:", error); + }); - // Handle incoming messages - socket.on("new_message", (data: ChatMessage) => { - setMessages((prev) => [...prev, data]); - }); + // Handle incoming messages + socket.on("new_message", (data: ChatMessage) => { + setMessages((prev) => [...prev, data]); + }); - // Handle live viewership - socket.on("status", (data: any) => { - if (onViewerCountChange && data.num_viewers) { - onViewerCountChange(data.num_viewers); - } - }); + // Handle live viewership + socket.on("status", (data: any) => { + if (onViewerCountChange && data.num_viewers) { + onViewerCountChange(data.num_viewers); + } + }); - // Cleanup function - return () => { - window.removeEventListener("beforeunload", handleBeforeUnload); - socket.emit("leave", { stream_id: streamId }); - socket.disconnect(); - }; - } - }, [socket, isConnected, userId, username, streamId]); + // Cleanup function + return () => { + window.removeEventListener("beforeunload", handleBeforeUnload); + socket.emit("leave", { stream_id: streamId }); + socket.disconnect(); + }; + } + }, [socket, isConnected, userId, username, streamId]); - // Auto-scroll to bottom when new messages arrive - useEffect(() => { - if (chatContainerRef.current) { - chatContainerRef.current.scrollTop = - chatContainerRef.current.scrollHeight; - } - }, [messages]); + // Auto-scroll to bottom when new messages arrive + useEffect(() => { + if (chatContainerRef.current) { + chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight; + } + }, [messages]); - // Keyboard shortcut to toggle chat - useEffect(() => { - const handleKeyPress = (e: KeyboardEvent) => { - if (e.key === "c" && document.activeElement == document.body) { - toggleChat(); - } - }; + // Keyboard shortcut to toggle chat + useEffect(() => { + const handleKeyPress = (e: KeyboardEvent) => { + if (e.key === "c" && document.activeElement == document.body) { + toggleChat(); + } + }; - document.addEventListener("keydown", handleKeyPress); + document.addEventListener("keydown", handleKeyPress); - return () => { - document.removeEventListener("keydown", handleKeyPress); - }; - }, [showChat]); + return () => { + document.removeEventListener("keydown", handleKeyPress); + }; + }, [showChat]); - const toggleChat = () => { - setShowChat(!showChat); - setJustToggled(true); - setTimeout(() => setJustToggled(false), 200); - }; + const toggleChat = () => { + setShowChat(!showChat); + setJustToggled(true); + setTimeout(() => setJustToggled(false), 200); + }; - const sendChat = () => { - if (!inputMessage.trim() || !socket || !isConnected) { - console.log("Invalid message or socket not connected"); - return; - } + const sendChat = () => { + if (!inputMessage.trim() || !socket || !isConnected) { + console.log("Invalid message or socket not connected"); + return; + } - socket.emit("send_message", { - username: username, - stream_id: streamId, - message: inputMessage.trim(), - }); + socket.emit("send_message", { + username: username, + stream_id: streamId, + message: inputMessage.trim(), + }); - setInputMessage(""); - }; + setInputMessage(""); + }; - const handleKeyPress = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - sendChat(); - } - }; + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + sendChat(); + } + }; - return ( -
- {/* Toggle Button for Chat */} - + + Press C + + - {/* Chat Header */} -

- Stream Chat -

+ {/* Chat Header */} +

Stream Chat

- {/* Message List */} -
- {messages.map((msg, index) => ( -
- {/* User avatar with image */} -
- msg.chatter_username === username - ? null - : (window.location.href = `/user/${msg.chatter_username}`) - } - > - + {messages.map((msg, index) => ( +
+ {/* User avatar with image */} +
(msg.chatter_username === username ? null : (window.location.href = `/user/${msg.chatter_username}`))} + > + User Avatar -
+ alt="User Avatar" + className="w-full h-full object-cover" + style={{ width: "2.5em", height: "2.5em" }} + /> +
-
-
- {/* Username */} - - msg.chatter_username === username - ? null - : (window.location.href = `/user/${msg.chatter_username}`) - } - > - {msg.chatter_username} - {msg.is_subscribed && } - -
- {/* Message content */} -
- {msg.message} -
-
+
+
+ {/* Username */} + (msg.chatter_username === username ? null : (window.location.href = `/user/${msg.chatter_username}`))} + > + {msg.chatter_username} + {msg.is_subscribed && } + +
+ {/* Message content */} +
{msg.message}
+
- {/* Time sent */} -
- {new Date(msg.time_sent).toLocaleTimeString("en-GB", { - hour12: false, - hour: "2-digit", - minute: "2-digit", - })} -
-
- ))} -
+ {/* Time sent */} +
+ {new Date(msg.time_sent).toLocaleTimeString("en-GB", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + })} +
+
+ ))} +
- {/* Input area */} -
- {isLoggedIn ? ( - <> - setInputMessage(e.target.value)} - onKeyDown={handleKeyPress} - placeholder="Type a message..." - extraClasses="flex-grow w-full focus:w-full" - maxLength={200} - onClick={() => !isLoggedIn && setShowAuthModal(true)} - /> + {/* Input area */} +
+ {isLoggedIn ? ( + <> + setInputMessage(e.target.value)} + onKeyDown={handleKeyPress} + placeholder="Type a message..." + extraClasses="flex-grow w-full focus:w-full" + maxLength={200} + onClick={() => !isLoggedIn && setShowAuthModal(true)} + /> - - - ) : ( - - )} -
- {showAuthModal && setShowAuthModal(false)} />} -
- ); + + + ) : ( + + )} +
+ {showAuthModal && setShowAuthModal(false)} />} +
+ ); }; export default ChatPanel; diff --git a/frontend/src/components/Stream/StreamDashboard.tsx b/frontend/src/components/Stream/StreamDashboard.tsx new file mode 100644 index 0000000..0c9aaa0 --- /dev/null +++ b/frontend/src/components/Stream/StreamDashboard.tsx @@ -0,0 +1,389 @@ +import React, { useState, useEffect } from "react"; +import Button from "../../components/Input/Button"; +import Input from "../../components/Input/Input"; +import { useCategories } from "../../hooks/useContent"; +import { X as CloseIcon, Eye as ShowIcon, EyeOff as HideIcon } from "lucide-react"; +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; + category_name: string; + viewer_count: number; + start_time: string; + stream_key: string; +} + +interface StreamDashboardProps { + username: string; + userId: number; + isLive: boolean; +} + +const StreamDashboard: React.FC = ({ username, userId, isLive }) => { + const [streamData, setStreamData] = useState({ + title: "", + category_name: "", + viewer_count: 0, + start_time: "", + stream_key: "", + }); + const [timeStarted, setTimeStarted] = useState(""); + const [streamDetected, setStreamDetected] = useState(false); + const [isCategoryFocused, setIsCategoryFocused] = useState(false); + const [filteredCategories, setFilteredCategories] = useState([]); + const [thumbnail, setThumbnail] = useState(null); + const [thumbnailPreview, setThumbnailPreview] = useState<{ + url: string; + isCustom: boolean; + }>({ url: "", isCustom: false }); + const [debouncedCheck, setDebouncedCheck] = useState(null); + const [showKey, setShowKey] = useState(false); + + const { categories } = 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: CategoryType) => cat.title.toLowerCase() === categoryName.toLowerCase()); + + if (isValidCategory && !thumbnailPreview.isCustom) { + const defaultThumbnail = getCategoryThumbnail(categoryName); + setThumbnailPreview({ url: defaultThumbnail, isCustom: false }); + } + }, 300); + + setDebouncedCheck(() => categoryCheck); + + return () => { + categoryCheck.cancel(); + }; + }, [categories, thumbnailPreview.isCustom]); + + useEffect(() => { + if (username) { + checkStreamStatus(); + } + }, [username]); + + const checkStreamStatus = async () => { + try { + if (isLive) { + const streamResponse = await fetch(`/api/streams/${userId}/data`, { credentials: "include" }); + const streamData = await streamResponse.json(); + setStreamData({ + title: streamData.title, + category_name: streamData.category_name, + viewer_count: streamData.num_viewers, + start_time: streamData.start_time, + stream_key: streamData.stream_key, + }); + + const time = Math.floor( + (Date.now() - new Date(streamData.start_time).getTime()) / 60000 // Convert to minutes + ); + + if (time < 60) setTimeStarted(`${time}m ago`); + else if (time < 1440) setTimeStarted(`${Math.floor(time / 60)}h ${time % 60}m ago`); + else setTimeStarted(`${Math.floor(time / 1440)}d ${Math.floor((time % 1440) / 60)}h ${time % 60}m ago`); + } else { + // Just need the stream key if not streaming + const response = await fetch(`/api/user/${username}/stream_key`, { credentials: "include" }); + const keyData = await response.json(); + setStreamData((prev) => ({ + ...prev, + stream_key: keyData.stream_key, + })); + } + } catch (error) { + console.error("Error checking stream status:", error); + } + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setStreamData((prev) => ({ ...prev, [name]: value })); + + if (name === "category_name") { + const filtered = categories.filter((cat: CategoryType) => cat.title.toLowerCase().includes(value.toLowerCase())); + setFilteredCategories(filtered); + if (debouncedCheck) { + debouncedCheck(value); + } + } + }; + + const handleCategorySelect = (categoryName: string) => { + console.log("Selected category:", categoryName); + setStreamData((prev) => ({ ...prev, category_name: categoryName })); + setFilteredCategories([]); + if (debouncedCheck) { + debouncedCheck(categoryName); + } + }; + + const handleThumbnailChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files[0]) { + const file = e.target.files[0]; + setThumbnail(file); + setThumbnailPreview({ + url: URL.createObjectURL(file), + isCustom: true, + }); + } else { + setThumbnail(null); + if (streamData.category_name && debouncedCheck) { + debouncedCheck(streamData.category_name); + } else { + setThumbnailPreview({ url: "", isCustom: false }); + } + } + }; + + const clearThumbnail = () => { + setThumbnail(null); + if (streamData.category_name) { + console.log("Clearing thumbnail as category is set and default category thumbnail will be used"); + const defaultThumbnail = getCategoryThumbnail(streamData.category_name); + setThumbnailPreview({ url: defaultThumbnail, isCustom: false }); + } else { + setThumbnailPreview({ url: "", isCustom: false }); + } + }; + + const isFormValid = () => { + return ( + streamData.title.trim() !== "" && + streamData.category_name.trim() !== "" && + categories.some((cat: CategoryType) => cat.title.toLowerCase() === streamData.category_name.toLowerCase()) && + streamDetected + ); + }; + + const handlePublishStream = async () => { + console.log("Starting stream with data:", streamData); + + const formData = new FormData(); + formData.append("data", JSON.stringify(streamData)); + + try { + const response = await fetch("/api/publish_stream", { + method: "POST", + body: formData, + }); + + if (response.ok) { + console.log("Stream published successfully"); + window.location.reload(); + } else if (response.status === 403) { + console.error("Unauthorized - Invalid stream key or already streaming"); + } else { + console.error("Failed to publish stream"); + } + } catch (error) { + console.error("Error publishing stream:", error); + } + }; + + const handleUpdateStream = async () => { + console.log("Updating stream with data:", streamData); + + const formData = new FormData(); + formData.append("key", streamData.stream_key); + formData.append("title", streamData.title); + formData.append("category_name", streamData.category_name); + if (thumbnail) { + formData.append("thumbnail", thumbnail); + } + + try { + const response = await fetch("/api/update_stream", { + method: "POST", + body: formData, + }); + + if (response.ok) { + console.log("Stream updated successfully"); + window.location.reload(); + } else { + console.error("Failed to update stream"); + } + } catch (error) { + console.error("Error updating stream:", error); + } + }; + + const handleEndStream = async () => { + console.log("Ending stream..."); + + const formData = new FormData(); + formData.append("key", streamData.stream_key); + + try { + const response = await fetch("/api/end_stream", { + method: "POST", + body: formData, + }); + + if (response.ok) { + console.log("Stream ended successfully"); + window.location.reload(); + } else { + console.error("Failed to end stream"); + } + } catch (error) { + console.error("Error ending stream:", error); + } + }; + + return ( +
+
+ {/* Left side - Stream Settings */} +
+

Stream Settings

+
+
+ + +
+ +
+ + setIsCategoryFocused(true)} + onBlur={() => setTimeout(() => setIsCategoryFocused(false), 200)} + placeholder="Select or type a category" + extraClasses="w-[70%] focus:w-[70%]" + maxLength={50} + autoComplete="off" + type="search" + /> + {isCategoryFocused && filteredCategories.length > 0 && ( +
+ {filteredCategories.map((category) => ( +
handleCategorySelect(category.title)} + > + {category.title} +
+ ))} +
+ )} +
+ +
+ +
+ + + {thumbnail ? thumbnail.name : "No file selected"} + {thumbnailPreview.isCustom && ( + + )} +
+ {!thumbnailPreview.isCustom && ( +

No thumbnail selected - the default category image will be used

+ )} +
+ + {isLive && ( +
+

Stream Info

+

Viewers: {streamData.viewer_count}

+

+ Started: {new Date(streamData.start_time!).toLocaleTimeString()} + {` (${timeStarted})`} +

+
+ )} +
+ + + +
+ +
+
+ + {isLive && ( + + )} +
+ {!streamDetected && ( +

No stream input detected. Please start streaming using your broadcast software.

+ )} +
+
+
+ + {/* Right side - Preview */} +
+

Stream Preview

+
+
+

Video

+ +
+
+

List Item

+ {}} + extraClasses="max-w-[20vw]" + /> +
+
+
+
+
+ ); +}; + +export default StreamDashboard; diff --git a/frontend/src/components/Stream/VodsDashboard.tsx b/frontend/src/components/Stream/VodsDashboard.tsx new file mode 100644 index 0000000..b52a8ef --- /dev/null +++ b/frontend/src/components/Stream/VodsDashboard.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import ListRow from "../Layout/ListRow"; +import { VodType } from "../../types/VodType"; + +interface VodsDashboardProps { + vods: VodType[]; +} + +const VodsDashboard: React.FC = ({ vods }) => { + const handleVodClick = (vodUrl: string) => { + window.open(vodUrl, "_blank"); + }; + + return ( +
+

Past Broadcasts

+ + {vods.length === 0 ? ( +
+

No past broadcasts found

+
+ ) : ( + + )} +
+ ); +}; + +export default VodsDashboard; diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 6315f3b..6d711a8 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -1,22 +1,22 @@ import { createContext, useContext } from "react"; interface AuthContextType { - isLoggedIn: boolean; - username: string | null; - userId: number | null; - setIsLoggedIn: (value: boolean) => void; - setUsername: (value: string | null) => void; - setUserId: (value: number | null) => void; + isLoggedIn: boolean; + username: string | null; + userId: number | null; + isLive: boolean; + setIsLoggedIn: (value: boolean) => void; + setUsername: (value: string | null) => void; + setUserId: (value: number | null) => void; + setIsLive: (value: boolean) => void; } -export const AuthContext = createContext( - undefined -); +export const AuthContext = createContext(undefined); export function useAuth() { - const context = useContext(AuthContext); - if (context === undefined) { - throw new Error("useAuth must be used within an AuthProvider"); - } - return context; + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; } diff --git a/frontend/src/hooks/useContent.ts b/frontend/src/hooks/useContent.ts index 3113994..64084d3 100644 --- a/frontend/src/hooks/useContent.ts +++ b/frontend/src/hooks/useContent.ts @@ -4,173 +4,160 @@ import { useAuth } from "../context/AuthContext"; import { StreamType } from "../types/StreamType"; import { CategoryType } from "../types/CategoryType"; import { UserType } from "../types/UserType"; -import { VodType } from "../types/VodType" +import { VodType } from "../types/VodType"; import { getCategoryThumbnail } from "../utils/thumbnailUtils"; -// Process API data into our VodType structure const processVodData = (data: any[]): VodType[] => { - - return data.map((vod) => ({ - type: "vod", - id: vod.id, // Ensure this matches API response - title: vod.title, - streamer: vod.streamer, // Ensure backend sends streamer name or ID - datetime: new Date(vod.datetime).toLocaleString(), - category: vod.category, - length: vod.length, - views: vod.views, - url: vod.url, - thumbnail: "../../images/category_thumbnails/abstract.webp", - })); + return data.map((vod) => ({ + type: "vod", + vod_id: vod.vod_id, + title: vod.title, + username: vod.username, + datetime: formatDate(vod.datetime), + category_name: vod.category_name, + length: formatDuration(vod.length), + views: vod.views, + thumbnail: vod.thumbnail, //TODO + })); }; - - // 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), - })) + 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[] => { - console.log("Raw API VOD Data:", data); // Debugging - return data.map((category) => ({ - type: "category", - id: category.category_id, - title: category.category_name, - viewers: category.num_viewers, - thumbnail: getCategoryThumbnail(category.category_name) - })); + 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", - })); + 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( - url: string, - processor: (data: any[]) => T[], - dependencies: any[] = [] + url: string, + processor: (data: any[]) => T[], + dependencies: any[] = [] ): { data: T[]; isLoading: boolean; error: string | null } { - const [data, setData] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); + const [data, setData] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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); - } - }; + useEffect(() => { + const fetchData = async () => { + setIsLoading(true); + try { + const response = await fetch(url); - fetchData(); - }, dependencies); + if (!response.ok) { + throw new Error(`Error fetching data: ${response.status}`); + } - return { data, isLoading, error }; + 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 +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( - url, - processStreamData, - [isLoggedIn, customUrl] - ); + const { isLoggedIn } = useAuth(); + const url = customUrl || (isLoggedIn ? "/api/streams/recommended" : "/api/streams/popular/4"); - return { streams: data, isLoading, error }; + const { data, isLoading, error } = useFetchContent(url, processStreamData, [isLoggedIn, customUrl]); + + return { streams: data, isLoading, error }; } -export function useCategories(customUrl?: string): { - categories: CategoryType[]; - isLoading: boolean; - error: string | null +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( - url, - processCategoryData, - [isLoggedIn, customUrl] - ); + const { isLoggedIn } = useAuth(); + const url = customUrl || (isLoggedIn ? "/api/categories/recommended" : "/api/categories/popular/4"); - console.log("Fetched Cat Data:", data); // Debugging + const { data, isLoading, error } = useFetchContent(url, processCategoryData, [isLoggedIn, customUrl]); - - return { categories: data, isLoading, error }; + return { categories: data, isLoading, error }; } -export function useVods(customUrl?: string): { - vods: VodType[]; - isLoading: boolean; - error: string | null +export function useVods(customUrl?: string): { + vods: VodType[]; + isLoading: boolean; + error: string | null; } { - const url = customUrl || "api/vods/all"; - const { data, isLoading, error } = useFetchContent( - url, - processVodData, - [customUrl] - ); + const url = customUrl || "api/vods/all"; //TODO: Change this to the correct URL or implement it + const { data, isLoading, error } = useFetchContent(url, processVodData, [customUrl]); - - return { vods: data, isLoading, error }; + return { vods: data, isLoading, error }; } - -export function useUsers(customUrl?: string): { - users: UserType[]; - isLoading: boolean; - error: string | null +export function useUsers(customUrl?: string): { + users: UserType[]; + isLoading: boolean; + error: string | null; } { - const url = customUrl || "/api/users/popular"; - - const { data, isLoading, error } = useFetchContent( - url, - processUserData, - [customUrl] - ); + const url = customUrl || "/api/users/popular"; - return { users: data, isLoading, error }; -} \ No newline at end of file + const { data, isLoading, error } = useFetchContent(url, processUserData, [customUrl]); + + return { users: data, isLoading, error }; +} + +// Format duration from seconds to HH:MM:SS +const formatDuration = (seconds: number): string => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const remainingSeconds = seconds % 60; + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, "0")}:${remainingSeconds.toString().padStart(2, "0")}`; + } + return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`; +}; + +// Format date to a more readable format +const formatDate = (dateString: string): string => { + const date = new Date(dateString); + return date.toLocaleDateString() + " " + date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +}; diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..3091b34 --- /dev/null +++ b/frontend/src/pages/DashboardPage.tsx @@ -0,0 +1,100 @@ +import React, { useState, useEffect } from "react"; +import DynamicPageContent from "../components/Layout/DynamicPageContent"; +import { useAuth } from "../context/AuthContext"; +import StreamDashboard from "../components/Stream/StreamDashboard"; +import { CircleDotIcon as RecordIcon, SquarePlayIcon as PlaybackIcon, Undo2Icon } from "lucide-react"; +import VodsDashboard from "../components/Stream/VodsDashboard"; +import { useVods } from "../hooks/useContent"; + +interface DashboardPageProps { + tab?: "dashboard" | "stream" | "vod"; +} + +const DashboardPage: React.FC = ({ tab = "dashboard" }) => { + const { username, isLive, userId, setIsLive } = useAuth(); + const { vods } = useVods(`/api/vods/${username}`); + const [selectedTab, setSelectedTab] = useState<"dashboard" | "stream" | "vod">(tab); + + const colors = { + stream: "red-500", + vod: "green-500", + dashboard: "white", + }; + + useEffect(() => { + if (username) { + checkUserStatus(); + } + }, [username]); + + const checkUserStatus = async () => { + if (!username) return; + + try { + const response = await fetch(`/api/user/${username}/status`); + const data = await response.json(); + setIsLive(data.is_live); + } catch (error) { + console.error("Error checking user status:", error); + } + }; + + return ( + +
+
+ {selectedTab != "dashboard" && ( + setSelectedTab("dashboard")} + /> + )} +

+ Welcome {username} +

+
+

+ {selectedTab === "stream" ? "Streaming" : selectedTab === "vod" ? "VODs" : "Dashboard"} +

+ + {selectedTab == "dashboard" ? ( +
+
setSelectedTab("stream")} + > + {isLive && ( +
+ LIVE +
+ )} + +

Streaming

+
+
vods.length > 0 && setSelectedTab("vod")} + > + +

VODs

+

0 ? "text-white" : "text-gray-600"} absolute bottom-5 text-sm`}> + {vods.length} VOD{vods.length != 1 && "s"} available +

+
+
+ ) : selectedTab === "stream" && username && userId ? ( + + ) : ( + + )} +
+
+ ); +}; + +export default DashboardPage; diff --git a/frontend/src/pages/StreamDashboardPage.tsx b/frontend/src/pages/StreamDashboardPage.tsx deleted file mode 100644 index 0c5ef0f..0000000 --- a/frontend/src/pages/StreamDashboardPage.tsx +++ /dev/null @@ -1,480 +0,0 @@ -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 { 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; - category_name: string; - viewer_count: number; - start_time: string; - stream_key: string; -} - -const StreamDashboardPage: React.FC = () => { - const { username } = useAuth(); - const [isStreaming, setIsStreaming] = useState(false); - const [streamData, setStreamData] = useState({ - title: "", - category_name: "", - viewer_count: 0, - start_time: "", - stream_key: "", - }); - const [streamDetected, setStreamDetected] = useState(false); - const [timeStarted, setTimeStarted] = useState(""); - const [isCategoryFocused, setIsCategoryFocused] = useState(false); - const [filteredCategories, setFilteredCategories] = useState( - [] - ); - const [thumbnail, setThumbnail] = useState(null); - const [thumbnailPreview, setThumbnailPreview] = useState<{ - url: string; - isCustom: boolean; - }>({ url: "", isCustom: false }); - const [debouncedCheck, setDebouncedCheck] = useState(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.title.toLowerCase() === categoryName.toLowerCase() - ); - - if (isValidCategory && !thumbnailPreview.isCustom) { - const defaultThumbnail = getCategoryThumbnail(categoryName); - setThumbnailPreview({ url: defaultThumbnail, isCustom: false }); - } - }, 300); - - setDebouncedCheck(() => categoryCheck); - - return () => { - categoryCheck.cancel(); - }; - }, [categories, thumbnailPreview.isCustom]); - - useEffect(() => { - if (username) { - checkStreamStatus(); - } - }, [username]); - - const checkStreamStatus = async () => { - if (!username) return; - - try { - const response = await fetch(`/api/user/${username}/status`); - const data = await response.json(); - setIsStreaming(data.is_live); - - if (data.is_live) { - const streamResponse = await fetch( - `/api/streams/${data.user_id}/data`, - { credentials: "include" } - ); - const streamData = await streamResponse.json(); - setStreamData({ - title: streamData.title, - category_name: streamData.category_name, - viewer_count: streamData.num_viewers, - start_time: streamData.start_time, - stream_key: streamData.stream_key, - }); - - console.log("Stream data:", streamData); - - const time = Math.floor( - (Date.now() - new Date(streamData.start_time).getTime()) / 60000 // Convert to minutes - ); - - if (time < 60) setTimeStarted(`${time}m ago`); - else if (time < 1440) - setTimeStarted(`${Math.floor(time / 60)}h ${time % 60}m ago`); - else - setTimeStarted( - `${Math.floor(time / 1440)}d ${Math.floor((time % 1440) / 60)}h ${ - time % 60 - }m ago` - ); - } else { - // Just need the stream key if not streaming - const response = await fetch(`/api/user/${username}/stream_key`); - const keyData = await response.json(); - setStreamData((prev) => ({ - ...prev, - stream_key: keyData.stream_key, - })); - } - } catch (error) { - console.error("Error checking stream status:", error); - } - }; - - const handleInputChange = (e: React.ChangeEvent) => { - const { name, value } = e.target; - setStreamData((prev) => ({ ...prev, [name]: value })); - - if (name === "category_name") { - const filtered = categories.filter((cat) => - cat.title.toLowerCase().includes(value.toLowerCase()) - ); - setFilteredCategories(filtered); - if (debouncedCheck) { - debouncedCheck(value); - } - } - }; - - const handleCategorySelect = (categoryName: string) => { - console.log("Selected category:", categoryName); - setStreamData((prev) => ({ ...prev, category_name: categoryName })); - setFilteredCategories([]); - if (debouncedCheck) { - debouncedCheck(categoryName); - } - }; - - const handleThumbnailChange = (e: React.ChangeEvent) => { - if (e.target.files && e.target.files[0]) { - const file = e.target.files[0]; - setThumbnail(file); - setThumbnailPreview({ - url: URL.createObjectURL(file), - isCustom: true, - }); - } else { - setThumbnail(null); - if (streamData.category_name && debouncedCheck) { - debouncedCheck(streamData.category_name); - } else { - setThumbnailPreview({ url: "", isCustom: false }); - } - } - }; - - const clearThumbnail = () => { - setThumbnail(null); - if (streamData.category_name) { - console.log( - "Clearing thumbnail as category is set and default category thumbnail will be used" - ); - const defaultThumbnail = getCategoryThumbnail(streamData.category_name); - setThumbnailPreview({ url: defaultThumbnail, isCustom: false }); - } else { - setThumbnailPreview({ url: "", isCustom: false }); - } - }; - - const isFormValid = () => { - return ( - streamData.title.trim() !== "" && - streamData.category_name.trim() !== "" && - categories.some( - (cat) => - cat.title.toLowerCase() === streamData.category_name.toLowerCase() - ) && - streamDetected - ); - }; - - const handlePublishStream = async () => { - console.log("Starting stream with data:", streamData); - - try { - const response = await fetch("/api/publish_stream", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(streamData), - }); - - if (response.ok) { - console.log("Stream published successfully"); - window.location.reload(); - } else if (response.status === 403) { - console.error("Unauthorized - Invalid stream key or already streaming"); - } else { - console.error("Failed to publish stream"); - } - } catch (error) { - console.error("Error publishing stream:", error); - } - }; - - const handleUpdateStream = async () => { - console.log("Updating stream with data:", streamData); - - try { - const response = await fetch("/api/update_stream", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - key: streamData.stream_key, - title: streamData.title, - category_name: streamData.category_name, - thumbnail: thumbnail, - }), - }); - - if (response.ok) { - console.log("Stream updated successfully"); - window.location.reload(); - } else { - console.error("Failed to update stream"); - } - } catch (error) { - console.error("Error updating stream:", error); - } - }; - - const handleEndStream = async () => { - console.log("Ending stream..."); - - try { - const response = await fetch("/api/end_stream", { - method: "POST", - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ key: streamData.stream_key }), - }); - - if (response.ok) { - console.log("Stream ended successfully"); - window.location.reload(); - } else { - console.error("Failed to end stream"); - } - } catch (error) { - console.error("Error ending stream:", error); - } - }; - - return ( - -
-

- {isStreaming ? "Stream Dashboard" : "Start Streaming"} -

- -
- {/* Left side - Stream Settings */} -
-

- Stream Settings -

-
-
- - -
- -
- - setIsCategoryFocused(true)} - onBlur={() => - setTimeout(() => setIsCategoryFocused(false), 200) - } - placeholder="Select or type a category" - extraClasses="w-[70%] focus:w-[70%]" - maxLength={50} - autoComplete="off" - type="search" - /> - {isCategoryFocused && filteredCategories.length > 0 && ( -
- {filteredCategories.map((category) => ( -
handleCategorySelect(category.title)} - > - {category.title} -
- ))} -
- )} -
- -
- -
- - - - {thumbnail ? thumbnail.name : "No file selected"} - - {thumbnailPreview.isCustom && ( - - )} -
- {!thumbnailPreview.isCustom && ( -

- No thumbnail selected - the default category image will be - used -

- )} -
- - {isStreaming && ( -
-

Stream Info

-

- Viewers: {streamData.viewer_count} -

-

- Started:{" "} - {new Date(streamData.start_time!).toLocaleTimeString()} - {` (${timeStarted})`} -

-
- )} -
- - - -
- -
-
- - {isStreaming && ( - - )} -
- {!streamDetected && ( -

- No stream input detected. Please start streaming using your - broadcast software. -

- )} -
-
-
- - {/* Right side - Preview */} -
-

- Stream Preview -

-
-
-

Video

- -
-
-

List Item

- {}} - extraClasses="max-w-[20vw]" - /> -
-
-
-
-
-
- ); -}; - -export default StreamDashboardPage; diff --git a/frontend/src/types/VodType.ts b/frontend/src/types/VodType.ts index 32a3141..3a98688 100644 --- a/frontend/src/types/VodType.ts +++ b/frontend/src/types/VodType.ts @@ -1,12 +1,11 @@ export interface VodType { type: "vod"; - id: number; + vod_id: number; title: string; - streamer: string; + username: string; datetime: string; - category: string; - length: number; + category_name: string; + length: string; views: number; - url: string; thumbnail: string; - } \ No newline at end of file +} \ No newline at end of file diff --git a/web_server/utils/stream_utils.py b/web_server/utils/stream_utils.py index 951c27b..41ae263 100644 --- a/web_server/utils/stream_utils.py +++ b/web_server/utils/stream_utils.py @@ -85,7 +85,7 @@ def get_latest_vod(user_id: int): Returns data of the most recent stream by a streamer """ with Database() as db: - latest_vod = db.fetchone("""SELECT * FROM vods WHERE user_id = ? ORDER BY vod_id DESC;""", (user_id,)) + latest_vod = db.fetchone("""SELECT vods.*, category_name FROM vods JOIN categories ON vods.category_id = categories.category_id WHERE user_id = ? ORDER BY vod_id DESC;""", (user_id,)) return latest_vod def get_user_vods(user_id: int): @@ -93,7 +93,7 @@ def get_user_vods(user_id: int): Returns data of all vods by a streamer """ with Database() as db: - vods = db.fetchall("""SELECT * FROM vods WHERE user_id = ?;""", (user_id,)) + vods = db.fetchall("""SELECT vods.*, category_name FROM vods JOIN categories ON vods.category_id = categories.category_id WHERE user_id = ? ORDER BY vod_id DESC;""", (user_id,)) return vods def get_all_vods():