- Added Chat frontend, interfaces with backend;
- Updated styles for VideoPage; - Added StreamerRoute component; - Remove unused Login and Signup pages; - Update to Navbar and Logo components for new structure on different pages; - Update to auth flow to display error messages to user;
This commit is contained in:
@@ -13,10 +13,10 @@ const AuthModal: React.FC<AuthModalProps> = ({ onClose }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="blurring-layer fixed z-10 inset-0 w-screen h-screen backdrop-blur-sm group-has-[input:focus]:backdrop-blur-[5px]"></div>
|
||||
<div id="blurring-layer" className="fixed z-10 inset-0 w-screen h-screen backdrop-blur-sm group-has-[input:focus]:backdrop-blur-[5px]"></div>
|
||||
|
||||
<div className="modal-container fixed inset-0 bg-black/30 has-[input:focus]:bg-black/40 flex flex-col items-center justify-around z-50 h-[70vh] m-auto min-w-[40vw] w-fit py-[50px] rounded-[2rem] transition-all">
|
||||
<div className="login-methods w-full flex flex-row items-center justify-evenly">
|
||||
<div id="modal-container" className="fixed inset-0 bg-black/30 has-[input:focus]:bg-black/40 flex flex-col items-center justify-around z-50 h-[70vh] m-auto min-w-[40vw] w-fit py-[50px] rounded-[2rem] transition-all">
|
||||
<div id="login-methods" className="w-full flex flex-row items-center justify-evenly">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-[1rem] right-[2rem] text-[2rem] text-white hover:text-red-500 font-black hover:text-[2.5rem] transition-all"
|
||||
|
||||
@@ -61,27 +61,30 @@ const LoginForm: React.FC = () => {
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || "Login failed");
|
||||
}
|
||||
|
||||
if (data.logged_in) {
|
||||
//TODO: Handle successful login (e.g., redirect to home page)
|
||||
console.log("Login successful");
|
||||
setIsLoggedIn(true);
|
||||
window.location.reload();
|
||||
} else {
|
||||
// Handle authentication errors
|
||||
if (data.errors) {
|
||||
// Handle authentication errors from server
|
||||
if (data.error_fields) {
|
||||
const newErrors: FormErrors = {};
|
||||
for (const field of data.error_fields) {
|
||||
newErrors[field as keyof FormErrors] = data.message;
|
||||
}
|
||||
setErrors(newErrors);
|
||||
} else {
|
||||
// If no specific fields are indicated, set a general error
|
||||
setErrors({
|
||||
general: "Invalid username or password",
|
||||
general: data.message || "An error occurred during login",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Login error:", error);
|
||||
console.error("Error logging in:", error);
|
||||
setErrors({
|
||||
general: "An error occurred during login. Please try again.",
|
||||
general: "An error occurred during login",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -90,7 +93,8 @@ const LoginForm: React.FC = () => {
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="login-form h-[100%] flex flex-col h-full justify-evenly items-center"
|
||||
id="login-form"
|
||||
className="h-[100%] flex flex-col h-full justify-evenly items-center"
|
||||
>
|
||||
{errors.general && (
|
||||
<p className="text-red-500 text-sm text-center">{errors.general}</p>
|
||||
|
||||
@@ -15,6 +15,7 @@ interface FormErrors {
|
||||
email?: string;
|
||||
password?: string;
|
||||
confirmPassword?: string;
|
||||
general?: string; // For general authentication errors
|
||||
}
|
||||
|
||||
const RegisterForm: React.FC = () => {
|
||||
@@ -68,14 +69,8 @@ const RegisterForm: React.FC = () => {
|
||||
credentials: "include",
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
console.log(`sending data: ${JSON.stringify(formData)}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
data.message || `Registration failed. ${response.body}`
|
||||
);
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
if (data.account_created) {
|
||||
//TODO Handle successful registration (e.g., redirect or show success message)
|
||||
@@ -84,17 +79,24 @@ const RegisterForm: React.FC = () => {
|
||||
window.location.reload();
|
||||
} else {
|
||||
// Handle validation errors from server
|
||||
const serverErrors: FormErrors = {};
|
||||
if (data.errors) {
|
||||
Object.entries(data.errors).forEach(([field, message]) => {
|
||||
serverErrors[field as keyof FormErrors] = message as string;
|
||||
if (data.error_fields) {
|
||||
const newErrors: FormErrors = {};
|
||||
for (const field of data.error_fields) {
|
||||
newErrors[field as keyof FormErrors] = data.message;
|
||||
}
|
||||
setErrors(newErrors);
|
||||
} else {
|
||||
// If no specific fields are indicated, set a general error
|
||||
setErrors({
|
||||
general: data.message || "An error occurred during registration",
|
||||
});
|
||||
setErrors(serverErrors);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Registration error:", error);
|
||||
//TODO Show user-friendly error message via Alert component maybe
|
||||
console.error("Error Registering:", error);
|
||||
setErrors({
|
||||
general: "An error occurred during registration",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -102,8 +104,13 @@ const RegisterForm: React.FC = () => {
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="register-form h-[100%] flex flex-col h-full justify-evenly items-center"
|
||||
id="register-form"
|
||||
className="h-[100%] flex flex-col h-full justify-evenly items-center"
|
||||
>
|
||||
{errors.general && (
|
||||
<p className="text-red-500 text-sm text-center">{errors.general}</p>
|
||||
)}
|
||||
|
||||
{errors.username && (
|
||||
<p className="text-red-500 mt-3 text-sm">{errors.username}</p>
|
||||
)}
|
||||
|
||||
@@ -67,8 +67,8 @@ const CheckoutForm: React.FC<CheckoutFormProps> = ({ onClose }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="blurring-layer fixed z-10 inset-0 w-screen h-screen backdrop-blur-sm"></div>
|
||||
<div className="modal-container fixed inset-0 bg-black/30 flex items-center justify-center z-50 h-[70vh] m-auto w-fit py-[50px] px-[100px] rounded-[2rem]">
|
||||
<div id="blurring-layer" className="fixed z-10 inset-0 w-screen h-screen backdrop-blur-sm"></div>
|
||||
<div id="modal-container" className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 h-[70vh] m-auto w-fit py-[50px] px-[100px] rounded-[2rem]">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-[1rem] right-[3rem] text-[2rem] text-white hover:text-red-500 font-black hover:text-[2.5rem] transition-all"
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const Logo: React.FC = () => {
|
||||
interface LogoProps {
|
||||
variant?: "home" | "default";
|
||||
}
|
||||
|
||||
const Logo: React.FC<LogoProps> = ({ variant = "default" }) => {
|
||||
const gradient =
|
||||
"bg-gradient-to-br from-yellow-400 via-red-500 to-indigo-500 text-transparent bg-clip-text group-hover:mx-1 transition-all";
|
||||
return (
|
||||
<Link to="/" className="cursor-pointer">
|
||||
<div className="logo group py-3 text-center text-[12vh] font-bold hover:scale-110 transition-all">
|
||||
<div id="logo" className={`group py-3 text-center font-bold hover:scale-110 transition-all ${variant === "home" ? "text-[12vh]" : "text-[4vh]"}`}>
|
||||
<h6 className="text-sm bg-gradient-to-br from-blue-400 via-green-500 to-indigo-500 font-black text-transparent bg-clip-text">
|
||||
Go on, have a...
|
||||
</h6>
|
||||
<div className="flex w-fit min-w-[30vw] justify-evenly leading-none transition-all">
|
||||
<div className="flex w-fit min-w-[30vw] justify-center leading-none transition-all">
|
||||
<span className={gradient}>G</span>
|
||||
<span className={gradient}>A</span>
|
||||
<span className={gradient}>N</span>
|
||||
|
||||
@@ -13,7 +13,11 @@ import Input from "./Input";
|
||||
import AuthModal from "../Auth/AuthModal";
|
||||
import { useAuth } from "../../context/AuthContext";
|
||||
|
||||
const Navbar: React.FC = () => {
|
||||
interface NavbarProps {
|
||||
variant?: "home" | "default";
|
||||
}
|
||||
|
||||
const Navbar: React.FC<NavbarProps> = ({ variant = "default" }) => {
|
||||
const [showAuthModal, setShowAuthModal] = useState(false);
|
||||
const { isLoggedIn } = useAuth();
|
||||
|
||||
@@ -39,8 +43,8 @@ const Navbar: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-around items-center h-[45vh]">
|
||||
<Logo />
|
||||
<div id="navbar" className={`flex justify-center items-center ${variant === "home" ? "h-[45vh] flex-col" : "h-[15vh] col-span-2 flex-row"}`}>
|
||||
<Logo variant={variant} />
|
||||
<Button
|
||||
extraClasses="absolute top-[20px] left-[20px] text-[1rem] flex items-center flex-nowrap"
|
||||
onClick={() => (isLoggedIn ? handleLogout() : setShowAuthModal(true))}
|
||||
@@ -69,13 +73,13 @@ const Navbar: React.FC = () => {
|
||||
|
||||
<Button
|
||||
extraClasses="absolute top-[20px] right-[20px] text-[1rem] flex items-center flex-nowrap"
|
||||
onClick={() => console.log("Settings")}
|
||||
onClick={() => console.log("Settings - TODO")}
|
||||
>
|
||||
<SettingsIcon className="h-15 w-15 mr-1" />
|
||||
Quick Settings
|
||||
</Button>
|
||||
|
||||
<div className="search-bar flex items-center">
|
||||
<div id="search-bar" className="flex items-center">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
|
||||
@@ -17,11 +17,14 @@ interface StreamListRowProps {
|
||||
title: string;
|
||||
description: string;
|
||||
streams: StreamItem[];
|
||||
onStreamClick?: (streamId: string) => void;
|
||||
onStreamClick: (streamId: number, streamerName: string) => void;
|
||||
}
|
||||
|
||||
// Individual stream entry component
|
||||
const StreamListEntry: React.FC<StreamListEntryProps> = ({ stream, onClick }) => {
|
||||
const StreamListEntry: React.FC<StreamListEntryProps> = ({
|
||||
stream,
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col bg-gray-800 rounded-lg overflow-hidden cursor-pointer hover:bg-gray-700 transition-colors"
|
||||
@@ -30,7 +33,7 @@ const StreamListEntry: React.FC<StreamListEntryProps> = ({ stream, onClick }) =>
|
||||
<div className="relative w-full pt-[56.25%]">
|
||||
{stream.thumbnail ? (
|
||||
<img
|
||||
src={`images/`+stream.thumbnail}
|
||||
src={`images/` + stream.thumbnail}
|
||||
alt={stream.title}
|
||||
className="absolute top-0 left-0 w-full h-full object-cover"
|
||||
/>
|
||||
@@ -65,7 +68,7 @@ const StreamListRow: React.FC<StreamListRowProps> = ({
|
||||
<StreamListEntry
|
||||
key={stream.id}
|
||||
stream={stream}
|
||||
onClick={() => onStreamClick?.(stream.id)}
|
||||
onClick={() => onStreamClick?.(stream.id, stream.streamer)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
41
frontend/src/components/Stream/StreamerRoute.tsx
Normal file
41
frontend/src/components/Stream/StreamerRoute.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import VideoPage from "../../pages/VideoPage";
|
||||
import UserPage from "../../pages/UserPage";
|
||||
|
||||
const StreamerRoute: React.FC = () => {
|
||||
const { streamerName } = useParams<{ streamerName: string }>();
|
||||
const [isLive, setIsLive] = useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
const checkStreamStatus = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/streamer/${streamerName}/status`);
|
||||
const data = await response.json();
|
||||
console.log("Stream status:", data);
|
||||
setIsLive(data.status === "live");
|
||||
} catch (error) {
|
||||
console.error("Error checking stream status:", error);
|
||||
setIsLive(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkStreamStatus();
|
||||
|
||||
// Poll for live status changes
|
||||
const interval = setInterval(checkStreamStatus, 90000); // Check every 90 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [streamerName]);
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>; // Or your loading component
|
||||
}
|
||||
|
||||
return isLive ? <VideoPage streamId={1} /> : <UserPage />;
|
||||
};
|
||||
|
||||
export default StreamerRoute;
|
||||
@@ -7,12 +7,12 @@ interface ThumbnailProps {
|
||||
|
||||
const Thumbnail = ({ path, alt }: ThumbnailProps) => {
|
||||
return (
|
||||
<div>
|
||||
<div id='stream-thumbnail'>
|
||||
<img
|
||||
width={300}
|
||||
src={path}
|
||||
alt={alt}
|
||||
className="stream-thumbnail rounded-md"
|
||||
className="rounded-md"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
151
frontend/src/components/Video/ChatPanel.tsx
Normal file
151
frontend/src/components/Video/ChatPanel.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import Input from "../Layout/Input";
|
||||
|
||||
interface ChatMessage {
|
||||
chatter_id: string;
|
||||
message: string;
|
||||
time_sent: string;
|
||||
}
|
||||
|
||||
interface ChatPanelProps {
|
||||
streamId: number;
|
||||
chatterId?: string; // Optional as user might not be logged in
|
||||
}
|
||||
|
||||
const ChatPanel: React.FC<ChatPanelProps> = ({ streamId, chatterId }) => {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [inputMessage, setInputMessage] = useState("");
|
||||
const lastReceivedRef = useRef<string>("");
|
||||
const chatContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load initial chat history
|
||||
useEffect(() => {
|
||||
const loadPastChat = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/chat/${streamId}`);
|
||||
if (!response.ok) throw new Error("Failed to fetch chat history");
|
||||
const data = await response.json();
|
||||
if (data.chat_history) {
|
||||
setMessages(data.chat_history);
|
||||
if (data.chat_history.length > 0) {
|
||||
lastReceivedRef.current =
|
||||
data.chat_history[data.chat_history.length - 1].time_sent;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading chat history:", error);
|
||||
}
|
||||
};
|
||||
|
||||
loadPastChat();
|
||||
}, [streamId]);
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
useEffect(() => {
|
||||
if (chatContainerRef.current)
|
||||
chatContainerRef.current.scrollTop =
|
||||
chatContainerRef.current.scrollHeight;
|
||||
}, [messages]);
|
||||
|
||||
// Poll for new messages
|
||||
useEffect(() => {
|
||||
const getRecentChats = async () => {
|
||||
if (!lastReceivedRef.current) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/load_new_chat/${streamId}?last_received=${lastReceivedRef.current}`
|
||||
);
|
||||
if (!response.ok) throw new Error("Failed to fetch recent chats");
|
||||
const newMessages = await response.json();
|
||||
if (newMessages && newMessages.length > 0) {
|
||||
setMessages((prev) => [...prev, ...newMessages]);
|
||||
lastReceivedRef.current =
|
||||
newMessages[newMessages.length - 1].time_sent;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching recent chats:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const pollInterval = setInterval(getRecentChats, 3000); // Poll every 3 seconds
|
||||
return () => clearInterval(pollInterval);
|
||||
}, [streamId]);
|
||||
|
||||
const sendChat = async () => {
|
||||
if (!inputMessage.trim() || !chatterId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/send_chat", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chatter_id: chatterId,
|
||||
stream_id: streamId,
|
||||
message: inputMessage,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.chat_sent) {
|
||||
setInputMessage("");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending chat:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendChat();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div id="chat-panel" className="h-full flex flex-col rounded-lg p-4" >
|
||||
<h2 className="text-xl font-bold mb-4 text-white">Stream Chat</h2>
|
||||
|
||||
<div
|
||||
ref={chatContainerRef}
|
||||
id="chat-message-list"
|
||||
className="flex-grow w-full max-h-[50vh] overflow-y-auto mb-4 space-y-2"
|
||||
>
|
||||
{messages.map((msg, index) => (
|
||||
<div key={index} className="grid grid-cols-[8%_minmax(15%,_100px)_1fr] items-center bg-gray-700 rounded p-2 text-white">
|
||||
<span className="text-gray-400 text-sm">
|
||||
{new Date(msg.time_sent).toLocaleTimeString()}
|
||||
</span>
|
||||
<span className={`font-bold ${msg.chatter_id === chatterId ? "text-blue-400" : "text-green-400"}`}> {msg.chatter_id}: </span>
|
||||
<span>{msg.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={inputMessage}
|
||||
onChange={(e) => setInputMessage(e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
placeholder={chatterId ? "Type a message..." : "Login to chat"}
|
||||
disabled={!chatterId}
|
||||
extraClasses="flex-grow disabled:cursor-not-allowed"
|
||||
/>
|
||||
<button
|
||||
onClick={sendChat}
|
||||
disabled={!chatterId}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatPanel;
|
||||
@@ -1,35 +1,59 @@
|
||||
// video.html
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import videojs from "video.js";
|
||||
import type Player from "video.js/dist/types/player";
|
||||
import "video.js/dist/video-js.css";
|
||||
|
||||
// src/components/Video/VideoPlayer.tsx
|
||||
import React, { useEffect } from 'react';
|
||||
import videojs from 'video.js';
|
||||
import 'video.js/dist/video-js.css';
|
||||
interface VideoPlayerProps {
|
||||
streamId: number;
|
||||
}
|
||||
|
||||
const VideoPlayer: React.FC<VideoPlayerProps> = ({ streamId }) => {
|
||||
const videoRef = useRef<HTMLDivElement>(null);
|
||||
const playerRef = useRef<Player | null>(null);
|
||||
|
||||
const VideoPlayer: React.FC = () => {
|
||||
useEffect(() => {
|
||||
const player = videojs('player', {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
controls: true,
|
||||
preload: 'auto',
|
||||
sources: [{
|
||||
src: '/hls/stream.m3u8',
|
||||
type: 'application/x-mpegURL'
|
||||
}]
|
||||
});
|
||||
// Makes sure Video.js player is only initialized once
|
||||
if (!playerRef.current && videoRef.current) {
|
||||
const videoElement = document.createElement("video");
|
||||
videoElement.classList.add(
|
||||
"video-js",
|
||||
"vjs-big-play-centered",
|
||||
"w-full",
|
||||
"h-full"
|
||||
);
|
||||
videoRef.current.appendChild(videoElement);
|
||||
|
||||
playerRef.current = videojs(videoElement, {
|
||||
controls: true,
|
||||
fluid: true,
|
||||
responsive: true,
|
||||
aspectRatio: "16:9",
|
||||
sources: [
|
||||
{
|
||||
src: `/api/hls1/${streamId}`,
|
||||
type: "application/x-mpegURL",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Dispose the Video.js player when the component unmounts
|
||||
return () => {
|
||||
if (player) {
|
||||
player.dispose();
|
||||
if (playerRef.current) {
|
||||
playerRef.current.dispose();
|
||||
playerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [streamId]);
|
||||
|
||||
return (
|
||||
<video
|
||||
id="player"
|
||||
className="video-js vjs-default-skin"
|
||||
/>
|
||||
<div
|
||||
id="video-container"
|
||||
className="h-full flex items-center bg-gray-900 rounded-lg"
|
||||
style={{ gridArea: "2 / 1 / 3 / 2" }}
|
||||
>
|
||||
<div ref={videoRef} id="video-player" className="w-full" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user