diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 6be5702..a3aba71 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -2,10 +2,15 @@ package main import ( "log" + "os" "strconv" "arguehub/config" + "arguehub/db" + "arguehub/middlewares" "arguehub/routes" + "arguehub/services" + "arguehub/utils" "arguehub/websocket" "github.com/gin-contrib/cors" @@ -13,26 +18,44 @@ import ( ) func main() { + // Load the configuration from the specified YAML file cfg, err := config.LoadConfig("./config/config.prod.yml") if err != nil { log.Fatalf("Failed to load config: %v", err) } - router := setupRouter(cfg) + services.InitDebateVsBotService(cfg) + services.InitCoachService() + // Connect to MongoDB using the URI from the configuration + if err := db.ConnectMongoDB(cfg.Database.URI); err != nil { + log.Fatalf("Failed to connect to MongoDB: %v", err) + } + log.Println("Connected to MongoDB") + + // Seed initial debate-related data + utils.SeedDebateData() + utils.PopulateTestUsers() + // Create uploads directory + os.MkdirAll("uploads", os.ModePerm) + + // Set up the Gin router and configure routes + router := setupRouter(cfg) port := strconv.Itoa(cfg.Server.Port) log.Printf("Server starting on port %s", port) + if err := router.Run(":" + port); err != nil { log.Fatalf("Failed to start server: %v", err) } } func setupRouter(cfg *config.Config) *gin.Engine { - // gin.SetMode(gin.ReleaseMode) // Uncomment this line for production - router := gin.Default() + + // Set trusted proxies (adjust as needed) router.SetTrustedProxies([]string{"127.0.0.1", "localhost"}) + // Configure CORS for your frontend (e.g., localhost:5173 for Vite) router.Use(cors.New(cors.Config{ AllowOrigins: []string{"http://localhost:5173"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, @@ -40,19 +63,44 @@ func setupRouter(cfg *config.Config) *gin.Engine { ExposeHeaders: []string{"Content-Length"}, AllowCredentials: true, })) + router.OPTIONS("/*path", func(c *gin.Context) { c.Status(204) }) - router.OPTIONS("/*path", func(c *gin.Context) { - c.Status(204) - }) - + // Public routes for authentication router.POST("/signup", routes.SignUpRouteHandler) router.POST("/verifyEmail", routes.VerifyEmailRouteHandler) router.POST("/login", routes.LoginRouteHandler) + router.POST("/googleLogin", routes.GoogleLoginRouteHandler) router.POST("/forgotPassword", routes.ForgotPasswordRouteHandler) router.POST("/confirmForgotPassword", routes.VerifyForgotPasswordRouteHandler) router.POST("/verifyToken", routes.VerifyTokenRouteHandler) - - router.GET("/ws", websocket.WebsocketHandler) + + // Protected routes (JWT auth) + auth := router.Group("/") + auth.Use(middlewares.AuthMiddleware("./config/config.prod.yml")) + { + auth.GET("/user/fetchprofile", routes.GetProfileRouteHandler) + auth.PUT("/user/updateprofile", routes.UpdateProfileRouteHandler) + auth.GET("/leaderboard", routes.GetLeaderboardRouteHandler) + auth.POST("/debate/result", routes.UpdateEloAfterDebateRouteHandler) + routes.SetupDebateVsBotRoutes(auth) + + // WebSocket signaling endpoint + auth.GET("/ws", websocket.WebsocketHandler) + + routes.SetupTranscriptRoutes(auth) + auth.GET("/coach/strengthen-argument/weak-statement", routes.GetWeakStatement) + auth.POST("/coach/strengthen-argument/evaluate", routes.EvaluateStrengthenedArgument) + + // Add Room routes. + auth.GET("/rooms", routes.GetRoomsHandler) + auth.POST("/rooms", routes.CreateRoomHandler) + auth.POST("/rooms/:id/join", routes.JoinRoomHandler) + + auth.GET("/chat/:roomId", websocket.RoomChatHandler) + + auth.GET("/coach/pros-cons/topic", routes.GetProsConsTopic) + auth.POST("/coach/pros-cons/submit", routes.SubmitProsCons) + } return router -} \ No newline at end of file +} diff --git a/backend/config/config.go b/backend/config/config.go index a71fe8b..a9c3aa9 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -2,37 +2,63 @@ package config import ( "fmt" - "io/ioutil" + "io/ioutil" - "gopkg.in/yaml.v3" + "gopkg.in/yaml.v3" ) + type Config struct { - Server struct { - Port int `yaml:"port"` - } `yaml:"server"` - - Cognito struct { - AppClientId string `yaml:"appClientId"` - AppClientSecret string `yaml:"appClientSecret"` - UserPoolId string `yaml:"userPoolId"` - Region string `yaml:"region"` - } `yaml:"cognito"` - - Openai struct { - GptApiKey string `yaml:"gptApiKey"` - } `yaml:"openai` + Server struct { + Port int `yaml:"port"` + } `yaml:"server"` + + Cognito struct { + AppClientId string `yaml:"appClientId"` + AppClientSecret string `yaml:"appClientSecret"` + UserPoolId string `yaml:"userPoolId"` + Region string `yaml:"region"` + } `yaml:"cognito"` + + Openai struct { + GptApiKey string `yaml:"gptApiKey"` + } `yaml:"openai"` + + Gemini struct { + ApiKey string `yaml:"apiKey"` + } `yaml:"gemini"` + + Database struct { + URI string `yaml:"uri"` + } `yaml:"database"` + + JWT struct { + Secret string // Add JWT secret + Expiry int // Token expiry in minutes + } + SMTP struct { // Add SMTP configuration + Host string + Port int + Username string // Gmail address + Password string // App Password + SenderEmail string // Same as Username for Gmail + SenderName string + } + GoogleOAuth struct { // Add Google OAuth configuration + ClientID string + } } +// LoadConfig reads the configuration file func LoadConfig(path string) (*Config, error) { - data, err := ioutil.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("failed to read config file: %w", err) - } + data, err := ioutil.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } - var cfg Config - if err := yaml.Unmarshal(data, &cfg); err != nil { - return nil, fmt.Errorf("failed to unmarshal yaml: %w", err) - } + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to unmarshal yaml: %w", err) + } - return &cfg, nil + return &cfg, nil } diff --git a/backend/config/config.prod.yml b/backend/config/config.prod.yml index 1a81323..1ca3cff 100644 --- a/backend/config/config.prod.yml +++ b/backend/config/config.prod.yml @@ -1,12 +1,32 @@ server: - port: 1313 + port: # Port number on which the server runs cognito: - appClientId: - appClientSecret: - userPoolId: - region: + appClientId: # AWS Cognito App Client ID + appClientSecret: # AWS Cognito App Client Secret + userPoolId: # AWS Cognito User Pool ID + region: # AWS region for Cognito openai: - gptApiKey: - \ No newline at end of file + gptApiKey: # OpenAI GPT API key + +database: + uri: # MongoDB connection URI + +gemini: + apiKey: # Google Gemini API key + +jwt: + secret: # Secret key for signing JWT tokens + expiry: # JWT token expiry time in minutes + +smtp: + host: # SMTP server host (e.g., smtp.gmail.com) + port: # SMTP server port (usually 587 for TLS) + username: # SMTP username (usually your email) + password: # SMTP app password (not your regular email password) + senderEmail: # Email address used to send emails + senderName: # Display name for the sender in outgoing emails + +googleOAuth: + clientID: # Google OAuth Client ID \ No newline at end of file diff --git a/backend/controllers/auth.go b/backend/controllers/auth.go index 25595a8..5f998e6 100644 --- a/backend/controllers/auth.go +++ b/backend/controllers/auth.go @@ -1,328 +1,423 @@ package controllers import ( - "arguehub/config" - "arguehub/structs" - "arguehub/utils" + "context" "fmt" "log" + "net/http" "os" "strings" - "github.com/aws/aws-sdk-go-v2/aws" - awsConfig "github.com/aws/aws-sdk-go-v2/config" - "github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider" + "time" + + "arguehub/config" + "arguehub/db" + "arguehub/models" + "arguehub/structs" + "arguehub/utils" + "github.com/gin-gonic/gin" - "github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider/types" + "github.com/golang-jwt/jwt/v5" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "golang.org/x/crypto/bcrypt" + "google.golang.org/api/idtoken" ) -func SignUp(ctx *gin.Context) { +type GoogleLoginRequest struct { + IDToken string `json:"idToken" binding:"required"` +} + +func GoogleLogin(ctx *gin.Context) { cfg := loadConfig(ctx) if cfg == nil { return } - var request structs.SignUpRequest + var request GoogleLoginRequest if err := ctx.ShouldBindJSON(&request); err != nil { ctx.JSON(400, gin.H{"error": "Invalid input", "message": err.Error()}) return } - err := signUpWithCognito(cfg.Cognito.AppClientId, cfg.Cognito.AppClientSecret, request.Email, request.Password, ctx) + // Verify Google ID token + payload, err := idtoken.Validate(ctx, request.IDToken, cfg.GoogleOAuth.ClientID) if err != nil { - ctx.JSON(500, gin.H{"error": "Failed to sign up", "message": err.Error()}) + log.Printf("Google ID token validation failed: %v", err) + ctx.JSON(401, gin.H{"error": "Invalid Google ID token", "message": err.Error()}) return } - ctx.JSON(200, gin.H{"message": "Sign-up successful"}) -} - -func VerifyEmail(ctx *gin.Context) { - cfg := loadConfig(ctx) - if cfg == nil { + // Extract email and name from Google token + email, ok := payload.Claims["email"].(string) + if !ok || email == "" { + ctx.JSON(400, gin.H{"error": "Email not found in Google token"}) return } + nickname, _ := payload.Claims["name"].(string) + if nickname == "" { + nickname = utils.ExtractNameFromEmail(email) + } - var request structs.VerifyEmailRequest - if err := ctx.ShouldBindJSON(&request); err != nil { - ctx.JSON(400, gin.H{"error": "Invalid input", "message": err.Error()}) + // Check if user exists in MongoDB + dbCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var existingUser models.User + err = db.MongoDatabase.Collection("users").FindOne(dbCtx, bson.M{"email": email}).Decode(&existingUser) + if err != nil && err != mongo.ErrNoDocuments { + log.Printf("Database error: %v", err) + ctx.JSON(500, gin.H{"error": "Database error", "message": err.Error()}) return } - err := verifyEmailWithCognito(cfg.Cognito.AppClientId, cfg.Cognito.AppClientSecret, request.Email, request.ConfirmationCode, ctx) + if err == mongo.ErrNoDocuments { + // Create new user + newUser := models.User{ + Email: email, + Password: "", // No password for Google users + Nickname: nickname, + EloRating: 1200, + IsVerified: true, // Google-verified emails are trusted + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + _, err = db.MongoDatabase.Collection("users").InsertOne(dbCtx, newUser) + if err != nil { + log.Printf("User insertion error: %v", err) + ctx.JSON(500, gin.H{"error": "Failed to create user", "message": err.Error()}) + return + } + } + + // Generate JWT + token, err := generateJWT(email, cfg.JWT.Secret, cfg.JWT.Expiry) if err != nil { - ctx.JSON(500, gin.H{"error": "Failed to verify email", "message": err.Error()}) + log.Printf("Token generation error: %v", err) + ctx.JSON(500, gin.H{"error": "Failed to generate token", "message": err.Error()}) return } - ctx.JSON(200, gin.H{"message": "Email verification successful"}) + ctx.JSON(http.StatusOK, gin.H{ + "message": "Google login successful", + "accessToken": token, + }) } -func Login(ctx *gin.Context) { +func SignUp(ctx *gin.Context) { cfg := loadConfig(ctx) if cfg == nil { return } - var request structs.LoginRequest + var request structs.SignUpRequest if err := ctx.ShouldBindJSON(&request); err != nil { - ctx.JSON(400, gin.H{"error": "Invalid input", "message": "Check email and password format"}) + log.Printf("Binding error: %v", err) + ctx.JSON(400, gin.H{"error": "Invalid input", "message": err.Error()}) + return + } + + // Check if user already exists + dbCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var existingUser models.User + err := db.MongoDatabase.Collection("users").FindOne(dbCtx, bson.M{"email": request.Email}).Decode(&existingUser) + if err == nil { + ctx.JSON(400, gin.H{"error": "User already exists"}) + return + } + if err != mongo.ErrNoDocuments { + ctx.JSON(500, gin.H{"error": "Database error", "message": err.Error()}) return } - token, err := loginWithCognito(cfg.Cognito.AppClientId, cfg.Cognito.AppClientSecret, request.Email, request.Password, ctx) + // Hash password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(request.Password), bcrypt.DefaultCost) if err != nil { - ctx.JSON(401, gin.H{"error": "Failed to sign in", "message": "Invalid email or password"}) + ctx.JSON(500, gin.H{"error": "Failed to hash password", "message": err.Error()}) return } - ctx.JSON(200, gin.H{"message": "Sign-in successful", "accessToken": token}) -} + // Generate verification code + verificationCode := utils.GenerateRandomCode(6) -func ForgotPassword(ctx *gin.Context) { - cfg := loadConfig(ctx) - if cfg == nil { - return + // Create new user + newUser := models.User{ + Email: request.Email, + Password: string(hashedPassword), + Nickname: utils.ExtractNameFromEmail(request.Email), + EloRating: 1200, + IsVerified: false, + VerificationCode: verificationCode, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), } - var request structs.ForgotPasswordRequest - if err := ctx.ShouldBindJSON(&request); err != nil { - ctx.JSON(400, gin.H{"error": "Invalid input", "message": "Check email format"}) + // Insert user into MongoDB + _, err = db.MongoDatabase.Collection("users").InsertOne(dbCtx, newUser) + if err != nil { + ctx.JSON(500, gin.H{"error": "Failed to create user", "message": err.Error()}) return } - _, err := initiateForgotPassword(cfg.Cognito.AppClientId, cfg.Cognito.AppClientSecret, request.Email, ctx) + // Send verification email (implement email sending logic) + err = utils.SendVerificationEmail(request.Email, verificationCode) if err != nil { - ctx.JSON(500, gin.H{"error": "Failed to initiate password reset", "message": err.Error()}) + ctx.JSON(500, gin.H{"error": "Failed to send verification email", "message": err.Error()}) return } - ctx.JSON(200, gin.H{"message": "Password reset initiated. Check your email for further instructions."}) + ctx.JSON(200, gin.H{"message": "Sign-up successful. Please verify your email."}) } -func VerifyForgotPassword(ctx *gin.Context) { +func VerifyEmail(ctx *gin.Context) { cfg := loadConfig(ctx) if cfg == nil { return } - var request structs.VerifyForgotPasswordRequest + var request structs.VerifyEmailRequest if err := ctx.ShouldBindJSON(&request); err != nil { ctx.JSON(400, gin.H{"error": "Invalid input", "message": err.Error()}) return } - _, err := confirmForgotPassword(cfg.Cognito.AppClientId, cfg.Cognito.AppClientSecret, request.Email, request.Code, request.NewPassword, ctx) + dbCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var user models.User + err := db.MongoDatabase.Collection("users").FindOne(dbCtx, bson.M{"email": request.Email, "verificationCode": request.ConfirmationCode}).Decode(&user) if err != nil { - ctx.JSON(500, gin.H{"error": "Failed to confirm password reset", "message": err.Error()}) + ctx.JSON(400, gin.H{"error": "Invalid email or verification code"}) return } - ctx.JSON(200, gin.H{"message": "Password successfully changed"}) + // Update user verification status + update := bson.M{ + "$set": bson.M{"isVerified": true, "verificationCode": "", "updatedAt": time.Now()}, + } + _, err = db.MongoDatabase.Collection("users").UpdateOne(dbCtx, bson.M{"email": request.Email}, update) + if err != nil { + ctx.JSON(500, gin.H{"error": "Failed to verify email", "message": err.Error()}) + return + } + + ctx.JSON(200, gin.H{"message": "Email verification successful"}) } -func VerifyToken(ctx *gin.Context) { +func Login(ctx *gin.Context) { cfg := loadConfig(ctx) if cfg == nil { return } - authHeader := ctx.GetHeader("Authorization") - if authHeader == "" { - ctx.JSON(401, gin.H{"error": "Missing token"}) + var request structs.LoginRequest + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input", "message": "Check email and password format"}) return } - tokenParts := strings.Split(authHeader, " ") - if len(tokenParts) != 2 || tokenParts[0] != "Bearer" { - ctx.JSON(400, gin.H{"error": "Invalid token format"}) + // Find user in MongoDB + dbCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var user models.User + err := db.MongoDatabase.Collection("users").FindOne(dbCtx, bson.M{"email": request.Email}).Decode(&user) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid email or password"}) return } - token := tokenParts[1] - valid, err := validateTokenWithCognito(cfg.Cognito.UserPoolId, token, ctx) + // Check if user is verified + if !user.IsVerified { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Email not verified"}) + return + } + + // Verify password + err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(request.Password)) if err != nil { - ctx.JSON(401, gin.H{"error": "Invalid or expired token", "message": err.Error()}) + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid email or password"}) return } - if !valid { - ctx.JSON(401, gin.H{"error": "Token is invalid or expired"}) + // Generate JWT + token, err := generateJWT(user.Email, cfg.JWT.Secret, cfg.JWT.Expiry) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token", "message": err.Error()}) return } - ctx.JSON(200, gin.H{"message": "Token is valid"}) + ctx.JSON(http.StatusOK, gin.H{ + "message": "Sign-in successful", + "accessToken": token, + }) } -func loadConfig(ctx *gin.Context) *config.Config { - cfgPath := os.Getenv("CONFIG_PATH") - if cfgPath == "" { - cfgPath = "./config/config.prod.yml" +func ForgotPassword(ctx *gin.Context) { + cfg := loadConfig(ctx) + if cfg == nil { + return } - cfg, err := config.LoadConfig(cfgPath) - if err != nil { - log.Println("Failed to load config") - ctx.JSON(500, gin.H{"error": "Internal server error"}) - return nil + + var request structs.ForgotPasswordRequest + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(400, gin.H{"error": "Invalid input", "message": "Check email format"}) + return } - return cfg -} -func signUpWithCognito(appClientId, appClientSecret, email, password string, ctx *gin.Context) error { - config, err := awsConfig.LoadDefaultConfig(ctx, awsConfig.WithRegion("ap-south-1")) + // Find user + dbCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var user models.User + err := db.MongoDatabase.Collection("users").FindOne(dbCtx, bson.M{"email": request.Email}).Decode(&user) if err != nil { - log.Println("Error loading AWS config:", err) - return fmt.Errorf("failed to load AWS config: %v", err) - } - - cognitoClient := cognitoidentityprovider.NewFromConfig(config) - - secretHash := utils.GenerateSecretHash(email, appClientId, appClientSecret) - - signupInput := cognitoidentityprovider.SignUpInput{ - ClientId: aws.String(appClientId), - Password: aws.String(password), - SecretHash: aws.String(secretHash), - Username: aws.String(email), - UserAttributes: []types.AttributeType{ - { - Name: aws.String("email"), - Value: aws.String(email), - }, - { - Name: aws.String("nickname"), - Value: aws.String(utils.ExtractNameFromEmail(email)), - }, - }, + ctx.JSON(400, gin.H{"error": "User not found"}) + return } - signupStatus, err := cognitoClient.SignUp(ctx, &signupInput) + // Generate reset code + resetCode := utils.GenerateRandomCode(6) + + // Update user with reset code + update := bson.M{ + "$set": bson.M{"resetPasswordCode": resetCode, "updatedAt": time.Now()}, + } + _, err = db.MongoDatabase.Collection("users").UpdateOne(dbCtx, bson.M{"email": request.Email}, update) if err != nil { - log.Println("Error during sign-up:", err) - return fmt.Errorf("sign-up failed: %v", err) + ctx.JSON(500, gin.H{"error": "Failed to initiate password reset", "message": err.Error()}) + return } - log.Println("Sign-up successful:", signupStatus) - return nil -} - -func verifyEmailWithCognito(appClientId, appClientSecret, email, confirmationCode string, ctx *gin.Context) error { - config, err := awsConfig.LoadDefaultConfig(ctx, awsConfig.WithRegion("ap-south-1")) + // Send reset email + err = utils.SendPasswordResetEmail(request.Email, resetCode) if err != nil { - log.Println("Error loading AWS config:", err) - return fmt.Errorf("failed to load AWS config: %v", err) + ctx.JSON(500, gin.H{"error": "Failed to send reset email", "message": err.Error()}) + return } - cognitoClient := cognitoidentityprovider.NewFromConfig(config) + ctx.JSON(200, gin.H{"message": "Password reset initiated. Check your email for further instructions."}) +} - secretHash := utils.GenerateSecretHash(email, appClientId, appClientSecret) +func VerifyForgotPassword(ctx *gin.Context) { + cfg := loadConfig(ctx) + if cfg == nil { + return + } - confirmSignUpInput := cognitoidentityprovider.ConfirmSignUpInput{ - ClientId: aws.String(appClientId), - ConfirmationCode: aws.String(confirmationCode), - Username: aws.String(email), - SecretHash: aws.String(secretHash), + var request structs.VerifyForgotPasswordRequest + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(400, gin.H{"error": "Invalid input", "message": err.Error()}) + return } - confirmationStatus, err := cognitoClient.ConfirmSignUp(ctx, &confirmSignUpInput) + // Find user with reset code + dbCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var user models.User + err := db.MongoDatabase.Collection("users").FindOne(dbCtx, bson.M{"email": request.Email, "resetPasswordCode": request.Code}).Decode(&user) if err != nil { - log.Println("Error during email verification:", err) - return fmt.Errorf("email verification failed: %v", err) + ctx.JSON(400, gin.H{"error": "Invalid email or reset code"}) + return } - log.Println("Email verification successful:", confirmationStatus) - return nil -} - -func loginWithCognito(appClientId, appClientSecret, email, password string, ctx *gin.Context) (string, error) { - config, err := awsConfig.LoadDefaultConfig(ctx, awsConfig.WithRegion("ap-south-1")) + // Hash new password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(request.NewPassword), bcrypt.DefaultCost) if err != nil { - return "", fmt.Errorf("failed to load AWS config") + ctx.JSON(500, gin.H{"error": "Failed to hash password", "message": err.Error()}) + return } - cognitoClient := cognitoidentityprovider.NewFromConfig(config) - secretHash := utils.GenerateSecretHash(email, appClientId, appClientSecret) - - authInput := cognitoidentityprovider.InitiateAuthInput{ - AuthFlow: types.AuthFlowTypeUserPasswordAuth, - ClientId: aws.String(appClientId), - AuthParameters: map[string]string{ - "USERNAME": email, - "PASSWORD": password, - "SECRET_HASH": secretHash, + // Update user with new password + update := bson.M{ + "$set": bson.M{ + "password": string(hashedPassword), + "resetPasswordCode": "", + "updatedAt": time.Now(), }, } - - authOutput, err := cognitoClient.InitiateAuth(ctx, &authInput) + _, err = db.MongoDatabase.Collection("users").UpdateOne(dbCtx, bson.M{"email": request.Email}, update) if err != nil { - return "", fmt.Errorf("authentication failed") + ctx.JSON(500, gin.H{"error": "Failed to reset password", "message": err.Error()}) + return } - return *authOutput.AuthenticationResult.AccessToken, nil + ctx.JSON(200, gin.H{"message": "Password successfully changed"}) } -func initiateForgotPassword(appClientId, appClientSecret, email string, ctx *gin.Context) (*cognitoidentityprovider.ForgotPasswordOutput, error) { - config, err := awsConfig.LoadDefaultConfig(ctx, awsConfig.WithRegion("ap-south-1")) - if err != nil { - return nil, fmt.Errorf("failed to load AWS config") +func VerifyToken(ctx *gin.Context) { + cfg := loadConfig(ctx) + if cfg == nil { + return } - cognitoClient := cognitoidentityprovider.NewFromConfig(config) - secretHash := utils.GenerateSecretHash(email, appClientId, appClientSecret) + authHeader := ctx.GetHeader("Authorization") + if authHeader == "" { + ctx.JSON(401, gin.H{"error": "Missing token"}) + return + } - forgotPasswordInput := cognitoidentityprovider.ForgotPasswordInput{ - ClientId: aws.String(appClientId), - Username: aws.String(email), - SecretHash: aws.String(secretHash), + tokenParts := strings.Split(authHeader, " ") + if len(tokenParts) != 2 || tokenParts[0] != "Bearer" { + ctx.JSON(400, gin.H{"error": "Invalid token format"}) + return } + tokenString := tokenParts[1] - output, err := cognitoClient.ForgotPassword(ctx, &forgotPasswordInput) + // Validate JWT + claims, err := validateJWT(tokenString, cfg.JWT.Secret) if err != nil { - return nil, fmt.Errorf("error initiating forgot password: %v", err) + ctx.JSON(401, gin.H{"error": "Invalid or expired token", "message": err.Error()}) + return } - return output, nil -} - -func confirmForgotPassword(appClientId, appClientSecret, email, code, newPassword string, ctx *gin.Context) (*cognitoidentityprovider.ConfirmForgotPasswordOutput, error) { - config, err := awsConfig.LoadDefaultConfig(ctx, awsConfig.WithRegion("ap-south-1")) + // Verify user exists in MongoDB + dbCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var user models.User + err = db.MongoDatabase.Collection("users").FindOne(dbCtx, bson.M{"email": claims["sub"].(string)}).Decode(&user) if err != nil { - return nil, fmt.Errorf("failed to load AWS config") + ctx.JSON(401, gin.H{"error": "User not found"}) + return } - cognitoClient := cognitoidentityprovider.NewFromConfig(config) - secretHash := utils.GenerateSecretHash(email, appClientId, appClientSecret) + ctx.JSON(200, gin.H{"message": "Token is valid"}) +} - confirmForgotPasswordInput := cognitoidentityprovider.ConfirmForgotPasswordInput{ - ClientId: aws.String(appClientId), - Username: aws.String(email), - ConfirmationCode: aws.String(code), - Password: aws.String(newPassword), - SecretHash: aws.String(secretHash), +// Helper function to generate JWT +func generateJWT(email, secret string, expiryMinutes int) (string, error) { + claims := jwt.MapClaims{ + "sub": email, + "exp": time.Now().Add(time.Minute * time.Duration(expiryMinutes)).Unix(), + "iat": time.Now().Unix(), } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(secret)) +} - output, err := cognitoClient.ConfirmForgotPassword(ctx, &confirmForgotPasswordInput) +// Helper function to validate JWT +func validateJWT(tokenString, secret string) (jwt.MapClaims, error) { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(secret), nil + }) if err != nil { - return nil, fmt.Errorf("error confirming forgot password: %v", err) + return nil, err } - - return output, nil + if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + return claims, nil + } + return nil, fmt.Errorf("invalid token") } -func validateTokenWithCognito(userPoolId, token string, ctx *gin.Context) (bool, error) { - config, err := awsConfig.LoadDefaultConfig(ctx, awsConfig.WithRegion("ap-south-1")) - if err != nil { - return false, fmt.Errorf("failed to load AWS config") +func loadConfig(ctx *gin.Context) *config.Config { + cfgPath := os.Getenv("CONFIG_PATH") + if cfgPath == "" { + cfgPath = "./config/config.prod.yml" } - - cognitoClient := cognitoidentityprovider.NewFromConfig(config) - - _, err = cognitoClient.GetUser(ctx, &cognitoidentityprovider.GetUserInput{ - AccessToken: aws.String(token), - }) + cfg, err := config.LoadConfig(cfgPath) if err != nil { - log.Println("Token verification failed:", err) - return false, fmt.Errorf("token validation failed: %v", err) + log.Println("Failed to load config") + ctx.JSON(500, gin.H{"error": "Internal server error"}) + return nil } - - return true, nil -} \ No newline at end of file + return cfg +} diff --git a/backend/controllers/debate_controller.go b/backend/controllers/debate_controller.go new file mode 100644 index 0000000..3581c1d --- /dev/null +++ b/backend/controllers/debate_controller.go @@ -0,0 +1,100 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +// DebateMessage holds a single text message in the debate. +type DebateMessage struct { + User string `json:"user"` + Phase string `json:"phase"` + Message string `json:"message"` + Timestamp time.Time `json:"timestamp"` +} + +// DebateRoom stores all messages for a debate room. +type DebateRoom struct { + RoomID string `json:"roomId"` + Messages []DebateMessage `json:"messages"` + Mutex sync.Mutex `json:"-"` +} + +var debateRooms = make(map[string]*DebateRoom) +var debateRoomsMutex sync.Mutex + +// SubmitDebateMessageHandler handles the POST request for a new debate message. +func SubmitDebateMessageHandler(c *gin.Context) { + roomID := c.Query("room") + if roomID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "room parameter required"}) + return + } + + var msg DebateMessage + if err := c.ShouldBindJSON(&msg); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"}) + return + } + msg.Timestamp = time.Now() + + // Get or create the debate room. + debateRoomsMutex.Lock() + room, exists := debateRooms[roomID] + if !exists { + room = &DebateRoom{ + RoomID: roomID, + Messages: []DebateMessage{}, + } + debateRooms[roomID] = room + } + debateRoomsMutex.Unlock() + + // Append the new message safely. + room.Mutex.Lock() + room.Messages = append(room.Messages, msg) + room.Mutex.Unlock() + + // Persist the current transcript to disk asynchronously. + go persistDebateRoom(room) + + c.JSON(http.StatusOK, gin.H{"status": "message received"}) +} + +// GetDebateTranscriptHandler returns the complete transcript for a debate room. +func GetDebateTranscriptHandler(c *gin.Context) { + roomID := c.Query("room") + if roomID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "room parameter required"}) + return + } + debateRoomsMutex.Lock() + room, exists := debateRooms[roomID] + debateRoomsMutex.Unlock() + if !exists { + c.JSON(http.StatusNotFound, gin.H{"error": "room not found"}) + return + } + c.JSON(http.StatusOK, room) +} + +func persistDebateRoom(room *DebateRoom) { + room.Mutex.Lock() + defer room.Mutex.Unlock() + data, err := json.MarshalIndent(room, "", " ") + if err != nil { + log.Println("Error marshaling room data:", err) + return + } + filename := fmt.Sprintf("room_%s.json", room.RoomID) + if err := os.WriteFile(filename, data, 0644); err != nil { + log.Println("Error writing file:", err) + } +} diff --git a/backend/controllers/debatevsbot_controller.go b/backend/controllers/debatevsbot_controller.go new file mode 100644 index 0000000..860f13e --- /dev/null +++ b/backend/controllers/debatevsbot_controller.go @@ -0,0 +1,208 @@ +package controllers + +import ( + "log" + "strings" + "time" + + "arguehub/db" + "arguehub/models" + "arguehub/services" + "arguehub/utils" + + "github.com/gin-gonic/gin" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +type DebateRequest struct { + BotName string `json:"botName" binding:"required"` + BotLevel string `json:"botLevel" binding:"required"` + Topic string `json:"topic" binding:"required"` + Stance string `json:"stance" binding:"required"` + History []models.Message `json:"history"` + PhaseTimings []PhaseTiming `json:"phaseTimings"` + Context string `json:"context"` +} + +type PhaseTiming struct { + Name string `json:"name" binding:"required"` + Time int `json:"time" binding:"required"` // Single time value in seconds +} + +type JudgeRequest struct { + History []models.Message `json:"history" binding:"required"` +} + +type DebateResponse struct { + DebateId string `json:"debateId"` + BotName string `json:"botName"` + BotLevel string `json:"botLevel"` + Topic string `json:"topic"` + Stance string `json:"stance"` + PhaseTimings []models.PhaseTiming `json:"phaseTimings,omitempty"` // Backend format +} + +type DebateMessageResponse struct { + DebateId string `json:"debateId"` + BotName string `json:"botName"` + BotLevel string `json:"botLevel"` + Topic string `json:"topic"` + Stance string `json:"stance"` + Response string `json:"response"` +} + +type JudgeResponse struct { + Result string `json:"result"` +} + +func CreateDebate(c *gin.Context) { + // Extract token from request header + token := c.GetHeader("Authorization") + if token == "" { + c.JSON(401, gin.H{"error": "Authorization token required"}) + return + } + + token = strings.TrimPrefix(token, "Bearer ") + // Validate token and get user email + valid, userEmail, err := utils.ValidateTokenAndFetchEmail("./config/config.prod.yml", token, c) + if err != nil || !valid { + c.JSON(401, gin.H{"error": "Invalid or expired token"}) + return + } + + var req DebateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": "Invalid request payload: " + err.Error()}) + return + } + + // Convert PhaseTimings to backend model format + backendPhaseTimings := make([]models.PhaseTiming, len(req.PhaseTimings)) + for i, pt := range req.PhaseTimings { + backendPhaseTimings[i] = models.PhaseTiming{ + Name: pt.Name, + UserTime: pt.Time, + BotTime: pt.Time, + } + } + + debate := models.DebateVsBot{ + UserEmail: userEmail, + BotName: req.BotName, + BotLevel: req.BotLevel, + Topic: req.Topic, + Stance: req.Stance, + History: req.History, + PhaseTimings: backendPhaseTimings, + CreatedAt: time.Now().Unix(), + } + + debateID, err := services.CreateDebateService(&debate, req.Stance) + if err != nil { + c.JSON(500, gin.H{"error": "Failed to create debate: " + err.Error()}) + return + } + + response := DebateResponse{ + DebateId: debateID, + BotName: req.BotName, + BotLevel: req.BotLevel, + Topic: req.Topic, + Stance: req.Stance, + PhaseTimings: backendPhaseTimings, + } + c.JSON(200, response) +} + +func SendDebateMessage(c *gin.Context) { + token := c.GetHeader("Authorization") + if token == "" { + c.JSON(401, gin.H{"error": "Authorization token required"}) + return + } + + token = strings.TrimPrefix(token, "Bearer ") + valid, userEmail, err := utils.ValidateTokenAndFetchEmail("./config/config.prod.yml", token, c) + if err != nil || !valid { + c.JSON(401, gin.H{"error": "Invalid or expired token"}) + return + } + + var req DebateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": "Invalid request payload: " + err.Error()}) + return + } + + // Generate bot response with the additional context field. + botResponse := services.GenerateBotResponse(req.BotName, req.BotLevel, req.Topic, req.History, req.Stance, req.Context, 150) + + // Update debate history with the bot's response. + updatedHistory := append(req.History, models.Message{ + Sender: "Bot", + Text: botResponse, + // You can also store the phase if needed. + }) + + debate := models.DebateVsBot{ + UserEmail: userEmail, + BotName: req.BotName, + BotLevel: req.BotLevel, + Topic: req.Topic, + Stance: req.Stance, + History: updatedHistory, + CreatedAt: time.Now().Unix(), + } + + // Save to database (assuming ID is generated in service or here) + if debate.ID.IsZero() { + debate.ID = primitive.NewObjectID() + } + if err := db.SaveDebateVsBot(debate); err != nil { + log.Printf("Failed to save debate: %v", err) + } + + response := DebateMessageResponse{ + DebateId: debate.ID.Hex(), + BotName: req.BotName, + BotLevel: req.BotLevel, + Topic: req.Topic, + Stance: req.Stance, + Response: botResponse, + } + c.JSON(200, response) +} + +func JudgeDebate(c *gin.Context) { + token := c.GetHeader("Authorization") + if token == "" { + c.JSON(401, gin.H{"error": "Authorization token required"}) + return + } + + token = strings.TrimPrefix(token, "Bearer ") + valid, userEmail, err := utils.ValidateTokenAndFetchEmail("./config/config.prod.yml", token, c) + if err != nil || !valid { + c.JSON(401, gin.H{"error": "Invalid or expired token"}) + return + } + + var req JudgeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": "Invalid request payload: " + err.Error()}) + return + } + + // Judge the debate + result := services.JudgeDebate(req.History) + + // Update debate outcome + if err := db.UpdateDebateVsBotOutcome(userEmail, result); err != nil { + log.Printf("Failed to update debate outcome: %v", err) + } + + c.JSON(200, JudgeResponse{ + Result: result, + }) +} diff --git a/backend/controllers/leaderboard.go b/backend/controllers/leaderboard.go new file mode 100644 index 0000000..84426c7 --- /dev/null +++ b/backend/controllers/leaderboard.go @@ -0,0 +1,107 @@ +package controllers + +import ( + "log" + "net/http" + "strconv" + + "arguehub/db" + "arguehub/models" + "arguehub/utils" + + "github.com/gin-gonic/gin" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// LeaderboardData defines the response structure for the frontend +type LeaderboardData struct { + Debaters []Debater `json:"debaters"` + Stats []Stat `json:"stats"` +} + +// Debater represents a leaderboard entry +type Debater struct { + ID string `json:"id"` + Rank int `json:"rank"` + Name string `json:"name"` + Score int `json:"score"` + AvatarURL string `json:"avatarUrl"` + CurrentUser bool `json:"currentUser"` +} + +// Stat represents a single statistic +type Stat struct { + Icon string `json:"icon"` + Value string `json:"value"` + Label string `json:"label"` +} + +// GetLeaderboard fetches and returns leaderboard data +func GetLeaderboard(c *gin.Context) { + // Check for authenticated user + currentUserEmail, exists := c.Get("userEmail") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + // Query users sorted by EloRating (descending) + collection := db.MongoDatabase.Collection("users") + findOptions := options.Find().SetSort(bson.D{{"eloRating", -1}}) + cursor, err := collection.Find(c, bson.M{}, findOptions) + if err != nil { + log.Printf("Failed to fetch users: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch leaderboard data"}) + return + } + defer cursor.Close(c) + + // Decode users into slice + var users []models.User + if err := cursor.All(c, &users); err != nil { + log.Printf("Failed to decode users: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decode leaderboard data"}) + return + } + + // Build debaters list + var debaters []Debater + for i, user := range users { + name := user.DisplayName + if name == "" { + name = utils.ExtractNameFromEmail(user.Email) + } + + avatarURL := user.AvatarURL + if avatarURL == "" { + avatarURL = "https://api.dicebear.com/9.x/adventurer/svg?seed=" + name + } + + isCurrentUser := user.Email == currentUserEmail + debaters = append(debaters, Debater{ + ID: user.ID.Hex(), + Rank: i + 1, + Name: name, + Score: user.EloRating, + AvatarURL: avatarURL, + CurrentUser: isCurrentUser, + }) + } + + // Generate stats + totalUsers := len(users) + stats := []Stat{ + {Icon: "crown", Value: strconv.Itoa(totalUsers), Label: "REGISTERED DEBATERS"}, + {Icon: "chessQueen", Value: "430", Label: "DEBATES TODAY"}, // Placeholder + {Icon: "medal", Value: "98", Label: "DEBATING NOW"}, // Placeholder + {Icon: "crown", Value: "37", Label: "EXPERTS ONLINE"}, // Placeholder + } + + // Send response + response := LeaderboardData{ + Debaters: debaters, + Stats: stats, + } + c.JSON(http.StatusOK, response) +} diff --git a/backend/controllers/profile_controller.go b/backend/controllers/profile_controller.go new file mode 100644 index 0000000..8d9960f --- /dev/null +++ b/backend/controllers/profile_controller.go @@ -0,0 +1,348 @@ +package controllers + +import ( + "context" + "net/http" + "time" + + "arguehub/db" + "arguehub/models" + + "github.com/gin-gonic/gin" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// calculateEloRating computes new Elo ratings after a match +func calculateEloRating(ratingA, ratingB int, scoreA float64) (newRatingA, newRatingB int) { + const K = 32 + expectedA := 1.0 / (1.0 + pow(10, float64(ratingB-ratingA)/400.0)) + scoreB := 1.0 - scoreA + expectedB := 1.0 - expectedA + + newRatingA = ratingA + int(float64(K)*(scoreA-expectedA)) + newRatingB = ratingB + int(float64(K)*(scoreB-expectedB)) + return newRatingA, newRatingB +} + +// pow computes base^exponent as a simple helper +func pow(base, exponent float64) float64 { + result := 1.0 + for i := 0; i < int(exponent); i++ { + result *= base + } + return result +} + +// GetProfile retrieves and returns user profile data +func GetProfile(ctx *gin.Context) { + userEmail := ctx.GetString("userEmail") + if userEmail == "" { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + dbCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Fetch user profile + var user models.User + err := db.MongoDatabase.Collection("users").FindOne(dbCtx, bson.M{"email": userEmail}).Decode(&user) + if err != nil { + if err == mongo.ErrNoDocuments { + ctx.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + } else { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + } + return + } + + // Set avatar URL with DiceBear fallback + profileAvatarURL := user.AvatarURL + if profileAvatarURL == "" { + profileName := user.DisplayName + if profileName == "" { + profileName = extractNameFromEmail(userEmail) + } + profileAvatarURL = "https://api.dicebear.com/9.x/adventurer/svg?seed=" + profileName + } + + // Fetch leaderboard + leaderboardCursor, err := db.MongoDatabase.Collection("users").Find( + dbCtx, + bson.M{}, + options.Find().SetSort(bson.M{"eloRating": -1}).SetLimit(10), + ) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Error fetching leaderboard"}) + return + } + defer leaderboardCursor.Close(dbCtx) + + var leaderboard []struct { + Rank int `json:"rank"` + Name string `json:"name"` + Score int `json:"score"` + AvatarUrl string `json:"avatarUrl"` + CurrentUser bool `json:"currentUser"` + } + rank := 1 + for leaderboardCursor.Next(dbCtx) { + var lbUser models.User + leaderboardCursor.Decode(&lbUser) + lbAvatarURL := lbUser.AvatarURL + if lbAvatarURL == "" { + lbName := lbUser.DisplayName + if lbName == "" { + lbName = extractNameFromEmail(lbUser.Email) + } + lbAvatarURL = "https://api.dicebear.com/9.x/adventurer/svg?seed=" + lbName + } + leaderboard = append(leaderboard, struct { + Rank int `json:"rank"` + Name string `json:"name"` + Score int `json:"score"` + AvatarUrl string `json:"avatarUrl"` + CurrentUser bool `json:"currentUser"` + }{rank, lbUser.DisplayName, lbUser.EloRating, lbAvatarURL, lbUser.Email == userEmail}) + rank++ + } + + // Fetch debate history + debateCursor, err := db.MongoDatabase.Collection("debates").Find( + dbCtx, + bson.M{"userEmail": userEmail}, + options.Find().SetSort(bson.M{"date": -1}).SetLimit(5), + ) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Error fetching debate history"}) + return + } + defer debateCursor.Close(dbCtx) + + var debates []struct { + Topic string `bson:"topic" json:"topic"` + Result string `bson:"result" json:"result"` + EloChange int `bson:"eloChange" json:"eloChange"` + Date time.Time `bson:"date" json:"date"` + } + if err := debateCursor.All(dbCtx, &debates); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Error decoding debate history"}) + return + } + + // Aggregate stats (wins, losses, draws) + pipeline := mongo.Pipeline{ + bson.D{{"$match", bson.M{"userEmail": userEmail}}}, + bson.D{{"$group", bson.M{ + "_id": nil, + "wins": bson.M{"$sum": bson.M{"$cond": bson.M{"if": bson.M{"$eq": []string{"$result", "win"}}, "then": 1, "else": 0}}}, + "losses": bson.M{"$sum": bson.M{"$cond": bson.M{"if": bson.M{"$eq": []string{"$result", "loss"}}, "then": 1, "else": 0}}}, + "draws": bson.M{"$sum": bson.M{"$cond": bson.M{"if": bson.M{"$eq": []string{"$result", "draw"}}, "then": 1, "else": 0}}}, + }}}, + } + statsCursor, err := db.MongoDatabase.Collection("debates").Aggregate(dbCtx, pipeline) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Error aggregating stats"}) + return + } + defer statsCursor.Close(dbCtx) + + var stats struct { + Wins int `json:"wins"` + Losses int `json:"losses"` + Draws int `json:"draws"` + } + if statsCursor.Next(dbCtx) { + statsCursor.Decode(&stats) + } + + // Build Elo history + eloCursor, err := db.MongoDatabase.Collection("debates").Find( + dbCtx, + bson.M{"userEmail": userEmail}, + options.Find().SetSort(bson.M{"date": 1}), + ) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Error fetching Elo history"}) + return + } + defer eloCursor.Close(dbCtx) + + var eloHistory []struct { + Month string `json:"month"` + Elo int `json:"elo"` + } + currentElo := user.EloRating + for eloCursor.Next(dbCtx) { + var debate struct { + Date time.Time `bson:"date"` + EloChange int `bson:"eloChange"` + } + eloCursor.Decode(&debate) + currentElo -= debate.EloChange + eloHistory = append([]struct { + Month string `json:"month"` + Elo int `json:"elo"` + }{{debate.Date.Format("January"), currentElo}}, eloHistory...) + } + eloHistory = append(eloHistory, struct { + Month string `json:"month"` + Elo int `json:"elo"` + }{time.Now().Format("January"), user.EloRating}) + + // Construct response + response := gin.H{ + "profile": gin.H{ + "displayName": user.DisplayName, + "email": user.Email, + "bio": user.Bio, + "eloRating": user.EloRating, + "avatarUrl": profileAvatarURL, + }, + "leaderboard": leaderboard, + "debateHistory": debates, + "stats": gin.H{ + "wins": stats.Wins, + "losses": stats.Losses, + "draws": stats.Draws, + "eloHistory": eloHistory, + }, + } + ctx.JSON(http.StatusOK, response) +} + +// UpdateProfile modifies user display name and bio +func UpdateProfile(ctx *gin.Context) { + userEmail := ctx.GetString("userEmail") + if userEmail == "" { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized", "message": "Missing user email in context"}) + return + } + + var updateData struct { + DisplayName string `json:"displayName"` + Bio string `json:"bio"` + } + if err := ctx.ShouldBindJSON(&updateData); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "message": err.Error()}) + return + } + + dbCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + filter := bson.M{"email": userEmail} + update := bson.M{"$set": bson.M{ + "displayName": updateData.DisplayName, + "bio": updateData.Bio, + }} + _, err := db.MongoDatabase.Collection("users").UpdateOne(dbCtx, filter, update) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "Profile updated successfully"}) +} + +// UpdateEloAfterDebate updates Elo ratings for winner and loser +func UpdateEloAfterDebate(ctx *gin.Context) { + var req struct { + WinnerID string `json:"winnerId"` + LoserID string `json:"loserId"` + Topic string `json:"topic"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "message": err.Error()}) + return + } + + dbCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + winnerObjID, err := primitive.ObjectIDFromHex(req.WinnerID) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid winnerId"}) + return + } + loserObjID, err := primitive.ObjectIDFromHex(req.LoserID) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid loserId"}) + return + } + + var winner, loser models.User + if err = db.MongoDatabase.Collection("users").FindOne(dbCtx, bson.M{"_id": winnerObjID}).Decode(&winner); err != nil { + if err == mongo.ErrNoDocuments { + ctx.JSON(http.StatusNotFound, gin.H{"error": "Winner not found"}) + } else { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Error fetching winner from DB"}) + } + return + } + + if err = db.MongoDatabase.Collection("users").FindOne(dbCtx, bson.M{"_id": loserObjID}).Decode(&loser); err != nil { + if err == mongo.ErrNoDocuments { + ctx.JSON(http.StatusNotFound, gin.H{"error": "Loser not found"}) + } else { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Error fetching loser from DB"}) + } + return + } + + // Calculate new Elo ratings + newWinnerElo, newLoserElo := calculateEloRating(winner.EloRating, loser.EloRating, 1.0) + winnerEloChange := newWinnerElo - winner.EloRating + loserEloChange := newLoserElo - loser.EloRating + + // Update user Elo ratings + _, err = db.MongoDatabase.Collection("users").UpdateOne(dbCtx, bson.M{"_id": winnerObjID}, bson.M{"$set": bson.M{"eloRating": newWinnerElo}}) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + _, err = db.MongoDatabase.Collection("users").UpdateOne(dbCtx, bson.M{"_id": loserObjID}, bson.M{"$set": bson.M{"eloRating": newLoserElo}}) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + // Record debate results + now := time.Now() + winnerDebate := models.Debate{ + UserEmail: winner.Email, + Topic: req.Topic, + Result: "win", + EloChange: winnerEloChange, + Date: now, + } + loserDebate := models.Debate{ + UserEmail: loser.Email, + Topic: req.Topic, + Result: "loss", + EloChange: loserEloChange, + Date: now, + } + + db.MongoDatabase.Collection("debates").InsertOne(dbCtx, winnerDebate) + db.MongoDatabase.Collection("debates").InsertOne(dbCtx, loserDebate) + + ctx.JSON(http.StatusOK, gin.H{ + "winnerNewElo": newWinnerElo, + "loserNewElo": newLoserElo, + }) +} + +// extractNameFromEmail extracts the name from an email address +func extractNameFromEmail(email string) string { + for i, char := range email { + if char == '@' { + return email[:i] + } + } + return email +} diff --git a/backend/controllers/transcript_controller.go b/backend/controllers/transcript_controller.go new file mode 100644 index 0000000..aecc185 --- /dev/null +++ b/backend/controllers/transcript_controller.go @@ -0,0 +1,31 @@ +package controllers + +import ( + "net/http" + + "arguehub/services" + + "github.com/gin-gonic/gin" +) + +type SubmitTranscriptsRequest struct { + RoomID string `json:"roomId" binding:"required"` + Role string `json:"role" binding:"required,oneof=for against"` + Transcripts map[string]string `json:"transcripts" binding:"required"` +} + +func SubmitTranscripts(c *gin.Context) { + var req SubmitTranscriptsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body: " + err.Error()}) + return + } + + result, err := services.SubmitTranscripts(req.RoomID, req.Role, req.Transcripts) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, result) +} \ No newline at end of file diff --git a/backend/db/db.go b/backend/db/db.go new file mode 100644 index 0000000..186ee09 --- /dev/null +++ b/backend/db/db.go @@ -0,0 +1,77 @@ +package db + +import ( + "arguehub/models" + "context" + "fmt" + "log" + "net/url" + "time" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var MongoClient *mongo.Client +var MongoDatabase *mongo.Database +var DebateVsBotCollection *mongo.Collection + +// extractDBName parses the database name from the URI, defaulting to "test" +func extractDBName(uri string) string { + u, err := url.Parse(uri) + if err != nil { + return "test" + } + if u.Path != "" && u.Path != "/" { + return u.Path[1:] // Trim leading '/' + } + return "test" +} + +// ConnectMongoDB establishes a connection to MongoDB using the provided URI +func ConnectMongoDB(uri string) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + clientOptions := options.Client().ApplyURI(uri) + client, err := mongo.Connect(ctx, clientOptions) + if err != nil { + return fmt.Errorf("failed to connect to MongoDB: %w", err) + } + + // Verify connection with a ping + if err := client.Ping(ctx, nil); err != nil { + return fmt.Errorf("failed to ping MongoDB: %w", err) + } + + MongoClient = client + dbName := extractDBName(uri) + log.Printf("Using database: %s", dbName) + + MongoDatabase = client.Database(dbName) + DebateVsBotCollection = MongoDatabase.Collection("debates_vs_bot") + return nil +} + +// SaveDebateVsBot saves a bot debate session to MongoDB +func SaveDebateVsBot(debate models.DebateVsBot) error { + _, err := DebateVsBotCollection.InsertOne(context.Background(), debate) + if err != nil { + log.Printf("Error saving debate: %v", err) + return err + } + return nil +} + +// UpdateDebateVsBotOutcome updates the outcome of the most recent bot debate for a user +func UpdateDebateVsBotOutcome(userId, outcome string) error { + filter := bson.M{"userId": userId} + update := bson.M{"$set": bson.M{"outcome": outcome}} + _, err := DebateVsBotCollection.UpdateOne(context.Background(), filter, update, nil) + if err != nil { + log.Printf("Error updating debate outcome: %v", err) + return err + } + return nil +} diff --git a/backend/go.mod b/backend/go.mod index 68ff1cb..df95980 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -6,11 +6,22 @@ require ( github.com/aws/aws-sdk-go-v2 v1.32.2 github.com/aws/aws-sdk-go-v2/config v1.28.0 github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider v1.46.2 + github.com/gin-contrib/cors v1.7.2 github.com/gin-gonic/gin v1.10.0 + github.com/google/generative-ai-go v0.19.0 github.com/gorilla/websocket v1.5.3 + go.mongodb.org/mongo-driver v1.17.3 + google.golang.org/api v0.228.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( + cloud.google.com/go v0.115.0 // indirect + cloud.google.com/go/ai v0.8.0 // indirect + cloud.google.com/go/auth v0.15.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.6.0 // indirect + cloud.google.com/go/longrunning v0.5.7 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.17.41 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.17 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.21 // indirect @@ -26,27 +37,52 @@ require ( github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect - github.com/gin-contrib/cors v1.7.2 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect + github.com/googleapis/gax-go/v2 v2.14.1 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.16.7 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/montanaflynn/stats v0.7.1 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.1.2 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect + go.opentelemetry.io/otel v1.34.0 // indirect + go.opentelemetry.io/otel/metric v1.34.0 // indirect + go.opentelemetry.io/otel/trace v1.34.0 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/crypto v0.23.0 // indirect - golang.org/x/net v0.25.0 // indirect - golang.org/x/sys v0.20.0 // indirect - golang.org/x/text v0.15.0 // indirect - google.golang.org/protobuf v1.34.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/net v0.37.0 // indirect + golang.org/x/oauth2 v0.28.0 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + golang.org/x/time v0.11.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 // indirect + google.golang.org/grpc v1.71.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 7c0b670..3e52213 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,3 +1,15 @@ +cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14= +cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU= +cloud.google.com/go/ai v0.8.0 h1:rXUEz8Wp2OlrM8r1bfmpF2+VKqc1VJpafE3HgzRnD/w= +cloud.google.com/go/ai v0.8.0/go.mod h1:t3Dfk4cM61sytiggo2UyGsDVW3RF1qGZaUKDrZFyqkE= +cloud.google.com/go/auth v0.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps= +cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= +cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= +cloud.google.com/go/longrunning v0.5.7 h1:WLbHekDbjK1fVFD3ibpFFVoyizlLRl73I7YKuAKilhU= +cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng= github.com/aws/aws-sdk-go-v2 v1.32.2 h1:AkNLZEyYMLnx/Q/mSKkcMqwNFXMAvFto9bNsHqcTduI= github.com/aws/aws-sdk-go-v2 v1.32.2/go.mod h1:2SK5n0a2karNTv5tbP1SjsX0uhttou00v/HpXKM1ZUo= github.com/aws/aws-sdk-go-v2/config v1.28.0 h1:FosVYWcqEtWNxHn8gB/Vs6jOlNwSoyOCA/g/sxyySOQ= @@ -37,6 +49,8 @@ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw= @@ -45,6 +59,11 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -55,17 +74,39 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/generative-ai-go v0.19.0 h1:R71szggh8wHMCUlEMsW2A/3T+5LdEIkiaHSYgSpUgdg= +github.com/google/generative-ai-go v0.19.0/go.mod h1:JYolL13VG7j79kM5BtHz4qwONHkeJQzOCkKXnpqtS/E= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= +github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= +github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= +github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -75,10 +116,14 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= +github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -89,32 +134,95 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ= +go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 h1:rgMkmiGfix9vFJDcDi1PK8WEQP4FLQwLDfhp5ZLpFeE= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= +go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= +go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= +go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= +go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= +go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= +go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= +go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= -golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= +golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= +golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= -google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.228.0 h1:X2DJ/uoWGnY5obVjewbp8icSL5U4FzuCfy9OjbLSnLs= +google.golang.org/api v0.228.0/go.mod h1:wNvRS1Pbe8r4+IfBIniV8fwCpGwTrYa+kMUDiC5z5a4= +google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 h1:GVIKPyP/kLIyVOgOnTwFOrvQaQUzOzGMCxgFUOEmm24= +google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422/go.mod h1:b6h1vNKhxaSoEI+5jc3PJUCustfli/mRab7295pY7rw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 h1:iK2jbkWL86DXjEx0qiHcRE9dE4/Ahua5k6V8OWFb//c= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= +google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= +google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/middlewares/auth.go b/backend/middlewares/auth.go new file mode 100644 index 0000000..a8517ff --- /dev/null +++ b/backend/middlewares/auth.go @@ -0,0 +1,62 @@ +package middlewares + +import ( + "arguehub/config" + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +func AuthMiddleware(configPath string) gin.HandlerFunc { + return func(c *gin.Context) { + cfg, err := config.LoadConfig(configPath) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load configuration"}) + c.Abort() + return + } + + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is required"}) + c.Abort() + return + } + + tokenParts := strings.Split(authHeader, " ") + if len(tokenParts) != 2 || tokenParts[0] != "Bearer" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token format"}) + c.Abort() + return + } + + claims, err := validateJWT(tokenParts[1], cfg.JWT.Secret) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token", "message": err.Error()}) + c.Abort() + return + } + + c.Set("email", claims["sub"].(string)) + c.Next() + } +} + +func validateJWT(tokenString, secret string) (jwt.MapClaims, error) { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(secret), nil + }) + if err != nil { + return nil, err + } + if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + return claims, nil + } + return nil, fmt.Errorf("invalid token") +} diff --git a/backend/models/coach.go b/backend/models/coach.go new file mode 100644 index 0000000..24f21f2 --- /dev/null +++ b/backend/models/coach.go @@ -0,0 +1,21 @@ +package models + +// WeakStatement represents a weak opening statement stored in MongoDB +type WeakStatement struct { + ID string `json:"id"` + Topic string `json:"topic"` + Stance string `json:"stance"` + Text string `json:"text"` +} + +// EvaluateArgumentRequest is the payload sent by the frontend to evaluate an argument +type EvaluateArgumentRequest struct { + WeakStatementID string `json:"weakStatementId" binding:"required"` + UserResponse string `json:"userResponse" binding:"required"` +} + +// Evaluation is the response from the Gemini API +type Evaluation struct { + Score int `json:"score"` + Feedback string `json:"feedback"` +} \ No newline at end of file diff --git a/backend/models/debate.go b/backend/models/debate.go new file mode 100644 index 0000000..da32c88 --- /dev/null +++ b/backend/models/debate.go @@ -0,0 +1,36 @@ +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// Debate defines a single debate record +type Debate struct { + ID primitive.ObjectID `bson:"_id,omitempty" json:"id,omitempty"` + UserEmail string `bson:"userEmail" json:"userEmail"` + Topic string `bson:"topic" json:"topic"` + Result string `bson:"result" json:"result"` + EloChange int `bson:"eloChange" json:"eloChange"` + Date time.Time `bson:"date" json:"date"` +} + +type DebateTopic struct { + Topic string `bson:"topic" json:"topic"` + Difficulty string `bson:"difficulty" json:"difficulty"` // "beginner", "intermediate", "advanced" +} + +// ProsConsEvaluation holds the evaluation results for pros and cons +type ProsConsEvaluation struct { + Pros []ArgumentEvaluation `json:"pros"` + Cons []ArgumentEvaluation `json:"cons"` + Score int `json:"score"` // Total score out of 50 +} + +// ArgumentEvaluation represents the evaluation of a single argument +type ArgumentEvaluation struct { + Score int `json:"score"` // 1-10 + Feedback string `json:"feedback"` + Counter string `json:"counter"` // Counterargument +} \ No newline at end of file diff --git a/backend/models/debatevsbot.go b/backend/models/debatevsbot.go new file mode 100644 index 0000000..fc10eca --- /dev/null +++ b/backend/models/debatevsbot.go @@ -0,0 +1,31 @@ +package models + +import "go.mongodb.org/mongo-driver/bson/primitive" + +// Message represents a single message in the debate +type Message struct { + Sender string `json:"sender" bson:"sender"` // "User", "Bot", or "Judge" + Text string `json:"text" bson:"text"` + Phase string `json:"phase,omitempty" bson:"phase,omitempty"` // Added for phase-specific tracking +} + +// PhaseTiming represents the timing configuration for a debate phase +type PhaseTiming struct { + Name string `json:"name" bson:"name"` + UserTime int `json:"userTime" bson:"userTime"` // Time in seconds for user + BotTime int `json:"botTime" bson:"botTime"` // Time in seconds for bot +} + +// DebateVsBot represents a debate session against a bot +type DebateVsBot struct { + ID primitive.ObjectID `json:"id" bson:"_id,omitempty"` + UserEmail string `json:"userEmail" bson:"userEmail"` + BotName string `json:"botName" bson:"botName"` + BotLevel string `json:"botLevel" bson:"botLevel"` + Topic string `json:"topic" bson:"topic"` + Stance string `json:"stance" bson:"stance"` // Added to track bot's stance + History []Message `json:"history" bson:"history"` + PhaseTimings []PhaseTiming `json:"phaseTimings" bson:"phaseTimings"` // Added for custom timings + Outcome string `json:"outcome" bson:"outcome"` // Result of the debate (e.g., "User wins") + CreatedAt int64 `json:"createdAt" bson:"createdAt"` +} diff --git a/backend/models/transcript.go b/backend/models/transcript.go new file mode 100644 index 0000000..81417ae --- /dev/null +++ b/backend/models/transcript.go @@ -0,0 +1,19 @@ +package models + +import ( + "time" +) + +type DebateTranscript struct { + RoomID string `bson:"roomId" json:"roomId"` + Role string `bson:"role" json:"role"` + Transcripts map[string]string `bson:"transcripts" json:"transcripts"` + CreatedAt time.Time `bson:"createdAt" json:"createdAt"` + UpdatedAt time.Time `bson:"updatedAt" json:"updatedAt"` +} + +type DebateResult struct { + RoomID string `bson:"roomId" json:"roomId"` + Result string `bson:"result" json:"result"` + CreatedAt time.Time `bson:"createdAt" json:"createdAt"` +} diff --git a/backend/models/user.go b/backend/models/user.go new file mode 100644 index 0000000..f70e5b2 --- /dev/null +++ b/backend/models/user.go @@ -0,0 +1,24 @@ +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// User defines a user entity +type User struct { + ID primitive.ObjectID `bson:"_id,omitempty" json:"id,omitempty"` + Email string `bson:"email" json:"email"` + DisplayName string `bson:"displayName" json:"displayName"` + Bio string `bson:"bio" json:"bio"` + EloRating int `bson:"eloRating" json:"eloRating"` + AvatarURL string `bson:"avatarUrl,omitempty" json:"avatarUrl,omitempty"` + Password string `bson:"password"` + Nickname string `bson:"nickname"` + IsVerified bool `bson:"isVerified"` + VerificationCode string `bson:"verificationCode,omitempty"` + ResetPasswordCode string `bson:"resetPasswordCode,omitempty"` + CreatedAt time.Time `bson:"createdAt"` + UpdatedAt time.Time `bson:"updatedAt"` +} diff --git a/backend/routes/auth.go b/backend/routes/auth.go index 17dd8fd..0e54cd0 100644 --- a/backend/routes/auth.go +++ b/backend/routes/auth.go @@ -6,26 +6,30 @@ import ( "github.com/gin-gonic/gin" ) -func SignUpRouteHandler(ctx *gin.Context) { - controllers.SignUp(ctx) +func GoogleLoginRouteHandler(c *gin.Context) { + controllers.GoogleLogin(c) } -func VerifyEmailRouteHandler(ctx *gin.Context) { - controllers.VerifyEmail(ctx) +func SignUpRouteHandler(c *gin.Context) { + controllers.SignUp(c) } -func LoginRouteHandler(ctx *gin.Context) { - controllers.Login(ctx) +func VerifyEmailRouteHandler(c *gin.Context) { + controllers.VerifyEmail(c) } -func ForgotPasswordRouteHandler(ctx *gin.Context) { - controllers.ForgotPassword(ctx) +func LoginRouteHandler(c *gin.Context) { + controllers.Login(c) } -func VerifyForgotPasswordRouteHandler(ctx *gin.Context) { - controllers.VerifyForgotPassword(ctx) +func ForgotPasswordRouteHandler(c *gin.Context) { + controllers.ForgotPassword(c) } -func VerifyTokenRouteHandler(ctx *gin.Context) { - controllers.VerifyToken(ctx) -} \ No newline at end of file +func VerifyForgotPasswordRouteHandler(c *gin.Context) { + controllers.VerifyForgotPassword(c) +} + +func VerifyTokenRouteHandler(c *gin.Context) { + controllers.VerifyToken(c) +} diff --git a/backend/routes/coach.go b/backend/routes/coach.go new file mode 100644 index 0000000..94fa43b --- /dev/null +++ b/backend/routes/coach.go @@ -0,0 +1,63 @@ +package routes + +import ( + "arguehub/services" + "net/http" + + "github.com/gin-gonic/gin" +) + +// GetWeakStatement generates a weak statement based on the user-provided topic and stance +func GetWeakStatement(c *gin.Context) { + topic := c.Query("topic") + stance := c.Query("stance") + + if topic == "" || stance == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Both topic and stance are required"}) + return + } + + weakStatement, err := services.GenerateWeakStatement(topic, stance) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate weak statement"}) + return + } + c.JSON(http.StatusOK, weakStatement) +} + +// EvaluateStrengthenedArgument evaluates the user's improved statement +func EvaluateStrengthenedArgument(c *gin.Context) { + var req struct { + Topic string `json:"topic" binding:"required"` + Stance string `json:"stance" binding:"required"` + WeakStatementText string `json:"weakStatementText" binding:"required"` + UserResponse string `json:"userResponse" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request payload"}) + return + } + + // Evaluate the argument using the Gemini API with all required arguments + evaluation, err := services.EvaluateArgument(req.Topic, req.Stance, req.WeakStatementText, req.UserResponse) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to evaluate argument"}) + return + } + + // Calculate points (score * 10) + pointsEarned := evaluation.Score * 10 + + // Update user's points + userID := c.GetString("user_id") + if err := services.UpdateUserPoints(userID, pointsEarned); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user points"}) + return + } + + // Return feedback and points + c.JSON(http.StatusOK, gin.H{ + "feedback": evaluation.Feedback, + "pointsEarned": pointsEarned, + }) +} diff --git a/backend/routes/debatevsbot.go b/backend/routes/debatevsbot.go new file mode 100644 index 0000000..9e42541 --- /dev/null +++ b/backend/routes/debatevsbot.go @@ -0,0 +1,17 @@ +package routes + +import ( + "arguehub/controllers" + + "github.com/gin-gonic/gin" +) + +// SetupDebateVsBotRoutes sets up the debate-related routes for bot debates +func SetupDebateVsBotRoutes(router *gin.RouterGroup) { + vsbot := router.Group("/vsbot") + { + vsbot.POST("/create", controllers.CreateDebate) + vsbot.POST("/debate", controllers.SendDebateMessage) + vsbot.POST("/judge", controllers.JudgeDebate) + } +} diff --git a/backend/routes/leaderboard.go b/backend/routes/leaderboard.go new file mode 100644 index 0000000..58820b9 --- /dev/null +++ b/backend/routes/leaderboard.go @@ -0,0 +1,11 @@ +package routes + +import ( + "arguehub/controllers" + + "github.com/gin-gonic/gin" +) + +func GetLeaderboardRouteHandler(c *gin.Context) { + controllers.GetLeaderboard(c) +} diff --git a/backend/routes/profile.go b/backend/routes/profile.go new file mode 100644 index 0000000..59a9d72 --- /dev/null +++ b/backend/routes/profile.go @@ -0,0 +1,19 @@ +package routes + +import ( + "arguehub/controllers" + + "github.com/gin-gonic/gin" +) + +func GetProfileRouteHandler(ctx *gin.Context) { + controllers.GetProfile(ctx) +} + +func UpdateProfileRouteHandler(ctx *gin.Context) { + controllers.UpdateProfile(ctx) +} + +func UpdateEloAfterDebateRouteHandler(ctx *gin.Context) { + controllers.UpdateEloAfterDebate(ctx) +} diff --git a/backend/routes/pros_cons_route.go b/backend/routes/pros_cons_route.go new file mode 100644 index 0000000..1ba0cb5 --- /dev/null +++ b/backend/routes/pros_cons_route.go @@ -0,0 +1,49 @@ +package routes + +import ( + "arguehub/services" + "net/http" + + "github.com/gin-gonic/gin" +) + +// GetProsConsTopic generates a debate topic based on a default skill level +func GetProsConsTopic(c *gin.Context) { + // Use a default skill level of "beginner" since SkillLevel is not available + skillLevel := "intermediate" + + topic, err := services.GenerateDebateTopic(skillLevel) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"topic": topic}) +} + +// SubmitProsCons evaluates the user's arguments +func SubmitProsCons(c *gin.Context) { + var req struct { + Topic string `json:"topic" binding:"required"` + Pros []string `json:"pros" binding:"required,max=5"` + Cons []string `json:"cons" binding:"required,max=5"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request payload"}) + return + } + + evaluation, err := services.EvaluateProsCons(req.Topic, req.Pros, req.Cons) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Update user points (score * 2 for simplicity) + userID := c.GetString("user_id") + if err := services.UpdateUserPoints(userID, evaluation.Score*2); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update points"}) + return + } + + c.JSON(http.StatusOK, evaluation) +} \ No newline at end of file diff --git a/backend/routes/rooms.go b/backend/routes/rooms.go new file mode 100644 index 0000000..b2336de --- /dev/null +++ b/backend/routes/rooms.go @@ -0,0 +1,153 @@ +package routes + +import ( + "context" + "log" + "math/rand" + "net/http" + "strconv" + "time" + + "arguehub/db" + + "github.com/gin-gonic/gin" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// Room represents a debate room. +type Room struct { + ID string `json:"id" bson:"_id"` + Type string `json:"type" bson:"type"` + Participants []Participant `json:"participants" bson:"participants"` +} + +// Participant represents a user in a room. +type Participant struct { + ID string `json:"id" bson:"id"` + Username string `json:"username" bson:"username"` + Elo int `json:"elo" bson:"elo"` +} + +// generateRoomID creates a random six-digit room ID as a string. +func generateRoomID() string { + rand.Seed(time.Now().UnixNano()) + return strconv.Itoa(rand.Intn(900000) + 100000) +} + +// CreateRoomHandler handles POST /rooms and creates a new debate room. +// CreateRoomHandler handles POST /rooms and creates a new debate room. +func CreateRoomHandler(c *gin.Context) { + type CreateRoomInput struct { + Type string `json:"type"` // public, private, invite + } + + var input CreateRoomInput + if err := c.ShouldBindJSON(&input); err != nil || input.Type == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input"}) + return + } + + // Get user email from middleware-set context + userEmail, exists := c.Get("userEmail") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: user email not found"}) + return + } + + // Query user document using email + userCollection := db.MongoClient.Database("DebateAI").Collection("users") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var user struct { + ID string `bson:"_id"` + Email string `bson:"email"` + DisplayName string `bson:"displayName"` + EloRating int `bson:"eloRating"` + } + + err := userCollection.FindOne(ctx, bson.M{"email": userEmail}).Decode(&user) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + + // Add the room creator as the first participant + creatorParticipant := Participant{ + ID: user.ID, + Username: user.DisplayName, + Elo: user.EloRating, + } + + roomID := generateRoomID() + newRoom := Room{ + ID: roomID, + Type: input.Type, + Participants: []Participant{creatorParticipant}, + } + + roomCollection := db.MongoClient.Database("DebateAI").Collection("rooms") + _, err = roomCollection.InsertOne(ctx, newRoom) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create room"}) + return + } + + c.JSON(http.StatusOK, newRoom) +} + + +// GetRoomsHandler handles GET /rooms and returns all rooms. +func GetRoomsHandler(c *gin.Context) { + log.Println("🔍 GetRoomsHandler called") + + collection := db.MongoClient.Database("DebateAI").Collection("rooms") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cursor, err := collection.Find(ctx, bson.D{}) + if err != nil { + log.Printf("❌ Error fetching rooms from DB: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Error fetching rooms"}) + return + } + + var rooms []Room + if err = cursor.All(ctx, &rooms); err != nil { + log.Printf("❌ Error decoding rooms cursor: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Error decoding rooms"}) + return + } + + log.Printf("✅ Successfully fetched %d rooms", len(rooms)) + c.JSON(http.StatusOK, rooms) +} + +// JoinRoomHandler handles POST /rooms/:id/join where a user joins a room. +func JoinRoomHandler(c *gin.Context) { + roomId := c.Param("id") + collection := db.MongoClient.Database("DebateAI").Collection("rooms") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Dummy participant (replace this with actual user in production) + dummyUser := Participant{ + ID: "dummyUserID", + Username: "JohnDoe", + Elo: 1200, + } + + filter := bson.M{"_id": roomId} + update := bson.M{ + "$addToSet": bson.M{"participants": dummyUser}, + } + opts := options.FindOneAndUpdate().SetReturnDocument(options.After) + + var updatedRoom Room + if err := collection.FindOneAndUpdate(ctx, filter, update, opts).Decode(&updatedRoom); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not join room"}) + return + } + c.JSON(http.StatusOK, updatedRoom) +} diff --git a/backend/routes/transcriptroutes.go b/backend/routes/transcriptroutes.go new file mode 100644 index 0000000..c361ac9 --- /dev/null +++ b/backend/routes/transcriptroutes.go @@ -0,0 +1,11 @@ +package routes + +import ( + "arguehub/controllers" + + "github.com/gin-gonic/gin" +) + +func SetupTranscriptRoutes(router *gin.RouterGroup) { + router.POST("/api/submit-transcripts", controllers.SubmitTranscripts) +} diff --git a/backend/services/ai.go b/backend/services/ai.go index 123dc04..d62cce2 100644 --- a/backend/services/ai.go +++ b/backend/services/ai.go @@ -1,4 +1,4 @@ -package main +package services import ( "bytes" @@ -12,7 +12,6 @@ import ( appConfig "arguehub/config" "strings" - ) type OpenAIRequest struct { @@ -98,8 +97,8 @@ func (c *ChatGPT) Chat(model, developerPrompt, userMessage string) (string, erro } type DebateFormat struct { - Sections []string `json:"sections"` - CurrentTurn string `json:"currentTurn"` // User ID of the current user's turn. + Sections []string `json:"sections"` + CurrentTurn string `json:"currentTurn"` // User ID of the current user's turn. } type DebateContent map[string]map[string]string @@ -163,19 +162,18 @@ func main() { debateSections := []string{"opening", "constructive argument", "rebuttal", "closing"} debateContent := DebateContent{ "Participant1": { - "opening": "Participant 1: Good evening, everyone. Today, I stand firmly on the side of nature in the nature vs. nurture debate. Our genetic makeup profoundly influences who we are, from our physical characteristics to innate talents and predispositions. Scientific studies, such as those involving identical twins raised apart, show remarkable similarities in traits like intelligence, temperament, and even preferences. This demonstrates that nature plays a crucial role in shaping our identity.", + "opening": "Participant 1: Good evening, everyone. Today, I stand firmly on the side of nature in the nature vs. nurture debate. Our genetic makeup profoundly influences who we are, from our physical characteristics to innate talents and predispositions. Scientific studies, such as those involving identical twins raised apart, show remarkable similarities in traits like intelligence, temperament, and even preferences. This demonstrates that nature plays a crucial role in shaping our identity.", "constructive argument": "Participant 1: Consider the field of behavioral genetics, which has consistently found strong correlations between genetics and traits like personality, intelligence, and even susceptibility to certain mental health conditions. Furthermore, evolutionary psychology highlights how traits passed down through generations influence our behavior. For example, fight-or-flight responses are innate survival mechanisms, hardwired into our DNA. The evidence clearly indicates that nature is the dominant factor in determining who we are.", - "rebuttal": "Participant 1: My opponent argues that environment and upbringing shape individuals significantly. While I agree that nurture has an influence, it often acts as a moderator rather than a creator of traits. For example, a child with a natural aptitude for music will excel when given the right environment, but that aptitude originates from their genetic predisposition. Without nature providing the foundation, nurture alone would not yield such results.", - "closing": "Participant 1: In conclusion, the evidence overwhelmingly supports the idea that nature is the primary determinant of who we are. While nurture can shape and refine, it is our genetic blueprint that sets the stage for our potential. Thank you.", + "rebuttal": "Participant 1: My opponent argues that environment and upbringing shape individuals significantly. While I agree that nurture has an influence, it often acts as a moderator rather than a creator of traits. For example, a child with a natural aptitude for music will excel when given the right environment, but that aptitude originates from their genetic predisposition. Without nature providing the foundation, nurture alone would not yield such results.", + "closing": "Participant 1: In conclusion, the evidence overwhelmingly supports the idea that nature is the primary determinant of who we are. While nurture can shape and refine, it is our genetic blueprint that sets the stage for our potential. Thank you.", }, "Participant2": { - "opening": "Participant 2: Good evening, everyone. I firmly believe that nurture plays a more significant role in shaping who we are. Our experiences, education, and environment define our abilities, beliefs, and personalities. Studies have shown that children raised in enriched environments tend to perform better academically and socially, regardless of their genetic background. This clearly demonstrates the power of nurture.", + "opening": "Participant 2: Good evening, everyone. I firmly believe that nurture plays a more significant role in shaping who we are. Our experiences, education, and environment define our abilities, beliefs, and personalities. Studies have shown that children raised in enriched environments tend to perform better academically and socially, regardless of their genetic background. This clearly demonstrates the power of nurture.", "constructive argument": "Participant 2: Consider how culture and upbringing influence language, behavior, and values. A child born with a genetic predisposition for intelligence will not reach their full potential without proper education and support. Moreover, cases of children overcoming genetic disadvantages through determination and favorable environments underscore the importance of nurture. The famous case of Albert Einstein, who was considered a slow learner as a child but thrived due to a nurturing environment, is a testament to this.", - "rebuttal": "Participant 2: My opponent emphasizes genetic influence but overlooks the dynamic role of environment. For instance, identical twins raised apart often show differences in attitudes, hobbies, and career choices due to their distinct environments. Genes provide a starting point, but it is nurture that refines and ultimately shapes those traits into tangible outcomes. Without proper nurturing, even the most promising genetic traits can remain dormant.", - "closing": "Participant 2: In conclusion, while nature provides the raw material, it is nurture that sculpts it into something meaningful. The environment, experiences, and opportunities we encounter ultimately determine who we become. Thank you.", + "rebuttal": "Participant 2: My opponent emphasizes genetic influence but overlooks the dynamic role of environment. For instance, identical twins raised apart often show differences in attitudes, hobbies, and career choices due to their distinct environments. Genes provide a starting point, but it is nurture that refines and ultimately shapes those traits into tangible outcomes. Without proper nurturing, even the most promising genetic traits can remain dormant.", + "closing": "Participant 2: In conclusion, while nature provides the raw material, it is nurture that sculpts it into something meaningful. The environment, experiences, and opportunities we encounter ultimately determine who we become. Thank you.", }, } - debateFormat := DebateFormat{Sections: debateSections} @@ -187,4 +185,4 @@ func main() { fmt.Println("Evaluation Result:") fmt.Println(result) -} \ No newline at end of file +} diff --git a/backend/services/coach.go b/backend/services/coach.go new file mode 100644 index 0000000..51e0530 --- /dev/null +++ b/backend/services/coach.go @@ -0,0 +1,204 @@ +package services + +import ( + "arguehub/db" + "arguehub/models" + "context" + "encoding/json" + "errors" + "fmt" + "log" + "strings" + + "github.com/google/generative-ai-go/genai" + "go.mongodb.org/mongo-driver/bson" +) + +// InitCoachService is now a no-op since we don’t need a collection anymore +func InitCoachService() { + log.Println("Coach service initialized") +} + +// GenerateWeakStatement generates a weak opening statement for a given topic and stance using Gemini +func GenerateWeakStatement(topic, stance string) (models.WeakStatement, error) { + if geminiClient == nil { + log.Println("Gemini client not initialized") + return models.WeakStatement{}, errors.New("Gemini client not initialized") + } + + // Construct the prompt for Gemini to generate a full-fledged weak statement + prompt := fmt.Sprintf( + `Act as a debate coach and generate a weak opening statement for the topic "%s" taking the stance "%s". +The statement should: +- Be a full paragraph or more. +- Be vague or lack specific reasoning. +- Avoid strong evidence or persuasive language. +- Be simple and open to improvement. + +Required Output Format (JSON): +{ + "id": "generated", + "text": "your weak statement here" +} + +Provide ONLY the JSON output without additional text or markdown formatting.`, + topic, stance, + ) + + ctx := context.Background() + model := geminiClient.GenerativeModel("gemini-1.5-flash") + + // Set safety settings to BLOCK_NONE + model.SafetySettings = []*genai.SafetySetting{ + {Category: genai.HarmCategoryHarassment, Threshold: genai.HarmBlockNone}, + {Category: genai.HarmCategoryHateSpeech, Threshold: genai.HarmBlockNone}, + {Category: genai.HarmCategorySexuallyExplicit, Threshold: genai.HarmBlockNone}, + {Category: genai.HarmCategoryDangerousContent, Threshold: genai.HarmBlockNone}, + } + + resp, err := model.GenerateContent(ctx, genai.Text(prompt)) + if err != nil { + log.Printf("Gemini error generating weak statement: %v", err) + return models.WeakStatement{}, fmt.Errorf("failed to generate weak statement: %v", err) + } + + if resp.PromptFeedback != nil && resp.PromptFeedback.BlockReason != 0 { + log.Printf("Prompt blocked: %v", resp.PromptFeedback.BlockReason) + return models.WeakStatement{}, fmt.Errorf("prompt blocked: %v", resp.PromptFeedback.BlockReason) + } + + if len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 { + log.Println("No valid response from Gemini") + return models.WeakStatement{}, errors.New("no weak statement generated") + } + + for _, part := range resp.Candidates[0].Content.Parts { + if text, ok := part.(genai.Text); ok { + cleanedText := string(text) + cleanedText = strings.TrimSpace(cleanedText) + cleanedText = strings.TrimPrefix(cleanedText, "```json") + cleanedText = strings.TrimSuffix(cleanedText, "```") + cleanedText = strings.TrimPrefix(cleanedText, "```") + cleanedText = strings.TrimSpace(cleanedText) + + log.Printf("Cleaned Gemini response: %s", cleanedText) + + // Temporary struct to parse Gemini's JSON response + type geminiResponse struct { + ID string `json:"id"` + Text string `json:"text"` + } + var gr geminiResponse + err = json.Unmarshal([]byte(cleanedText), &gr) + if err != nil { + log.Printf("Failed to parse weak statement JSON: %v. Raw response: %s", err, cleanedText) + return models.WeakStatement{}, fmt.Errorf("invalid weak statement format: %v", err) + } + + // Validate required fields are present + if gr.ID == "" || gr.Text == "" { + log.Printf("Generated weak statement missing required fields: %+v", gr) + return models.WeakStatement{}, errors.New("invalid response format: missing fields") + } + + // Create the WeakStatement with topic and stance included + weakStatement := models.WeakStatement{ + ID: gr.ID, + Topic: topic, + Stance: stance, + Text: gr.Text, + } + return weakStatement, nil + } + } + + return models.WeakStatement{}, errors.New("no valid weak statement returned") +} + +// EvaluateArgument evaluates the user's improved argument against the weak statement +func EvaluateArgument(topic, stance, weakStatementText, userResponse string) (models.Evaluation, error) { + if geminiClient == nil { + log.Println("Gemini client not initialized") + return models.Evaluation{}, errors.New("Gemini client not initialized") + } + + prompt := fmt.Sprintf( + `Act as a debate coach and evaluate the user's improved argument for the topic "%s" with stance "%s", based on the original weak statement. Provide feedback and a score out of 10 in JSON format. + +Evaluation Criteria: +1. Strength of Argument (up to 4 points): Clarity of position, persuasiveness, and logical flow. +2. Use of Evidence (up to 3 points): Inclusion of supporting details or reasoning. +3. Expression (up to 3 points): Language proficiency and articulation. + +Original Weak Statement: "%s" +User's Improved Argument: "%s" + +Required Output Format: +{ + "score": X, + "feedback": "text describing strengths and areas for improvement" +} + +Provide ONLY the JSON output without additional text or markdown formatting.`, + topic, stance, weakStatementText, userResponse, + ) + + ctx := context.Background() + model := geminiClient.GenerativeModel("gemini-1.5-flash") + + model.SafetySettings = []*genai.SafetySetting{ + {Category: genai.HarmCategoryHarassment, Threshold: genai.HarmBlockNone}, + {Category: genai.HarmCategoryHateSpeech, Threshold: genai.HarmBlockNone}, + {Category: genai.HarmCategorySexuallyExplicit, Threshold: genai.HarmBlockNone}, + {Category: genai.HarmCategoryDangerousContent, Threshold: genai.HarmBlockNone}, + } + + resp, err := model.GenerateContent(ctx, genai.Text(prompt)) + if err != nil { + log.Printf("Gemini error: %v", err) + return models.Evaluation{}, fmt.Errorf("failed to evaluate argument: %v", err) + } + + if resp.PromptFeedback != nil && resp.PromptFeedback.BlockReason != 0 { + log.Printf("Prompt blocked: %v", resp.PromptFeedback.BlockReason) + return models.Evaluation{}, fmt.Errorf("prompt blocked due to safety settings: %v", resp.PromptFeedback.BlockReason) + } + + if len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 { + log.Println("No valid response from Gemini") + return models.Evaluation{}, errors.New("no evaluation returned") + } + + for _, part := range resp.Candidates[0].Content.Parts { + if text, ok := part.(genai.Text); ok { + cleanedText := string(text) + cleanedText = strings.TrimSpace(cleanedText) + cleanedText = strings.TrimPrefix(cleanedText, "```json") + cleanedText = strings.TrimSuffix(cleanedText, "```") + cleanedText = strings.TrimPrefix(cleanedText, "```") + cleanedText = strings.TrimSpace(cleanedText) + + log.Printf("Cleaned Gemini response: %s", cleanedText) + + var evaluation models.Evaluation + err = json.Unmarshal([]byte(cleanedText), &evaluation) + if err != nil { + log.Printf("Failed to parse evaluation JSON: %v. Raw response: %s", err, cleanedText) + return models.Evaluation{}, fmt.Errorf("invalid evaluation format: %v", err) + } + return evaluation, nil + } + } + + return models.Evaluation{}, errors.New("no valid evaluation returned") +} + +// UpdateUserPoints increments the user's total points in the database +func UpdateUserPoints(userID string, points int) error { + _, err := db.MongoDatabase.Collection("users").UpdateOne( + context.Background(), + bson.M{"_id": userID}, + bson.M{"$inc": bson.M{"total_points": points}}, + ) + return err +} diff --git a/backend/services/debatevsbot.go b/backend/services/debatevsbot.go new file mode 100644 index 0000000..628cdb5 --- /dev/null +++ b/backend/services/debatevsbot.go @@ -0,0 +1,402 @@ +package services + +import ( + "context" + "fmt" + "log" + "strings" + "time" + + "arguehub/config" + "arguehub/db" + "arguehub/models" + + "github.com/google/generative-ai-go/genai" + "go.mongodb.org/mongo-driver/bson/primitive" + "google.golang.org/api/option" +) + +// Global Gemini client instance +var geminiClient *genai.Client + +// InitDebateVsBotService initializes the Gemini client using the API key from the config +func InitDebateVsBotService(cfg *config.Config) { + var err error + geminiClient, err = genai.NewClient(context.Background(), option.WithAPIKey(cfg.Gemini.ApiKey)) + if err != nil { + log.Fatalf("Failed to initialize Gemini client: %v", err) + } +} + +// BotPersonality defines the debate bot's personality +type BotPersonality struct { + Name string + Level string +} + +// GetBotPersonality returns the personality details for a given bot name +func GetBotPersonality(botName string) BotPersonality { + switch botName { + case "Rookie Rick": + return BotPersonality{Name: "Rookie Rick", Level: "Easy"} + case "Casual Casey": + return BotPersonality{Name: "Casual Casey", Level: "Easy"} + case "Moderate Mike": + return BotPersonality{Name: "Moderate Mike", Level: "Medium"} + case "Sassy Sarah": + return BotPersonality{Name: "Sassy Sarah", Level: "Medium"} + case "Innovative Iris": + return BotPersonality{Name: "Innovative Iris", Level: "Medium"} + case "Tough Tony": + return BotPersonality{Name: "Tough Tony", Level: "Hard"} + case "Expert Emma": + return BotPersonality{Name: "Expert Emma", Level: "Hard"} + case "Grand Greg": + return BotPersonality{Name: "Grand Greg", Level: "Expert"} + default: + return BotPersonality{Name: botName, Level: "Medium"} + } +} + +// FormatHistory converts a slice of debate messages into a formatted transcript +func FormatHistory(history []models.Message) string { + var sb strings.Builder + for _, msg := range history { + phase := msg.Phase + if phase == "" { + phase = "Unspecified Phase" + } + sb.WriteString(fmt.Sprintf("%s (%s): %s\n", msg.Sender, phase, msg.Text)) + } + return sb.String() +} + +// findLastUserMessage returns the most recent message in the history from the "User". +// If no user message is found, it falls back to the last message in the history. +func findLastUserMessage(history []models.Message) models.Message { + for i := len(history) - 1; i >= 0; i-- { + if history[i].Sender == "User" { + return history[i] + } + } + // Fallback: return the last message even if it's from the bot. + return history[len(history)-1] +} + +// constructPrompt builds a prompt that adjusts based on bot personality, debate topic, history, +// extra context, and uses the provided stance directly. It includes phase-specific instructions. +func constructPrompt(bot BotPersonality, topic string, history []models.Message, stance, extraContext string, maxWords int) string { + // Level-based instructions + levelInstructions := "" + switch strings.ToLower(bot.Level) { + case "easy": + levelInstructions = "Use simple language and straightforward arguments." + case "medium": + levelInstructions = "Use moderate language with clear reasoning and some details." + case "hard", "expert": + levelInstructions = "Employ complex, nuanced arguments with in-depth reasoning." + default: + levelInstructions = "Use clear and balanced language." + } + + // Personality-based instructions to add more disparity + personalityInstructions := "" + switch bot.Name { + case "Rookie Rick": + personalityInstructions = "Keep your language simple and a bit naive." + case "Casual Casey": + personalityInstructions = "Maintain a friendly and relaxed tone." + case "Moderate Mike": + personalityInstructions = "Be balanced, logical, and provide clear reasoning." + case "Sassy Sarah": + personalityInstructions = "Inject wit and sarcasm while remaining convincing." + case "Innovative Iris": + personalityInstructions = "Show creativity and originality in your arguments." + case "Tough Tony": + personalityInstructions = "Be assertive and relentless in your logic." + case "Expert Emma": + personalityInstructions = "Use authoritative language with deep insights." + case "Grand Greg": + personalityInstructions = "Exude confidence and superiority in your arguments." + default: + personalityInstructions = "Express your points clearly." + } + + // Instruction to limit the response + limitInstruction := "" + if maxWords > 0 { + limitInstruction = fmt.Sprintf("Please limit your response to %d words.", maxWords) + } + + // Base instruction for all responses + baseInstruction := "Provide only your own argument in your response without simulating an opponent's dialogue. " + + "If the user's input appears unclear or off-topic, ask: 'Could you please clarify your question or provide an opening statement?'" + + // If no conversation history exists (or only one message), treat this as the opening statement. + if len(history) == 0 || len(history) == 1 { + phaseInstruction := "This is the Opening Statement phase. Introduce the topic, clearly state your stance, and outline the advantages or key points supporting your position." + return fmt.Sprintf( + `You are %s, a %s-level debate bot arguing %s the topic "%s". +Your debating style should reflect the following guidelines: +- Level: %s +- Personality: %s +Your stance is: %s. +%s +%s +%s +Provide an opening statement that clearly outlines your position. +[Your opening argument] +%s %s`, + bot.Name, bot.Level, stance, topic, + levelInstructions, + personalityInstructions, + stance, + func() string { + if extraContext != "" { + return fmt.Sprintf("Additional context: %s", extraContext) + } + return "" + }(), + phaseInstruction, + limitInstruction, baseInstruction, + ) + } + + // For subsequent turns, determine the phase and adjust instructions. + lastUserMsg := findLastUserMessage(history) + userText := strings.TrimSpace(lastUserMsg.Text) + if userText == "" { + userText = "It appears you didn't say anything." + } + // Normalize phase names: treat "first rebuttal" or "second rebuttal" as "Cross Examination" + currentPhase := lastUserMsg.Phase + phaseNormalized := strings.ToLower(currentPhase) + if phaseNormalized == "first rebuttal" || phaseNormalized == "second rebuttal" { + currentPhase = "Cross Examination" + } + + // Phase-specific instructions + var phaseInstruction string + switch strings.ToLower(currentPhase) { + case "opening statement": + phaseInstruction = "This is the Opening Statement phase. Respond to the user's opening statement by reinforcing your stance and highlighting key points." + case "cross examination": + phaseInstruction = "This is the Cross Examination phase. In this phase, the 'For' side asks a question and the opponent answers, then the opponent asks a question and the 'For' side responds." + case "closing statement": + phaseInstruction = "This is the Closing Statement phase. Summarize the key points from the debate and provide a conclusion that reinforces your overall position." + default: + phaseInstruction = fmt.Sprintf("This is the %s phase. Respond to the user's latest point in a way that advances the debate.", currentPhase) + } + + return fmt.Sprintf( + `You are %s, a %s-level debate bot arguing %s the topic "%s". +Your debating style should reflect the following guidelines: +- Level: %s +- Personality: %s +Your stance is: %s. +%s +%s +Based on the debate transcript below, continue the discussion in the %s phase by responding directly to the user's message. +User's message: "%s" +%s +Transcript: +%s +Please provide your full argument.`, + bot.Name, bot.Level, stance, topic, + levelInstructions, + personalityInstructions, + stance, + func() string { + if extraContext != "" { + return fmt.Sprintf("Additional context: %s", extraContext) + } + return "" + }(), + phaseInstruction, + currentPhase, + userText, + limitInstruction+" "+baseInstruction, + FormatHistory(history), + ) +} + +// GenerateBotResponse generates a response from the debate bot using the Gemini client library. +// It uses the provided stance directly, passes along extra context, and limits the response to maxWords. +func GenerateBotResponse(botName, botLevel, topic string, history []models.Message, stance, extraContext string, maxWords int) string { + if geminiClient == nil { + log.Println("Gemini client not initialized") + return "I'm not ready to debate yet!" + } + + bot := GetBotPersonality(botName) + // Construct prompt with extra context, word limit instruction, and improved history usage. + prompt := constructPrompt(bot, topic, history, stance, extraContext, maxWords) + + ctx := context.Background() + model := geminiClient.GenerativeModel("gemini-1.5-flash") + + // Set safety settings to BLOCK_NONE for all categories to ensure no content is blocked + model.SafetySettings = []*genai.SafetySetting{ + {Category: genai.HarmCategoryHarassment, Threshold: genai.HarmBlockNone}, + {Category: genai.HarmCategoryHateSpeech, Threshold: genai.HarmBlockNone}, + {Category: genai.HarmCategorySexuallyExplicit, Threshold: genai.HarmBlockNone}, + {Category: genai.HarmCategoryDangerousContent, Threshold: genai.HarmBlockNone}, + } + + resp, err := model.GenerateContent(ctx, genai.Text(prompt)) + if err != nil { + log.Printf("Gemini error: %v", err) + return "I'm stumped!" + } + + // Check if the prompt was blocked (non-nil PromptFeedback with a non-zero BlockReason) + if resp.PromptFeedback != nil && resp.PromptFeedback.BlockReason != 0 { + log.Printf("Prompt blocked: %v", resp.PromptFeedback.BlockReason) + return fmt.Sprintf("Prompt was blocked due to safety settings: %v", resp.PromptFeedback.BlockReason) + } + + if len(resp.Candidates) == 0 { + log.Println("No candidates returned") + return "I'm stumped due to content restrictions!" + } + + if len(resp.Candidates[0].Content.Parts) == 0 { + log.Println("No parts in candidate content") + return "I'm stumped!" + } + + for _, part := range resp.Candidates[0].Content.Parts { + if text, ok := part.(genai.Text); ok { + return string(text) + } + } + + log.Println("No text part found in Gemini response") + return "I'm stumped!" +} + +// JudgeDebate evaluates the debate by sending the formatted history to Gemini +// JudgeDebate evaluates the debate with structured scoring +func JudgeDebate(history []models.Message) string { + if geminiClient == nil { + log.Println("Gemini client not initialized") + return "Unable to judge." + } + log.Println("Judging debate...") + log.Println("History:", history) + prompt := fmt.Sprintf( + `Act as a professional debate judge. Analyze the following debate transcript and provide scores in STRICT JSON format: + +Judgment Criteria: +1. Opening Statement (10 points): + - Strength of opening: Clarity of position, persuasiveness + - Quality of reasoning: Validity, relevance, logical flow + - Diction/Expression: Language proficiency, articulation + +2. Cross Examination Questions (10 points): + - Validity and relevance to core issues + - Demonstration of high-order thinking + - Creativity/Originality ("out-of-the-box" nature) + +3. Answers to Cross Examination (10 points): + - Precision and directness (avoids evasion) + - Logical coherence + - Effectiveness in addressing the question + +4. Closing Statements (10 points): + - Comprehensive summary of key points + - Effective reiteration of stance + - Persuasiveness of final argument + +Required Output Format: +{ + "opening_statement": { + "user": {"score": X, "reason": "text"}, + "bot": {"score": Y, "reason": "text"} + }, + "cross_examination": { + "user": {"score": X, "reason": "text"}, + "bot": {"score": Y, "reason": "text"} + }, + "answers": { + "user": {"score": X, "reason": "text"}, + "bot": {"score": Y, "reason": "text"} + }, + "closing": { + "user": {"score": X, "reason": "text"}, + "bot": {"score": Y, "reason": "text"} + }, + "total": { + "user": X, + "bot": Y + }, + "verdict": { + "winner": "User/Bot", + "reason": "text", + "congratulations": "text", + "opponent_analysis": "text" + } +} + +Debate Transcript: +%s + +Provide ONLY the JSON output without any additional text.`, FormatHistory(history)) + + ctx := context.Background() + model := geminiClient.GenerativeModel("gemini-1.5-flash") + + model.SafetySettings = []*genai.SafetySetting{ + {Category: genai.HarmCategoryHarassment, Threshold: genai.HarmBlockNone}, + {Category: genai.HarmCategoryHateSpeech, Threshold: genai.HarmBlockNone}, + {Category: genai.HarmCategorySexuallyExplicit, Threshold: genai.HarmBlockNone}, + {Category: genai.HarmCategoryDangerousContent, Threshold: genai.HarmBlockNone}, + } + + resp, err := model.GenerateContent(ctx, genai.Text(prompt)) + if err != nil { + log.Printf("Gemini error: %v", err) + return "Unable to judge." + } + + // Extract and return the JSON response + if len(resp.Candidates) > 0 && len(resp.Candidates[0].Content.Parts) > 0 { + if text, ok := resp.Candidates[0].Content.Parts[0].(genai.Text); ok { + return string(text) + } + } + return "Unable to judge." +} + +// CreateDebateService creates a new debate in MongoDB using the existing collection +func CreateDebateService(debate *models.DebateVsBot, stance string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if debate.ID.IsZero() { + debate.ID = primitive.NewObjectID() + } + if debate.CreatedAt == 0 { + debate.CreatedAt = time.Now().Unix() + } + debate.Stance = stance // Set the bot's stance as provided + + if db.DebateVsBotCollection == nil { + log.Println("Debate collection not initialized") + return "", fmt.Errorf("database not initialized") + } + + result, err := db.DebateVsBotCollection.InsertOne(ctx, debate) + if err != nil { + log.Printf("Failed to create debate in MongoDB: %v", err) + return "", err + } + + id, ok := result.InsertedID.(primitive.ObjectID) + if !ok { + log.Println("Failed to convert InsertedID to ObjectID") + return "", fmt.Errorf("internal server error") + } + + return id.Hex(), nil +} diff --git a/backend/services/pros_cons.go b/backend/services/pros_cons.go new file mode 100644 index 0000000..191fc31 --- /dev/null +++ b/backend/services/pros_cons.go @@ -0,0 +1,165 @@ +package services + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "strings" + + "arguehub/models" + + "github.com/google/generative-ai-go/genai" +) + +// GenerateDebateTopic generates a debate topic using the Gemini API based on the user's skill level +func GenerateDebateTopic(skillLevel string) (string, error) { + if geminiClient == nil { + return "", errors.New("Gemini client not initialized") + } + + // Construct the prompt for generating a debate topic + prompt := fmt.Sprintf( + `Generate a concise debate topic suitable for a %s level debater. The topic must: +- Be a clear statement that can be argued for or against. +- Be specific enough for detailed arguments but avoid overly broad or narrow topics. +- NOT include formal debate phrasing like "This house believes that" or similar prefixes. +- Return only the topic statement itself. +Examples: +- Beginner: Should students be allowed to use smartphones in class? +- Intermediate: Is remote work better than office-based work? +- Advanced: Should governments prioritize economic growth over environmental protection?`, + skillLevel, + ) + + ctx := context.Background() + model := geminiClient.GenerativeModel("gemini-1.5-flash") + + // Set safety settings to prevent inappropriate content + model.SafetySettings = []*genai.SafetySetting{ + {Category: genai.HarmCategoryHarassment, Threshold: genai.HarmBlockLowAndAbove}, + {Category: genai.HarmCategoryHateSpeech, Threshold: genai.HarmBlockLowAndAbove}, + {Category: genai.HarmCategorySexuallyExplicit, Threshold: genai.HarmBlockLowAndAbove}, + {Category: genai.HarmCategoryDangerousContent, Threshold: genai.HarmBlockLowAndAbove}, + } + + resp, err := model.GenerateContent(ctx, genai.Text(prompt)) + if err != nil || len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 { + log.Printf("Failed to generate topic: %v", err) + return getFallbackTopic(skillLevel), nil + } + + for _, part := range resp.Candidates[0].Content.Parts { + if text, ok := part.(genai.Text); ok { + return strings.TrimSpace(string(text)), nil + } + } + + return getFallbackTopic(skillLevel), nil +} + +// getFallbackTopic returns a predefined topic based on skill level +func getFallbackTopic(skillLevel string) string { + fallbackTopics := map[string][]string{ + "beginner": {"Should school uniforms be mandatory?", "Is homework necessary for learning?"}, + "intermediate": {"Is social media beneficial for society?", "Should voting be mandatory?"}, + "advanced": {"Is globalization beneficial for developing countries?", "Should artificial intelligence be regulated?"}, + } + topics, ok := fallbackTopics[skillLevel] + if !ok || len(topics) == 0 { + return "Should technology be used in education?" + } + return topics[0] // Return the first fallback topic for simplicity +} + +// EvaluateProsCons evaluates the user's pros and cons +func EvaluateProsCons(topic string, pros, cons []string) (models.ProsConsEvaluation, error) { + if geminiClient == nil { + return models.ProsConsEvaluation{}, errors.New("Gemini client not initialized") + } + + if len(pros) > 5 || len(cons) > 5 { + return models.ProsConsEvaluation{}, errors.New("maximum of 5 pros and 5 cons allowed") + } + + prompt := fmt.Sprintf( + `Act as a debate coach and evaluate the following pros and cons for the topic "%s". For each argument: +- Score it out of 10 based on clarity (3), relevance (3), logic (2), and persuasiveness (2). +- Provide feedback explaining the score. +- Suggest a counterargument. + +Pros: +%s + +Cons: +%s + +Required Output Format (JSON): +{ + "pros": [ + {"score": X, "feedback": "text", "counter": "text"}, + ... + ], + "cons": [ + {"score": X, "feedback": "text", "counter": "text"}, + ... + ] +}`, + topic, + strings.Join(pros, "\n"), + strings.Join(cons, "\n"), + ) + + ctx := context.Background() + model := geminiClient.GenerativeModel("gemini-1.5-flash") + model.SafetySettings = []*genai.SafetySetting{ + {Category: genai.HarmCategoryHarassment, Threshold: genai.HarmBlockNone}, + {Category: genai.HarmCategoryHateSpeech, Threshold: genai.HarmBlockNone}, + {Category: genai.HarmCategorySexuallyExplicit, Threshold: genai.HarmBlockNone}, + {Category: genai.HarmCategoryDangerousContent, Threshold: genai.HarmBlockNone}, + } + + resp, err := model.GenerateContent(ctx, genai.Text(prompt)) + if err != nil { + log.Printf("Gemini error: %v", err) + return models.ProsConsEvaluation{}, err + } + + if len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 { + return models.ProsConsEvaluation{}, errors.New("no evaluation returned") + } + + for _, part := range resp.Candidates[0].Content.Parts { + if text, ok := part.(genai.Text); ok { + cleanedText := strings.TrimSpace(string(text)) + cleanedText = strings.TrimPrefix(cleanedText, "```json") + cleanedText = strings.TrimSuffix(cleanedText, "```") + cleanedText = strings.TrimSpace(cleanedText) + + var eval models.ProsConsEvaluation + err = json.Unmarshal([]byte(cleanedText), &eval) + if err != nil { + log.Printf("Failed to parse evaluation JSON: %v. Raw: %s", err, cleanedText) + return models.ProsConsEvaluation{}, err + } + + // Calculate total score and normalize to 100 + totalScore := 0 + for _, pro := range eval.Pros { + totalScore += pro.Score + } + for _, con := range eval.Cons { + totalScore += con.Score + } + // Normalize to 100: (totalScore / maxPossibleScore) * 100 + // maxPossibleScore = 10 points per argument * 10 arguments (5 pros + 5 cons) = 100 + // If fewer arguments are submitted, score is scaled proportionally + eval.Score = totalScore + + return eval, nil + } + } + + return models.ProsConsEvaluation{}, errors.New("no valid evaluation returned") +} diff --git a/backend/services/transcriptservice.go b/backend/services/transcriptservice.go new file mode 100644 index 0000000..2417d27 --- /dev/null +++ b/backend/services/transcriptservice.go @@ -0,0 +1,232 @@ +package services + +import ( + "context" + "errors" + "fmt" + "log" + "strings" + "time" + + "arguehub/db" + "arguehub/models" + + "github.com/google/generative-ai-go/genai" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" +) + +func SubmitTranscripts(roomID, role string, transcripts map[string]string) (map[string]interface{}, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Collections + transcriptCollection := db.MongoDatabase.Collection("debate_transcripts") + resultCollection := db.MongoDatabase.Collection("debate_results") + + // Check if a judgment result already exists for this room + var existingResult models.DebateResult + err := resultCollection.FindOne(ctx, bson.M{"roomId": roomID}).Decode(&existingResult) + if err == nil { + // Judgment already exists, return it + return map[string]interface{}{ + "message": "Debate already judged", + "result": existingResult.Result, + }, nil + } + if err != mongo.ErrNoDocuments { + return nil, errors.New("failed to check existing result: " + err.Error()) + } + + // No judgment exists yet, proceed with transcript submission + filter := bson.M{"roomId": roomID, "role": role} + var existingTranscript models.DebateTranscript + err = transcriptCollection.FindOne(ctx, filter).Decode(&existingTranscript) + if err != nil && err != mongo.ErrNoDocuments { + return nil, errors.New("failed to check existing submission: " + err.Error()) + } + + if err == nil { + // Update existing submission + update := bson.M{ + "$set": bson.M{ + "transcripts": transcripts, + "updatedAt": time.Now(), + }, + } + _, err = transcriptCollection.UpdateOne(ctx, filter, update) + if err != nil { + return nil, errors.New("failed to update submission: " + err.Error()) + } + } else { + // Insert new submission + doc := models.DebateTranscript{ + RoomID: roomID, + Role: role, + Transcripts: transcripts, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + _, err = transcriptCollection.InsertOne(ctx, doc) + if err != nil { + return nil, errors.New("failed to insert submission: " + err.Error()) + } + } + + // Check if both sides have submitted + var forSubmission, againstSubmission models.DebateTranscript + errFor := transcriptCollection.FindOne(ctx, bson.M{"roomId": roomID, "role": "for"}).Decode(&forSubmission) + errAgainst := transcriptCollection.FindOne(ctx, bson.M{"roomId": roomID, "role": "against"}).Decode(&againstSubmission) + + if errFor == nil && errAgainst == nil { + // Both submissions exist, compute judgment once + merged := mergeTranscripts(forSubmission.Transcripts, againstSubmission.Transcripts) + result := JudgeDebateHumanVsHuman(merged) + + // Store the result + resultDoc := models.DebateResult{ + RoomID: roomID, + Result: result, + CreatedAt: time.Now(), + } + _, err = resultCollection.InsertOne(ctx, resultDoc) + if err != nil { + log.Printf("Failed to store debate result: %v", err) + return nil, errors.New("failed to store debate result: " + err.Error()) + } + + // Clean up transcripts (optional) + _, err = transcriptCollection.DeleteMany(ctx, bson.M{"roomId": roomID}) + if err != nil { + log.Printf("Failed to clean up transcripts: %v", err) + } + + return map[string]interface{}{ + "message": "Debate judged", + "result": result, + }, nil + } + + // If only one side has submitted, return a waiting message + return map[string]interface{}{ + "message": "Waiting for opponent submission", + }, nil +} + +// mergeTranscripts and JudgeDebateHumanVsHuman remain unchanged +func mergeTranscripts(forTranscripts, againstTranscripts map[string]string) map[string]string { + merged := make(map[string]string) + for phase, transcript := range forTranscripts { + merged[phase] = transcript + } + for phase, transcript := range againstTranscripts { + merged[phase] = transcript + } + return merged +} + +func JudgeDebateHumanVsHuman(merged map[string]string) string { + if geminiClient == nil { + log.Println("Gemini client not initialized") + return "Unable to judge." + } + + var transcript strings.Builder + phaseOrder := []string{ + "openingFor", "openingAgainst", + "crossForQuestion", "crossAgainstAnswer", + "crossAgainstQuestion", "crossForAnswer", + "closingFor", "closingAgainst", + } + for _, phase := range phaseOrder { + if text, exists := merged[phase]; exists && text != "" { + role := "For" + if strings.Contains(phase, "Against") { + role = "Against" + } + transcript.WriteString(fmt.Sprintf("%s (%s): %s\n", role, phase, text)) + } + } + + prompt := fmt.Sprintf( + `Act as a professional debate judge. Analyze the following human-vs-human debate transcript and provide scores in STRICT JSON format: + +Judgment Criteria: +1. Opening Statement (10 points): + - Strength of opening: Clarity of position, persuasiveness + - Quality of reasoning: Validity, relevance, logical flow + - Diction/Expression: Language proficiency, articulation + +2. Cross Examination Questions (10 points): + - Validity and relevance to core issues + - Demonstration of high-order thinking + - Creativity/Originality ("out-of-the-box" nature) + +3. Answers to Cross Examination (10 points): + - Precision and directness (avoids evasion) + - Logical coherence + - Effectiveness in addressing the question + +4. Closing Statements (10 points): + - Comprehensive summary of key points + - Effective reiteration of stance + - Persuasiveness of final argument + +Required Output Format: +{ + "opening_statement": { + "for": {"score": X, "reason": "text"}, + "against": {"score": Y, "reason": "text"} + }, + "cross_examination_questions": { + "for": {"score": X, "reason": "text"}, + "against": {"score": Y, "reason": "text"} + }, + "cross_examination_answers": { + "for": {"score": X, "reason": "text"}, + "against": {"score": Y, "reason": "text"} + }, + "closing": { + "for": {"score": X, "reason": "text"}, + "against": {"score": Y, "reason": "text"} + }, + "total": { + "for": X, + "against": Y + }, + "verdict": { + "winner": "For/Against", + "reason": "text", + "congratulations": "text", + "opponent_analysis": "text" + } +} + +Debate Transcript: +%s + +Provide ONLY the JSON output without any additional text.`, transcript.String()) + + ctx := context.Background() + model := geminiClient.GenerativeModel("gemini-1.5-flash") + + model.SafetySettings = []*genai.SafetySetting{ + {Category: genai.HarmCategoryHarassment, Threshold: genai.HarmBlockNone}, + {Category: genai.HarmCategoryHateSpeech, Threshold: genai.HarmBlockNone}, + {Category: genai.HarmCategorySexuallyExplicit, Threshold: genai.HarmBlockNone}, + {Category: genai.HarmCategoryDangerousContent, Threshold: genai.HarmBlockNone}, + } + + resp, err := model.GenerateContent(ctx, genai.Text(prompt)) + if err != nil { + log.Printf("Gemini error: %v", err) + return "Unable to judge." + } + + if len(resp.Candidates) > 0 && len(resp.Candidates[0].Content.Parts) > 0 { + if text, ok := resp.Candidates[0].Content.Parts[0].(genai.Text); ok { + return string(text) + } + } + return "Unable to judge." +} diff --git a/backend/utils/auth.go b/backend/utils/auth.go index 17e521b..f569fdb 100644 --- a/backend/utils/auth.go +++ b/backend/utils/auth.go @@ -2,22 +2,141 @@ package utils import ( "crypto/hmac" - "crypto/sha256" + "crypto/rand" "encoding/base64" + "errors" + "fmt" + "log" "regexp" -) + "time" -func GenerateSecretHash(username, clientId, clientSecret string) string { - hmacInstance := hmac.New(sha256.New, []byte(clientSecret)) - hmacInstance.Write([]byte(username + clientId)) - secretHashByte := hmacInstance.Sum(nil) + "crypto/sha256" - secretHashString := base64.StdEncoding.EncodeToString(secretHashByte) - return secretHashString -} + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) +var ( + ErrInvalidToken = errors.New("invalid token") + ErrTokenExpired = errors.New("token has expired") +) + +// ExtractNameFromEmail extracts the username before '@' func ExtractNameFromEmail(email string) string { re := regexp.MustCompile(`^([^@]+)`) match := re.FindStringSubmatch(email) + if len(match) < 2 { + return email + } return match[1] -} \ No newline at end of file +} + +// Password Hashing Functions +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + log.Printf("Error hashing password: %v", err) + return "", fmt.Errorf("failed to hash password") + } + return string(bytes), nil +} + +func CheckPasswordHash(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +// JWT Functions +type Claims struct { + UserID string `json:"user_id"` + Email string `json:"email"` + jwt.RegisteredClaims +} + +func GenerateJWTToken(userID, email string) (string, error) { + expirationTime := time.Now().Add(24 * time.Hour) + + claims := &Claims{ + UserID: userID, + Email: email, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expirationTime), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + jwtSecret := []byte(getJWTSecret()) + + signedToken, err := token.SignedString(jwtSecret) + if err != nil { + log.Printf("Error signing token: %v", err) + return "", fmt.Errorf("failed to generate token") + } + + return signedToken, nil +} + +func ParseJWTToken(tokenString string) (*Claims, error) { + claims := &Claims{} + jwtSecret := []byte(getJWTSecret()) + + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return jwtSecret, nil + }) + + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + return nil, ErrTokenExpired + } + return nil, ErrInvalidToken + } + + if !token.Valid { + return nil, ErrInvalidToken + } + + return claims, nil +} + +// Token Generation +func GenerateRandomToken(length int) (string, error) { + b := make([]byte, length) + if _, err := rand.Read(b); err != nil { + log.Printf("Error generating random bytes: %v", err) + return "", fmt.Errorf("failed to generate random token") + } + return base64.URLEncoding.EncodeToString(b), nil +} + +func getJWTSecret() string { + secret := "your_default_secret_here" // Replace with env variable in production + // In production, use: + // secret := os.Getenv("JWT_SECRET") + // if secret == "" { + // log.Fatal("JWT_SECRET environment variable not set") + // } + return secret +} + +func ValidateTokenAndFetchEmail(configPath, token string, c *gin.Context) (bool, string, error) { + claims, err := ParseJWTToken(token) + if err != nil { + return false, "", err + } + return true, claims.Email, nil +} + +func GenerateSecretHash(username, clientID, clientSecret string) string { + key := []byte(clientSecret) + message := username + clientID + + mac := hmac.New(sha256.New, key) + mac.Write([]byte(message)) + return base64.StdEncoding.EncodeToString(mac.Sum(nil)) +} diff --git a/backend/utils/debate.go b/backend/utils/debate.go new file mode 100644 index 0000000..46cd9d8 --- /dev/null +++ b/backend/utils/debate.go @@ -0,0 +1,67 @@ +package utils + +import ( + "context" + "time" + + "arguehub/db" + "arguehub/models" + + "go.mongodb.org/mongo-driver/bson" +) + +// SeedDebateData populates the debates collection with sample data +func SeedDebateData() { + dbCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Skip if debates collection already has data + count, err := db.MongoDatabase.Collection("debates").CountDocuments(dbCtx, bson.M{}) + if err != nil || count > 0 { + return + } + + // Define sample debates + sampleDebates := []models.Debate{ + { + UserEmail: "irishittiwari@gmail.com", + Topic: "Global Warming", + Result: "win", + EloChange: 12, + Date: time.Now().Add(-time.Hour * 24 * 30), + }, + { + UserEmail: "irishittiwari@gmail.com", + Topic: "Universal Healthcare", + Result: "loss", + EloChange: -5, + Date: time.Now().Add(-time.Hour * 24 * 20), + }, + { + UserEmail: "irishittiwari@gmail.com", + Topic: "Social Media Regulation", + Result: "draw", + EloChange: 0, + Date: time.Now().Add(-time.Hour * 24 * 10), + }, + { + UserEmail: "irishittiwari@gmail.com", + Topic: "Renewable Energy", + Result: "win", + EloChange: 10, + Date: time.Now().Add(-time.Hour * 24 * 5), + }, + { + UserEmail: "irishittiwari@gmail.com", + Topic: "Space Exploration", + Result: "loss", + EloChange: -7, + Date: time.Now().Add(-time.Hour * 24 * 2), + }, + } + + // Insert sample debates + for _, debate := range sampleDebates { + db.MongoDatabase.Collection("debates").InsertOne(dbCtx, debate) + } +} diff --git a/backend/utils/email.go b/backend/utils/email.go new file mode 100644 index 0000000..7700d3d --- /dev/null +++ b/backend/utils/email.go @@ -0,0 +1,80 @@ +package utils + +import ( + "crypto/rand" + "fmt" + "math/big" + "net/smtp" + "strings" + + "arguehub/config" +) + +// GenerateRandomCode generates a random numeric code of specified length +func GenerateRandomCode(length int) string { + const charset = "0123456789" + code := make([]byte, length) + for i := 0; i < length; i++ { + num, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) + if err != nil { + // Fallback to a default code in case of error + return strings.Repeat("0", length) + } + code[i] = charset[num.Int64()] + } + return string(code) +} + +// SendVerificationEmail sends an email with a verification code using Gmail SMTP +func SendVerificationEmail(email, code string) error { + cfg, err := config.LoadConfig("./config/config.prod.yml") + if err != nil { + return fmt.Errorf("failed to load config: %v", err) + } + + auth := smtp.PlainAuth("", cfg.SMTP.Username, cfg.SMTP.Password, cfg.SMTP.Host) + to := []string{email} + msg := []byte(fmt.Sprintf( + "To: %s\r\n"+ + "From: %s <%s>\r\n"+ + "Subject: Verify Your ArgueHub Account\r\n"+ + "MIME-Version: 1.0\r\n"+ + "Content-Type: text/html; charset=\"UTF-8\"\r\n"+ + "\r\n"+ + "

Your verification code is: %s

\r\n", + email, cfg.SMTP.SenderName, cfg.SMTP.SenderEmail, code)) + + addr := fmt.Sprintf("%s:%d", cfg.SMTP.Host, cfg.SMTP.Port) + err = smtp.SendMail(addr, auth, cfg.SMTP.SenderEmail, to, msg) + if err != nil { + return fmt.Errorf("failed to send verification email: %v", err) + } + return nil +} + +// SendPasswordResetEmail sends an email with a password reset code using Gmail SMTP +func SendPasswordResetEmail(email, code string) error { + cfg, err := config.LoadConfig("./config/config.prod.yml") + if err != nil { + return fmt.Errorf("failed to load config: %v", err) + } + + auth := smtp.PlainAuth("", cfg.SMTP.Username, cfg.SMTP.Password, cfg.SMTP.Host) + to := []string{email} + msg := []byte(fmt.Sprintf( + "To: %s\r\n"+ + "From: %s <%s>\r\n"+ + "Subject: ArgueHub Password Reset\r\n"+ + "MIME-Version: 1.0\r\n"+ + "Content-Type: text/html; charset=\"UTF-8\"\r\n"+ + "\r\n"+ + "

Your password reset code is: %s

\r\n", + email, cfg.SMTP.SenderName, cfg.SMTP.SenderEmail, code)) + + addr := fmt.Sprintf("%s:%d", cfg.SMTP.Host, cfg.SMTP.Port) + err = smtp.SendMail(addr, auth, cfg.SMTP.SenderEmail, to, msg) + if err != nil { + return fmt.Errorf("failed to send password reset email: %v", err) + } + return nil +} diff --git a/backend/utils/user.go b/backend/utils/user.go new file mode 100644 index 0000000..d41dede --- /dev/null +++ b/backend/utils/user.go @@ -0,0 +1,49 @@ +package utils + +import ( + "context" + "time" + + "arguehub/db" + "arguehub/models" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// PopulateTestUsers inserts sample users into the database +func PopulateTestUsers() { + collection := db.MongoDatabase.Collection("users") + + // Define sample users + users := []models.User{ + { + ID: primitive.NewObjectID(), + Email: "alice@example.com", + DisplayName: "Alice Johnson", + Bio: "Debate enthusiast", + EloRating: 2500, + CreatedAt: time.Now(), + }, + { + ID: primitive.NewObjectID(), + Email: "bob@example.com", + DisplayName: "Bob Smith", + Bio: "Argument master", + EloRating: 2400, + CreatedAt: time.Now(), + }, + { + ID: primitive.NewObjectID(), + Email: "carol@example.com", + DisplayName: "Carol Davis", + Bio: "Wordsmith", + EloRating: 2350, + CreatedAt: time.Now(), + }, + } + + // Insert users + for _, user := range users { + collection.InsertOne(context.Background(), user) + } +} diff --git a/backend/websocket/handler.go b/backend/websocket/handler.go index f78d8d5..4b451b3 100644 --- a/backend/websocket/handler.go +++ b/backend/websocket/handler.go @@ -1,389 +1,389 @@ package websocket -import ( - "encoding/json" - "fmt" - "log" - "net/http" - "sync" - "time" - "os" - "bytes" - "arguehub/structs" - - "github.com/gin-gonic/gin" - "github.com/gorilla/websocket" -) - -// Constants for message types -const ( - MessageTypeDebateStart = "DEBATE_START" - MessageTypeDebateEnd = "DEBATE_END" - MessageTypeSectionStart = "SECTION_START" - MessageTypeSectionEnd = "SECTION_END" - MessageTypeTurnStart = "TURN_START" - MessageTypeTurnEnd = "TURN_END" - MessageTypeGeneratingTranscript = "GENERATING_TRANSCRIPT" - MessageTypeChatMessage = "CHAT_MESSAGE" - PingMessage = "PING" - - ReadBufferSize = 131022 - WriteBufferSize = 131022 -) - -// Global room storage -var ( - rooms = make(map[string]*structs.Room) - roomMu sync.Mutex -) - -// JSON helper function -func toJSON(data interface{}) (string, error) { - bytes, err := json.Marshal(data) - if err != nil { - return "", err - } - return string(bytes), nil -} - -// Send a WebSocket message -func sendMessage(conn *websocket.Conn, messageType string, data interface{}) error { - content, err := toJSON(data) - if err != nil { - return fmt.Errorf("error marshaling data: %w", err) - } - - message := structs.Message{ - Type: messageType, - Content: content, - } - if err := conn.WriteJSON(message); err != nil { - return fmt.Errorf("error sending %s message: %w", messageType, err) - } - return nil -} - -// Broadcast a message to all users in the room -func broadcastMessage(room *structs.Room, messageType string, data interface{}) { - room.Mutex.Lock() - defer room.Mutex.Unlock() - for userID, conn := range room.Users { - if err := sendMessage(conn, messageType, data); err != nil { - log.Printf("Error broadcasting to user %s: %v", userID, err) - conn.Close() - delete(room.Users, userID) - } - } -} - -// Create or join a room -func createOrJoinRoom(userID string, conn *websocket.Conn) (*structs.Room, error) { - roomMu.Lock() - defer roomMu.Unlock() - - for _, room := range rooms { - room.Mutex.Lock() - if existingConn, exists := room.Users[userID]; exists { - existingConn.Close() - room.Users[userID] = conn - room.Mutex.Unlock() - return room, nil - } - if len(room.Users) < 2 { - room.Users[userID] = conn - room.Mutex.Unlock() - return room, nil - } - room.Mutex.Unlock() - } - - // Initialize the room with TurnActive map - newRoom := &structs.Room{ - Users: map[string]*websocket.Conn{userID: conn}, - DebateFmt: getDebateFormat(), - TurnActive: make(map[string]bool), // Initialize TurnActive for each user - } - roomID := generateRoomID() - rooms[roomID] = newRoom - - // Verify connections for this new room - go verifyConnections(newRoom) - - return newRoom, nil -} - -// Verify active connections -func verifyConnections(room *structs.Room) { - time.Sleep(2 * time.Second) - room.Mutex.Lock() - defer room.Mutex.Unlock() - - for userID, conn := range room.Users { - if err := sendMessage(conn, PingMessage, nil); err != nil { - log.Printf("Connection lost for user %s, removing from room", userID) - conn.Close() - delete(room.Users, userID) - } - } -} - -//ws handler -func WebsocketHandler(ctx *gin.Context) { - upgrader := websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { return true }, - ReadBufferSize: ReadBufferSize, - WriteBufferSize: WriteBufferSize, - EnableCompression: false, - } - - conn, err := upgrader.Upgrade(ctx.Writer, ctx.Request, nil) - if err != nil { - log.Println("Error upgrading WebSocket:", err) - return - } - defer conn.Close() - - userID := ctx.Query("userId") - if userID == "" { - log.Println("Missing userId in query parameters") - return - } - - log.Printf("WebSocket connection established for userId: %s", userID) - - room, err := createOrJoinRoom(userID, conn) - if err != nil { - log.Println("Error joining room:", err) - return - } - - log.Println("Waiting for another user to join...") - for { - room.Mutex.Lock() - if len(room.Users) == 2 && !room.DebateStarted { - room.DebateStarted = true - room.Mutex.Unlock() - break - } - room.Mutex.Unlock() - time.Sleep(1 * time.Second) - } - - log.Println("Two users connected. Starting debate.") - - startDebate(room) - - closeConnectionsAndExpireRoom(room) -} - -func startDebate(room *structs.Room) { - broadcastMessage(room, MessageTypeDebateStart, nil) - - for _, section := range room.DebateFmt.Sections { - log.Printf("Section: %s", section.Name) - broadcastMessage(room, MessageTypeSectionStart, structs.CurrentStatus{Section: section.Name}) - - for userID, conn := range room.Users { - room.Mutex.Lock() - room.CurrentTurn = userID - room.Mutex.Unlock() - - turnStatus := structs.CurrentStatus{ - CurrentTurn: userID, - Section: section.Name, - Duration: int(section.Duration.Seconds()), - } - - // Mark the user's turn as active - room.Mutex.Lock() - room.TurnActive[userID] = true - room.Mutex.Unlock() - - time.Sleep(time.Second * 2) - broadcastMessage(room, MessageTypeTurnStart, turnStatus) - - // Save user media - mediaFileChan := make(chan string) - go saveUserMedia(conn, userID, section.Name, mediaFileChan, room) - - time.Sleep(section.Duration) - // End current turn - broadcastMessage(room, MessageTypeTurnEnd, nil) - - // Mark the user's turn as inactive - room.Mutex.Lock() - room.TurnActive[userID] = false - room.Mutex.Unlock() - - // Wait for media file path - mediaFilePath := <-mediaFileChan - if mediaFilePath != "" { - // Generate transcript - // Notify frontend that transcript is being generated - broadcastMessage(room, MessageTypeGeneratingTranscript, structs.ChatMessage{ - Sender: userID, - Message: "Transcript is being generated...", - }) - - transcript, err := generateTranscript(mediaFilePath) - if err != nil { - log.Printf("Error generating transcript for user %s: %v", userID, err) - continue - } - - // Broadcast transcript as a chat message - broadcastMessage(room, MessageTypeChatMessage, structs.ChatMessage{ - Sender: userID, - Message: transcript, - }) - } - } - - broadcastMessage(room, MessageTypeSectionEnd, nil) - } - - broadcastMessage(room, MessageTypeDebateEnd, nil) - - broadcastMessage(room, "GENERATING_RESULTS", nil); - - gameResult := structs.GameResult{ - WinnerUserId: "1", - Points: 10, - TotalPoints: 100, - EvaluationMessage: "you won the match, for the reasons you don't need to know", - } - broadcastMessage(room, "GAME_RESULT", gameResult); -} - -type TranscriptionResponse struct { - Transcription string `json:"transcription"` - Error string `json:"error"` -} -func generateTranscript(mediaFilePath string) (string, error) { - serverURL := "http://localhost:8000/transcribe/batch" - - payload := map[string]string{"file_path": mediaFilePath} - payloadBytes, err := json.Marshal(payload) - if err != nil { - return "", fmt.Errorf("failed to marshal JSON payload: %v", err) - } - - resp, err := http.Post(serverURL, "application/json", bytes.NewReader(payloadBytes)) - if err != nil { - return "", fmt.Errorf("failed to send POST request: %v", err) - } - defer resp.Body.Close() - - var result TranscriptionResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", fmt.Errorf("failed to decode response: %v", err) - } - - if result.Error != "" { - return "", fmt.Errorf("server error: %s", result.Error) - } - - return result.Transcription, nil -} - -// Generate a unique room ID -func generateRoomID() string { - return fmt.Sprintf("%d", time.Now().UnixNano()) -} - -// Initialize debate format -func getDebateFormat() structs.DebateFormat { - return structs.DebateFormat{ - Sections: []structs.Section{ - {Name: "Opening", Duration: 2 * time.Second}, - // {Name: "Rebuttal", Duration: 3 * time.Second}, - // {Name: "Closing", Duration: 3 * time.Second}, - }, - } -} - -func closeConnectionsAndExpireRoom(room *structs.Room) { - room.Mutex.Lock() - defer room.Mutex.Unlock() - - for userID, conn := range room.Users { - log.Printf("Closing connection for user: %s", userID) - conn.Close() - delete(room.Users, userID) - } - - roomMu.Lock() - defer roomMu.Unlock() - for roomID, r := range rooms { - if r == room { - delete(rooms, roomID) - log.Printf("Room %s expired and removed", roomID) - break - } - } -} - -// TranscriptionResult represents the JSON response from the Python script -type TranscriptionResult struct { - Transcription string `json:"transcription"` -} - -func saveUserMedia(conn *websocket.Conn, userID, sectionName string, mediaFileChan chan<- string, room *structs.Room) { - defer close(mediaFileChan) - - tempFilename := fmt.Sprintf("temp_media_%s_%s.webm", userID, sectionName) - finalFilename := fmt.Sprintf("media_%s_%s.webm", userID, sectionName) - - file, err := os.Create(tempFilename) - if err != nil { - log.Printf("Error creating file for user %s: %v", userID, err) - mediaFileChan <- "" - return - } - defer func() { - file.Close() - err = os.Rename(tempFilename, finalFilename) - if err != nil { - log.Printf("Error renaming file for user %s: %v", userID, err) - mediaFileChan <- "" - } else { - log.Printf("Media saved for user %s in section %s", userID, sectionName) - mediaFileChan <- finalFilename - } - }() - - for { - room.Mutex.Lock() - active := room.TurnActive[userID] - room.Mutex.Unlock() - - if !active { - log.Printf("Turn ended for user %s. Stopping media collection.", userID) - break - } - - messageType, data, err := conn.ReadMessage() - if err != nil { - if websocket.IsCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { - log.Printf("Connection closed for user %s", userID) - } else { - log.Printf("Error reading chunk for user %s: %v", userID, err) - } - break - } - - if messageType == websocket.BinaryMessage { - _, err = file.Write(data) - if err != nil { - log.Printf("Error writing chunk for user %s: %v", userID, err) - break - } - } - } - - err = file.Sync() - if err != nil { - log.Printf("Error syncing file for user %s: %v", userID, err) - mediaFileChan <- "" - } -} \ No newline at end of file +// import ( +// "encoding/json" +// "fmt" +// "log" +// "net/http" +// "sync" +// "time" +// "os" +// "bytes" +// "arguehub/structs" + +// "github.com/gin-gonic/gin" +// "github.com/gorilla/websocket" +// ) + +// // Constants for message types +// const ( +// MessageTypeDebateStart = "DEBATE_START" +// MessageTypeDebateEnd = "DEBATE_END" +// MessageTypeSectionStart = "SECTION_START" +// MessageTypeSectionEnd = "SECTION_END" +// MessageTypeTurnStart = "TURN_START" +// MessageTypeTurnEnd = "TURN_END" +// MessageTypeGeneratingTranscript = "GENERATING_TRANSCRIPT" +// MessageTypeChatMessage = "CHAT_MESSAGE" +// PingMessage = "PING" + +// ReadBufferSize = 131022 +// WriteBufferSize = 131022 +// ) + +// // Global room storage +// var ( +// rooms = make(map[string]*structs.Room) +// roomMu sync.Mutex +// ) + +// // JSON helper function +// func toJSON(data interface{}) (string, error) { +// bytes, err := json.Marshal(data) +// if err != nil { +// return "", err +// } +// return string(bytes), nil +// } + +// // Send a WebSocket message +// func sendMessage(conn *websocket.Conn, messageType string, data interface{}) error { +// content, err := toJSON(data) +// if err != nil { +// return fmt.Errorf("error marshaling data: %w", err) +// } + +// message := structs.Message{ +// Type: messageType, +// Content: content, +// } +// if err := conn.WriteJSON(message); err != nil { +// return fmt.Errorf("error sending %s message: %w", messageType, err) +// } +// return nil +// } + +// // Broadcast a message to all users in the room +// func broadcastMessage(room *structs.Room, messageType string, data interface{}) { +// room.Mutex.Lock() +// defer room.Mutex.Unlock() +// for userID, conn := range room.Users { +// if err := sendMessage(conn, messageType, data); err != nil { +// log.Printf("Error broadcasting to user %s: %v", userID, err) +// conn.Close() +// delete(room.Users, userID) +// } +// } +// } + +// // Create or join a room +// func createOrJoinRoom(userID string, conn *websocket.Conn) (*structs.Room, error) { +// roomMu.Lock() +// defer roomMu.Unlock() + +// for _, room := range rooms { +// room.Mutex.Lock() +// if existingConn, exists := room.Users[userID]; exists { +// existingConn.Close() +// room.Users[userID] = conn +// room.Mutex.Unlock() +// return room, nil +// } +// if len(room.Users) < 2 { +// room.Users[userID] = conn +// room.Mutex.Unlock() +// return room, nil +// } +// room.Mutex.Unlock() +// } + +// // Initialize the room with TurnActive map +// newRoom := &structs.Room{ +// Users: map[string]*websocket.Conn{userID: conn}, +// DebateFmt: getDebateFormat(), +// TurnActive: make(map[string]bool), // Initialize TurnActive for each user +// } +// roomID := generateRoomID() +// rooms[roomID] = newRoom + +// // Verify connections for this new room +// go verifyConnections(newRoom) + +// return newRoom, nil +// } + +// // Verify active connections +// func verifyConnections(room *structs.Room) { +// time.Sleep(2 * time.Second) +// room.Mutex.Lock() +// defer room.Mutex.Unlock() + +// for userID, conn := range room.Users { +// if err := sendMessage(conn, PingMessage, nil); err != nil { +// log.Printf("Connection lost for user %s, removing from room", userID) +// conn.Close() +// delete(room.Users, userID) +// } +// } +// } + +// //ws handler +// func WebsocketHandler(ctx *gin.Context) { +// upgrader := websocket.Upgrader{ +// CheckOrigin: func(r *http.Request) bool { return true }, +// ReadBufferSize: ReadBufferSize, +// WriteBufferSize: WriteBufferSize, +// EnableCompression: false, +// } + +// conn, err := upgrader.Upgrade(ctx.Writer, ctx.Request, nil) +// if err != nil { +// log.Println("Error upgrading WebSocket:", err) +// return +// } +// defer conn.Close() + +// userID := ctx.Query("userId") +// if userID == "" { +// log.Println("Missing userId in query parameters") +// return +// } + +// log.Printf("WebSocket connection established for userId: %s", userID) + +// room, err := createOrJoinRoom(userID, conn) +// if err != nil { +// log.Println("Error joining room:", err) +// return +// } + +// log.Println("Waiting for another user to join...") +// for { +// room.Mutex.Lock() +// if len(room.Users) == 2 && !room.DebateStarted { +// room.DebateStarted = true +// room.Mutex.Unlock() +// break +// } +// room.Mutex.Unlock() +// time.Sleep(1 * time.Second) +// } + +// log.Println("Two users connected. Starting debate.") + +// startDebate(room) + +// closeConnectionsAndExpireRoom(room) +// } + +// func startDebate(room *structs.Room) { +// broadcastMessage(room, MessageTypeDebateStart, nil) + +// for _, section := range room.DebateFmt.Sections { +// log.Printf("Section: %s", section.Name) +// broadcastMessage(room, MessageTypeSectionStart, structs.CurrentStatus{Section: section.Name}) + +// for userID, conn := range room.Users { +// room.Mutex.Lock() +// room.CurrentTurn = userID +// room.Mutex.Unlock() + +// turnStatus := structs.CurrentStatus{ +// CurrentTurn: userID, +// Section: section.Name, +// Duration: int(section.Duration.Seconds()), +// } + +// // Mark the user's turn as active +// room.Mutex.Lock() +// room.TurnActive[userID] = true +// room.Mutex.Unlock() + +// time.Sleep(time.Second * 2) +// broadcastMessage(room, MessageTypeTurnStart, turnStatus) + +// // Save user media +// mediaFileChan := make(chan string) +// go saveUserMedia(conn, userID, section.Name, mediaFileChan, room) + +// time.Sleep(section.Duration) +// // End current turn +// broadcastMessage(room, MessageTypeTurnEnd, nil) + +// // Mark the user's turn as inactive +// room.Mutex.Lock() +// room.TurnActive[userID] = false +// room.Mutex.Unlock() + +// // Wait for media file path +// mediaFilePath := <-mediaFileChan +// if mediaFilePath != "" { +// // Generate transcript +// // Notify frontend that transcript is being generated +// broadcastMessage(room, MessageTypeGeneratingTranscript, structs.ChatMessage{ +// Sender: userID, +// Message: "Transcript is being generated...", +// }) + +// transcript, err := generateTranscript(mediaFilePath) +// if err != nil { +// log.Printf("Error generating transcript for user %s: %v", userID, err) +// continue +// } + +// // Broadcast transcript as a chat message +// broadcastMessage(room, MessageTypeChatMessage, structs.ChatMessage{ +// Sender: userID, +// Message: transcript, +// }) +// } +// } + +// broadcastMessage(room, MessageTypeSectionEnd, nil) +// } + +// broadcastMessage(room, MessageTypeDebateEnd, nil) + +// broadcastMessage(room, "GENERATING_RESULTS", nil); + +// gameResult := structs.GameResult{ +// WinnerUserId: "1", +// Points: 10, +// TotalPoints: 100, +// EvaluationMessage: "you won the match, for the reasons you don't need to know", +// } +// broadcastMessage(room, "GAME_RESULT", gameResult); +// } + +// type TranscriptionResponse struct { +// Transcription string `json:"transcription"` +// Error string `json:"error"` +// } +// func generateTranscript(mediaFilePath string) (string, error) { +// serverURL := "http://localhost:8000/transcribe/batch" + +// payload := map[string]string{"file_path": mediaFilePath} +// payloadBytes, err := json.Marshal(payload) +// if err != nil { +// return "", fmt.Errorf("failed to marshal JSON payload: %v", err) +// } + +// resp, err := http.Post(serverURL, "application/json", bytes.NewReader(payloadBytes)) +// if err != nil { +// return "", fmt.Errorf("failed to send POST request: %v", err) +// } +// defer resp.Body.Close() + +// var result TranscriptionResponse +// if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { +// return "", fmt.Errorf("failed to decode response: %v", err) +// } + +// if result.Error != "" { +// return "", fmt.Errorf("server error: %s", result.Error) +// } + +// return result.Transcription, nil +// } + +// // Generate a unique room ID +// func generateRoomID() string { +// return fmt.Sprintf("%d", time.Now().UnixNano()) +// } + +// // Initialize debate format +// func getDebateFormat() structs.DebateFormat { +// return structs.DebateFormat{ +// Sections: []structs.Section{ +// {Name: "Opening", Duration: 2 * time.Second}, +// // {Name: "Rebuttal", Duration: 3 * time.Second}, +// // {Name: "Closing", Duration: 3 * time.Second}, +// }, +// } +// } + +// func closeConnectionsAndExpireRoom(room *structs.Room) { +// room.Mutex.Lock() +// defer room.Mutex.Unlock() + +// for userID, conn := range room.Users { +// log.Printf("Closing connection for user: %s", userID) +// conn.Close() +// delete(room.Users, userID) +// } + +// roomMu.Lock() +// defer roomMu.Unlock() +// for roomID, r := range rooms { +// if r == room { +// delete(rooms, roomID) +// log.Printf("Room %s expired and removed", roomID) +// break +// } +// } +// } + +// // TranscriptionResult represents the JSON response from the Python script +// type TranscriptionResult struct { +// Transcription string `json:"transcription"` +// } + +// func saveUserMedia(conn *websocket.Conn, userID, sectionName string, mediaFileChan chan<- string, room *structs.Room) { +// defer close(mediaFileChan) + +// tempFilename := fmt.Sprintf("temp_media_%s_%s.webm", userID, sectionName) +// finalFilename := fmt.Sprintf("media_%s_%s.webm", userID, sectionName) + +// file, err := os.Create(tempFilename) +// if err != nil { +// log.Printf("Error creating file for user %s: %v", userID, err) +// mediaFileChan <- "" +// return +// } +// defer func() { +// file.Close() +// err = os.Rename(tempFilename, finalFilename) +// if err != nil { +// log.Printf("Error renaming file for user %s: %v", userID, err) +// mediaFileChan <- "" +// } else { +// log.Printf("Media saved for user %s in section %s", userID, sectionName) +// mediaFileChan <- finalFilename +// } +// }() + +// for { +// room.Mutex.Lock() +// active := room.TurnActive[userID] +// room.Mutex.Unlock() + +// if !active { +// log.Printf("Turn ended for user %s. Stopping media collection.", userID) +// break +// } + +// messageType, data, err := conn.ReadMessage() +// if err != nil { +// if websocket.IsCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { +// log.Printf("Connection closed for user %s", userID) +// } else { +// log.Printf("Error reading chunk for user %s: %v", userID, err) +// } +// break +// } + +// if messageType == websocket.BinaryMessage { +// _, err = file.Write(data) +// if err != nil { +// log.Printf("Error writing chunk for user %s: %v", userID, err) +// break +// } +// } +// } + +// err = file.Sync() +// if err != nil { +// log.Printf("Error syncing file for user %s: %v", userID, err) +// mediaFileChan <- "" +// } +// } diff --git a/backend/websocket/websocket.go b/backend/websocket/websocket.go new file mode 100644 index 0000000..c58c68c --- /dev/null +++ b/backend/websocket/websocket.go @@ -0,0 +1,261 @@ +package websocket + +import ( + "log" + "net/http" + "sync" + "time" + + "encoding/json" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +var upgrader = websocket.Upgrader{ + // In production, adjust the CheckOrigin function to allow only trusted origins. + CheckOrigin: func(r *http.Request) bool { + return true + }, +} + +// Room represents a debate room with connected clients. +type Room struct { + Clients map[*websocket.Conn]bool + Mutex sync.Mutex +} + +type Message struct { + Type string `json:"type"` + Room string `json:"room,omitempty"` + Username string `json:"username,omitempty"` + Content string `json:"content,omitempty"` + Extra json.RawMessage `json:"extra,omitempty"` +} + +var rooms = make(map[string]*Room) +var roomsMutex sync.Mutex + +// WebsocketHandler handles WebSocket connections for debate signaling. +func WebsocketHandler(c *gin.Context) { + roomID := c.Query("room") + if roomID == "" { + log.Println("WebSocket connection failed: missing room parameter") + c.JSON(http.StatusBadRequest, gin.H{"error": "Missing room parameter"}) + return + } + + // Create the room if it doesn't exist. + roomsMutex.Lock() + if _, exists := rooms[roomID]; !exists { + rooms[roomID] = &Room{Clients: make(map[*websocket.Conn]bool)} + log.Printf("Created new room: %s", roomID) + } + room := rooms[roomID] + roomsMutex.Unlock() + + // Upgrade the connection. + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + log.Println("WebSocket upgrade error:", err) + return + } + + // Limit room to 2 clients. + room.Mutex.Lock() + if len(room.Clients) >= 2 { + log.Printf("Room %s is full. Closing connection.", roomID) + room.Mutex.Unlock() + conn.Close() + return + } + room.Clients[conn] = true + log.Printf("Client joined room %s (total clients: %d)", roomID, len(room.Clients)) + room.Mutex.Unlock() + + // Listen for messages. + for { + messageType, msg, err := conn.ReadMessage() + if err != nil { + log.Printf("WebSocket read error in room %s: %v", roomID, err) + // Remove client from room. + room.Mutex.Lock() + delete(room.Clients, conn) + log.Printf("Client removed from room %s (total clients: %d)", roomID, len(room.Clients)) + // If room is empty, delete it. + if len(room.Clients) == 0 { + roomsMutex.Lock() + delete(rooms, roomID) + roomsMutex.Unlock() + log.Printf("Room %s deleted as it became empty", roomID) + } + room.Mutex.Unlock() + break + } + + log.Printf("Received message in room %s: %s", roomID, string(msg)) + + // Broadcast the message to all other clients in the room. + room.Mutex.Lock() + for client := range room.Clients { + if client != conn { + if err := client.WriteMessage(messageType, msg); err != nil { + log.Printf("WebSocket write error in room %s: %v", roomID, err) + } else { + log.Printf("Forwarded message to a client in room %s", roomID) + } + } + } + room.Mutex.Unlock() + } + + log.Printf("Connection closed in room %s", roomID) +} + +func RoomChatHandler(c *gin.Context) { + roomID := c.Param("roomId") + if roomID == "" { + log.Println("WebSocket connection failed: missing roomId parameter") + c.JSON(http.StatusBadRequest, gin.H{"error": "Missing roomId parameter"}) + return + } + + // Access or create the room safely. + roomsMutex.Lock() + if _, exists := rooms[roomID]; !exists { + rooms[roomID] = &Room{Clients: make(map[*websocket.Conn]bool)} + log.Printf("Created new room: %s", roomID) + } + room := rooms[roomID] + roomsMutex.Unlock() + + // Upgrade the HTTP connection to a WebSocket connection. + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + log.Println("WebSocket upgrade error:", err) + return + } + + // Add the client to the room, with a limit of 2 clients. + room.Mutex.Lock() + if len(room.Clients) >= 10 { + log.Printf("Room %s is full. Closing connection.", roomID) + room.Mutex.Unlock() + conn.Close() + return + } + room.Clients[conn] = true + log.Printf("Client joined room %s (total clients: %d)", roomID, len(room.Clients)) + room.Mutex.Unlock() + + // Local map to associate connections with usernames. + usernames := make(map[*websocket.Conn]string) + + // Listen for incoming messages. + for { + _, msg, err := conn.ReadMessage() + if err != nil { + log.Printf("WebSocket read error in room %s: %v", roomID, err) + // Clean up on disconnect. + room.Mutex.Lock() + delete(room.Clients, conn) + delete(usernames, conn) + log.Printf("Client removed from room %s (total clients: %d)", roomID, len(room.Clients)) + if len(room.Clients) == 0 { + roomsMutex.Lock() + delete(rooms, roomID) + roomsMutex.Unlock() + log.Printf("Room %s deleted as it became empty", roomID) + } + room.Mutex.Unlock() + break + } + + // Parse the incoming message. + var message Message + if err := json.Unmarshal(msg, &message); err != nil { + log.Printf("Invalid message in room %s: %v", roomID, err) + continue + } + + // Handle different message types. + switch message.Type { + case "join": + // Store the username when a client joins. + usernames[conn] = message.Username + // Notify other clients of the new user. + room.Mutex.Lock() + for client := range room.Clients { + if client != conn { + client.WriteJSON(map[string]interface{}{ + "type": "notification", + "content": "User " + message.Username + " has joined", + }) + } + } + // Update user count for all clients. + for client := range room.Clients { + client.WriteJSON(map[string]interface{}{ + "type": "presence", + "count": len(room.Clients), + }) + } + room.Mutex.Unlock() + + case "chatMessage": + // Retrieve the sender's username. + username, exists := usernames[conn] + if !exists || username == "" { + log.Printf("No username set for client in room %s", roomID) + continue + } + // Add a timestamp and broadcast the message. + timestamp := time.Now().Unix() + room.Mutex.Lock() + for client := range room.Clients { + if client != conn { // Send to other clients only. + err := client.WriteJSON(map[string]interface{}{ + "type": "chatMessage", + "username": username, + "content": message.Content, + "timestamp": timestamp, + }) + if err != nil { + log.Printf("WebSocket write error in room %s: %v", roomID, err) + } + } + } + room.Mutex.Unlock() + + case "reaction": + // Broadcast reactions to other clients. + room.Mutex.Lock() + for client := range room.Clients { + if client != conn { + client.WriteJSON(map[string]interface{}{ + "type": "reaction", + "extra": message.Extra, + }) + } + } + room.Mutex.Unlock() + + case "vote": + // Broadcast votes to other clients. + room.Mutex.Lock() + for client := range room.Clients { + if client != conn { + client.WriteJSON(map[string]interface{}{ + "type": "vote", + "extra": message.Extra, + }) + } + } + room.Mutex.Unlock() + + default: + log.Printf("Unknown message type in room %s: %s", roomID, message.Type) + } + } + + log.Printf("Connection closed in room %s", roomID) +} \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index e4b78ea..423cb44 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,6 +5,7 @@ Vite + React + TS +
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d134963..83fc39b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,11 +9,16 @@ "version": "0.0.0", "dependencies": { "@radix-ui/react-avatar": "^1.1.2", + "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-progress": "^1.1.2", "@radix-ui/react-scroll-area": "^1.2.2", + "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.0", - "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-tabs": "^1.1.3", + "@radix-ui/react-toast": "^1.2.6", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "config": "^3.3.12", @@ -23,6 +28,7 @@ "react-helmet": "^6.1.0", "react-icons": "^5.3.0", "react-router-dom": "^6.28.0", + "recharts": "^2.15.1", "tailwind-merge": "^2.5.2", "tailwind-scrollbar-hide": "^2.0.0", "tailwindcss-animate": "^1.0.7" @@ -20218,7 +20224,6 @@ "version": "7.25.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.7.tgz", "integrity": "sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w==", - "dev": true, "license": "MIT", "dependencies": { "regenerator-runtime": "^0.14.0" @@ -20883,6 +20888,40 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", + "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", + "dependencies": { + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.13", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz", + "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==" + }, "node_modules/@graphql-codegen/core": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@graphql-codegen/core/-/core-4.0.2.tgz", @@ -22116,15 +22155,12 @@ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==" }, - "node_modules/@radix-ui/react-avatar": { + "node_modules/@radix-ui/react-arrow": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.2.tgz", - "integrity": "sha512-GaC7bXQZ5VgZvVvsJ5mu/AEbjYLnhhkoidOboC50Z6FFlLA03wG2ianUoH+zgDQ31/9gCF59bE4+2bBgTyMiig==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz", + "integrity": "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==", "dependencies": { - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-primitive": "2.0.1", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0" + "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", @@ -22141,17 +22177,50 @@ } } }, - "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", - "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.2.tgz", + "integrity": "sha512-GaC7bXQZ5VgZvVvsJ5mu/AEbjYLnhhkoidOboC50Z6FFlLA03wG2ianUoH+zgDQ31/9gCF59bE4+2bBgTyMiig==", + "dependencies": { + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, @@ -22194,11 +22263,57 @@ } } }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz", + "integrity": "sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", - "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", - "license": "MIT", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -22223,6 +22338,63 @@ } } }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.6.tgz", + "integrity": "sha512-/IVhJV5AceX620DUJ4uYVMymzsipdKBzo3edo+omeskCKGm9FRHM0ebIdbPnlQVJqyuHbuBltQUOG2mOTq2IYw==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.2", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", @@ -22237,6 +22409,114 @@ } } }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz", + "integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", + "integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz", + "integrity": "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-icons": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.0.tgz", @@ -22246,6 +22526,23 @@ "react": "^16.x || ^17.x || ^18.x" } }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", + "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-label": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.0.tgz", @@ -22269,6 +22566,104 @@ } } }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz", + "integrity": "sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-rect": "1.1.0", + "@radix-ui/react-use-size": "1.1.0", + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz", + "integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-presence": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz", @@ -22292,10 +22687,33 @@ } } }, - "node_modules/@radix-ui/react-presence/node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", - "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "node_modules/@radix-ui/react-primitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -22306,13 +22724,30 @@ } } }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", - "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", - "license": "MIT", + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", "dependencies": { - "@radix-ui/react-slot": "1.1.0" + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.2.tgz", + "integrity": "sha512-u1IgJFQ4zNAUTjGdDL5dcl/U8ntOR6jsnhxKb5RKp5Ozwl88xKR9EqRZOe/Mk8tnx0x5tNUe2F+MzsyjqMg0MA==", + "dependencies": { + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", @@ -22329,20 +22764,42 @@ } } }, - "node_modules/@radix-ui/react-scroll-area": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.2.tgz", - "integrity": "sha512-EFI1N/S3YxZEW/lJ/H1jY3njlvTd8tBmgKEn4GHi51+aMm94i6NmAJstsm5cu3yJwYqYc93gpCPm21FeAbFk6g==", + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.2.tgz", + "integrity": "sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==", "dependencies": { - "@radix-ui/number": "1.1.0", "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-presence": "1.1.2", - "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0" + "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { "@types/react": "*", @@ -22359,17 +22816,55 @@ } } }, - "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", - "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.2.tgz", + "integrity": "sha512-EFI1N/S3YxZEW/lJ/H1jY3njlvTd8tBmgKEn4GHi51+aMm94i6NmAJstsm5cu3yJwYqYc93gpCPm21FeAbFk6g==", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, @@ -22412,6 +22907,70 @@ } } }, + "node_modules/@radix-ui/react-select": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.6.tgz", + "integrity": "sha512-T6ajELxRvTuAMWH0YmRJ1qez+x4/7Nq7QIx7zJ0VK3qaEWdnWpNbEDnmWldG1zBDwqrLy5aLMUWcoGirVj5kMg==", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.2", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-separator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.0.tgz", @@ -22436,12 +22995,11 @@ } }, "node_modules/@radix-ui/react-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", - "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", - "license": "MIT", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", + "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0" + "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -22453,6 +23011,112 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.3.tgz", + "integrity": "sha512-9mFyI30cuRDImbmFF6O2KUJdgEOsGh9Vmx9x/Dh9tOhL7BngmQPQfwW4aejKm5OHpfWIdmeV6ySyuxoOGjtNng==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-roving-focus": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.6.tgz", + "integrity": "sha512-gN4dpuIVKEgpLn1z5FhzT9mYRUitbfZq9XqN/7kkBMUgFTzTG8x/KszWJugJXHcwxckY8xcKDZPz7kG3o6DsUA==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", @@ -22467,6 +23131,40 @@ } } }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", + "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-layout-effect": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", @@ -22481,6 +23179,103 @@ } } }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz", + "integrity": "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", + "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", + "dependencies": { + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", + "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.2.tgz", + "integrity": "sha512-1SzA4ns2M1aRlvxErqhLHsBHoS5eI5UUcI2awAMgGUp4LoaoWOKYmvqDY2s/tltuPkh3Yk77YF/r3IRj+Amx4Q==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", + "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==" + }, "node_modules/@remix-run/router": { "version": "1.21.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.21.0.tgz", @@ -24157,6 +24952,60 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -24627,6 +25476,17 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-hidden": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.4.tgz", + "integrity": "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", @@ -26195,7 +27055,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true, "license": "MIT" }, "node_modules/csv-parse": { @@ -26205,6 +27064,116 @@ "dev": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "engines": { + "node": ">=12" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -26304,6 +27273,11 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -26519,6 +27493,11 @@ "node": ">=0.10" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -26544,6 +27523,15 @@ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "license": "MIT" }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -27079,6 +28067,11 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, "node_modules/execa": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", @@ -27132,6 +28125,14 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-equals": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz", + "integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -27516,6 +28517,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "engines": { + "node": ">=6" + } + }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -28021,6 +29030,14 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "engines": { + "node": ">=12" + } + }, "node_modules/interpret": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", @@ -28814,7 +29831,6 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, "license": "MIT" }, "node_modules/lodash-es": { @@ -28992,7 +30008,6 @@ "version": "0.446.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.446.0.tgz", "integrity": "sha512-BU7gy8MfBMqvEdDPH79VhOXSEgyG8TSPOKWaExWGCQVqnGH7wGgDngPbofu+KdtVjPQBWbEmnfMTq90CTiiDRg==", - "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } @@ -30284,6 +31299,51 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-remove-scroll": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz", + "integrity": "sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ==", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-router": { "version": "6.28.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.28.0.tgz", @@ -30325,6 +31385,56 @@ "react": "^16.3.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -30346,6 +31456,41 @@ "node": ">=8.10.0" } }, + "node_modules/recharts": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.1.tgz", + "integrity": "sha512-v8PUTUlyiDe56qUj82w/EDVuzEFXwEHp9/xOowGAZwfLjB9uAy3GllQVIYMWF6nU+qibx85WF75zD7AjqoT54Q==", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" + }, "node_modules/rechoir": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", @@ -30383,7 +31528,6 @@ "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "dev": true, "license": "MIT" }, "node_modules/regenerator-transform": { @@ -31550,6 +32694,11 @@ "node": ">=8" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" + }, "node_modules/title-case": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz", @@ -31665,7 +32814,6 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", - "dev": true, "license": "0BSD" }, "node_modules/tsx": { @@ -32483,6 +33631,47 @@ "dev": true, "license": "MIT" }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -32513,6 +33702,27 @@ "node": ">=12" } }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "5.4.8", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.8.tgz", @@ -48691,7 +49901,6 @@ "version": "7.25.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.7.tgz", "integrity": "sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w==", - "dev": true, "requires": { "regenerator-runtime": "^0.14.0" } @@ -49036,6 +50245,36 @@ "levn": "^0.4.1" } }, + "@floating-ui/core": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", + "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", + "requires": { + "@floating-ui/utils": "^0.2.9" + } + }, + "@floating-ui/dom": { + "version": "1.6.13", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz", + "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", + "requires": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.9" + } + }, + "@floating-ui/react-dom": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "requires": { + "@floating-ui/dom": "^1.0.0" + } + }, + "@floating-ui/utils": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==" + }, "@graphql-codegen/core": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@graphql-codegen/core/-/core-4.0.2.tgz", @@ -49867,6 +51106,24 @@ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==" }, + "@radix-ui/react-arrow": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz", + "integrity": "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==", + "requires": { + "@radix-ui/react-primitive": "2.0.2" + }, + "dependencies": { + "@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "requires": { + "@radix-ui/react-slot": "1.1.2" + } + } + } + }, "@radix-ui/react-avatar": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.2.tgz", @@ -49878,12 +51135,6 @@ "@radix-ui/react-use-layout-effect": "1.1.0" }, "dependencies": { - "@radix-ui/react-compose-refs": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", - "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", - "requires": {} - }, "@radix-ui/react-primitive": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz", @@ -49902,10 +51153,31 @@ } } }, + "@radix-ui/react-collection": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz", + "integrity": "sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2" + }, + "dependencies": { + "@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "requires": { + "@radix-ui/react-slot": "1.1.2" + } + } + } + }, "@radix-ui/react-compose-refs": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", - "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", "requires": {} }, "@radix-ui/react-context": { @@ -49914,18 +51186,105 @@ "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", "requires": {} }, + "@radix-ui/react-dialog": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.6.tgz", + "integrity": "sha512-/IVhJV5AceX620DUJ4uYVMymzsipdKBzo3edo+omeskCKGm9FRHM0ebIdbPnlQVJqyuHbuBltQUOG2mOTq2IYw==", + "requires": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.2", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "dependencies": { + "@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "requires": { + "@radix-ui/react-slot": "1.1.2" + } + } + } + }, "@radix-ui/react-direction": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", "requires": {} }, + "@radix-ui/react-dismissable-layer": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz", + "integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==", + "requires": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "dependencies": { + "@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "requires": { + "@radix-ui/react-slot": "1.1.2" + } + } + } + }, + "@radix-ui/react-focus-guards": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", + "integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==", + "requires": {} + }, + "@radix-ui/react-focus-scope": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz", + "integrity": "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "dependencies": { + "@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "requires": { + "@radix-ui/react-slot": "1.1.2" + } + } + } + }, "@radix-ui/react-icons": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.0.tgz", "integrity": "sha512-jQxj/0LKgp+j9BiTXz3O3sgs26RNet2iLWmsPyRz2SIcR4q/4SbazXfnYwbAr+vLYKSfc7qxzyGQA1HLlYiuNw==", "requires": {} }, + "@radix-ui/react-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", + "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", + "requires": { + "@radix-ui/react-use-layout-effect": "1.1.0" + } + }, "@radix-ui/react-label": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.0.tgz", @@ -49934,6 +51293,52 @@ "@radix-ui/react-primitive": "2.0.0" } }, + "@radix-ui/react-popper": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz", + "integrity": "sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==", + "requires": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-rect": "1.1.0", + "@radix-ui/react-use-size": "1.1.0", + "@radix-ui/rect": "1.1.0" + }, + "dependencies": { + "@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "requires": { + "@radix-ui/react-slot": "1.1.2" + } + } + } + }, + "@radix-ui/react-portal": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz", + "integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==", + "requires": { + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "dependencies": { + "@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "requires": { + "@radix-ui/react-slot": "1.1.2" + } + } + } + }, "@radix-ui/react-presence": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz", @@ -49941,14 +51346,6 @@ "requires": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.0" - }, - "dependencies": { - "@radix-ui/react-compose-refs": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", - "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", - "requires": {} - } } }, "@radix-ui/react-primitive": { @@ -49957,6 +51354,67 @@ "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", "requires": { "@radix-ui/react-slot": "1.1.0" + }, + "dependencies": { + "@radix-ui/react-compose-refs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "requires": {} + }, + "@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.0" + } + } + } + }, + "@radix-ui/react-progress": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.2.tgz", + "integrity": "sha512-u1IgJFQ4zNAUTjGdDL5dcl/U8ntOR6jsnhxKb5RKp5Ozwl88xKR9EqRZOe/Mk8tnx0x5tNUe2F+MzsyjqMg0MA==", + "requires": { + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2" + }, + "dependencies": { + "@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "requires": { + "@radix-ui/react-slot": "1.1.2" + } + } + } + }, + "@radix-ui/react-roving-focus": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.2.tgz", + "integrity": "sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==", + "requires": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "dependencies": { + "@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "requires": { + "@radix-ui/react-slot": "1.1.2" + } + } } }, "@radix-ui/react-scroll-area": { @@ -49975,12 +51433,6 @@ "@radix-ui/react-use-layout-effect": "1.1.0" }, "dependencies": { - "@radix-ui/react-compose-refs": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", - "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", - "requires": {} - }, "@radix-ui/react-primitive": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz", @@ -49999,6 +51451,44 @@ } } }, + "@radix-ui/react-select": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.6.tgz", + "integrity": "sha512-T6ajELxRvTuAMWH0YmRJ1qez+x4/7Nq7QIx7zJ0VK3qaEWdnWpNbEDnmWldG1zBDwqrLy5aLMUWcoGirVj5kMg==", + "requires": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.2", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "dependencies": { + "@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "requires": { + "@radix-ui/react-slot": "1.1.2" + } + } + } + }, "@radix-ui/react-separator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.0.tgz", @@ -50008,11 +51498,65 @@ } }, "@radix-ui/react-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", - "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", + "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", "requires": { - "@radix-ui/react-compose-refs": "1.1.0" + "@radix-ui/react-compose-refs": "1.1.1" + } + }, + "@radix-ui/react-tabs": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.3.tgz", + "integrity": "sha512-9mFyI30cuRDImbmFF6O2KUJdgEOsGh9Vmx9x/Dh9tOhL7BngmQPQfwW4aejKm5OHpfWIdmeV6ySyuxoOGjtNng==", + "requires": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-roving-focus": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "dependencies": { + "@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "requires": { + "@radix-ui/react-slot": "1.1.2" + } + } + } + }, + "@radix-ui/react-toast": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.6.tgz", + "integrity": "sha512-gN4dpuIVKEgpLn1z5FhzT9mYRUitbfZq9XqN/7kkBMUgFTzTG8x/KszWJugJXHcwxckY8xcKDZPz7kG3o6DsUA==", + "requires": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.2" + }, + "dependencies": { + "@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "requires": { + "@radix-ui/react-slot": "1.1.2" + } + } } }, "@radix-ui/react-use-callback-ref": { @@ -50021,12 +51565,73 @@ "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", "requires": {} }, + "@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "requires": { + "@radix-ui/react-use-callback-ref": "1.1.0" + } + }, + "@radix-ui/react-use-escape-keydown": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", + "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", + "requires": { + "@radix-ui/react-use-callback-ref": "1.1.0" + } + }, "@radix-ui/react-use-layout-effect": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", "requires": {} }, + "@radix-ui/react-use-previous": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz", + "integrity": "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==", + "requires": {} + }, + "@radix-ui/react-use-rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", + "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", + "requires": { + "@radix-ui/rect": "1.1.0" + } + }, + "@radix-ui/react-use-size": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", + "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", + "requires": { + "@radix-ui/react-use-layout-effect": "1.1.0" + } + }, + "@radix-ui/react-visually-hidden": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.2.tgz", + "integrity": "sha512-1SzA4ns2M1aRlvxErqhLHsBHoS5eI5UUcI2awAMgGUp4LoaoWOKYmvqDY2s/tltuPkh3Yk77YF/r3IRj+Amx4Q==", + "requires": { + "@radix-ui/react-primitive": "2.0.2" + }, + "dependencies": { + "@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "requires": { + "@radix-ui/react-slot": "1.1.2" + } + } + } + }, + "@radix-ui/rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", + "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==" + }, "@remix-run/router": { "version": "1.21.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.21.0.tgz", @@ -51104,6 +52709,60 @@ "integrity": "sha512-dtByW6WiFk5W5Jfgz1VM+YPA21xMXTuSFoLYIDY0L44jDLLflVPtZkYuu3/YxpGcvjzKFBZLU+GyKjR0HOYtyw==", "dev": true }, + "@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==" + }, + "@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + }, + "@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" + }, + "@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "requires": { + "@types/d3-color": "*" + } + }, + "@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==" + }, + "@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "requires": { + "@types/d3-time": "*" + } + }, + "@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "requires": { + "@types/d3-path": "*" + } + }, + "@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==" + }, + "@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" + }, "@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -51407,6 +53066,14 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "aria-hidden": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.4.tgz", + "integrity": "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==", + "requires": { + "tslib": "^2.0.0" + } + }, "array-buffer-byte-length": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", @@ -52465,8 +54132,7 @@ "csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "csv-parse": { "version": "5.5.6", @@ -52474,6 +54140,83 @@ "integrity": "sha512-uNpm30m/AGSkLxxy7d9yRXpJQFrZzVWLFBkS+6ngPcZkw/5k3L/jjFuj7tVnEpRn+QgmiXr21nDlhCiUK4ij2A==", "dev": true }, + "d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "requires": { + "internmap": "1 - 2" + } + }, + "d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==" + }, + "d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==" + }, + "d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==" + }, + "d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "requires": { + "d3-color": "1 - 3" + } + }, + "d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==" + }, + "d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "requires": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + } + }, + "d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "requires": { + "d3-path": "^3.1.0" + } + }, + "d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "requires": { + "d3-array": "2 - 3" + } + }, + "d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "requires": { + "d3-time": "1 - 3" + } + }, + "d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==" + }, "data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -52534,6 +54277,11 @@ "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", "dev": true }, + "decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==" + }, "deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -52669,6 +54417,11 @@ "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", "dev": true }, + "detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" + }, "didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -52688,6 +54441,15 @@ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" }, + "dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "requires": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -53059,6 +54821,11 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true }, + "eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, "execa": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", @@ -53099,6 +54866,11 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, + "fast-equals": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz", + "integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==" + }, "fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -53363,6 +55135,11 @@ "hasown": "^2.0.0" } }, + "get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==" + }, "get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -53693,6 +55470,11 @@ "side-channel": "^1.0.4" } }, + "internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==" + }, "interpret": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", @@ -54180,8 +55962,7 @@ "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "lodash-es": { "version": "4.17.21", @@ -55141,6 +56922,27 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "react-remove-scroll": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz", + "integrity": "sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ==", + "requires": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + } + }, + "react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "requires": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + } + }, "react-router": { "version": "6.28.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.28.0.tgz", @@ -55164,6 +56966,36 @@ "integrity": "sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw==", "requires": {} }, + "react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "requires": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + } + }, + "react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "requires": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + } + }, + "react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "requires": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + } + }, "read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -55180,6 +57012,36 @@ "picomatch": "^2.2.1" } }, + "recharts": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.1.tgz", + "integrity": "sha512-v8PUTUlyiDe56qUj82w/EDVuzEFXwEHp9/xOowGAZwfLjB9uAy3GllQVIYMWF6nU+qibx85WF75zD7AjqoT54Q==", + "requires": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "dependencies": { + "react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" + } + } + }, + "recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "requires": { + "decimal.js-light": "^2.4.1" + } + }, "rechoir": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", @@ -55207,8 +57069,7 @@ "regenerator-runtime": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "dev": true + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, "regenerator-transform": { "version": "0.15.2", @@ -55995,6 +57856,11 @@ "integrity": "sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==", "dev": true }, + "tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" + }, "title-case": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz", @@ -56072,8 +57938,7 @@ "tslib": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", - "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", - "dev": true + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" }, "tsx": { "version": "4.19.1", @@ -56492,6 +58357,23 @@ "integrity": "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==", "dev": true }, + "use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "requires": { + "tslib": "^2.0.0" + } + }, + "use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "requires": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + } + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -56509,6 +58391,27 @@ "integrity": "sha512-Z6Uz+TYwEqE7ZN50gwn+1LCVo9ZVrpxRPOhOLnncYkY1ZzOYtrX8Fwf/rFktZ8R5mJms6EZf5TqNOMeZmnPq9Q==", "dev": true }, + "victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "requires": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "vite": { "version": "5.4.8", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index 102f792..1f5aba8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,11 +11,16 @@ }, "dependencies": { "@radix-ui/react-avatar": "^1.1.2", + "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-progress": "^1.1.2", "@radix-ui/react-scroll-area": "^1.2.2", + "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.0", - "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-tabs": "^1.1.3", + "@radix-ui/react-toast": "^1.2.6", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "config": "^3.3.12", @@ -25,6 +30,7 @@ "react-helmet": "^6.1.0", "react-icons": "^5.3.0", "react-router-dom": "^6.28.0", + "recharts": "^2.15.1", "tailwind-merge": "^2.5.2", "tailwind-scrollbar-hide": "^2.0.0", "tailwindcss-animate": "^1.0.7" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8ebbe24..7ed4e66 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,41 +1,96 @@ -import './App.css'; -import { BrowserRouter as Router, Routes, Route, Navigate, Outlet } from 'react-router-dom'; -import Authentication from './Pages/Authentication'; -import Home from './Pages/Home'; -import { ThemeProvider } from './context/theme-provider'; -import DebateApp from './Pages/Game'; -import { AuthContext, AuthProvider } from "./context/authContext"; -import { useContext, useEffect, useState } from 'react'; +import React, { useContext } from "react"; +import { Routes, Route, Navigate, Outlet } from "react-router-dom"; +import { AuthProvider, AuthContext } from "./context/authContext"; +import { ThemeProvider } from "./context/theme-provider"; +// Pages +import Home from "./Pages/Home"; +import Authentication from "./Pages/Authentication"; +import DebateApp from "./Pages/Game"; +import Profile from "./Pages/Profile"; +import Leaderboard from "./Pages/Leaderboard"; +import StartDebate from "./Pages/StartDebate"; +import About from "./Pages/About"; +import BotSelection from "./Pages/BotSelection"; +import DebateRoom from "./Pages/DebateRoom"; +import OnlineDebateRoom from "./Pages/OnlineDebateRoom"; +import StrengthenArgument from "./Pages/StrengthenArgument"; +// Layout +import Layout from "./components/Layout"; +import CoachPage from "./Pages/CoachPage"; +import ChatRoom from "./components/ChatRoom"; +import TournamentHub from "./Pages/TournamentHub"; +import TournamentDetails from "./Pages/TournamentDetails"; +import ProsConsChallenge from "./Pages/ProsConsChallenge"; -const ProtectedRoute = () => { - const auth = useContext(AuthContext); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - setIsLoading(false); - }, [auth?.isAuthenticated]); - - if (isLoading) return
Loading...
; // Show loading screen while checking auth - - return auth?.isAuthenticated ? : ; -}; +// Protects routes based on authentication status +function ProtectedRoute() { + const authContext = useContext(AuthContext); + if (!authContext) { + throw new Error("ProtectedRoute must be used within an AuthProvider"); + } + const { isAuthenticated, loading: isLoading } = authContext; + if (isLoading) { + return
Loading...
; + } + return isAuthenticated ? : ; +} +// Defines application routes +function AppRoutes() { + const authContext = useContext(AuthContext); + if (!authContext) { + throw new Error("AppRoutes must be used within an AuthProvider"); + } + const { isAuthenticated } = authContext; + return ( + + {/* Public routes */} + : + } + /> + } /> + {/* Protected routes with layout */} + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } + /> + } /> + } /> {/* Add this route */} + } /> + + } /> + } /> + } /> + + {/* Redirect unknown routes */} + } /> + + ); +} +// Main app with providers function App() { return ( - - } /> - } /> - }> - } /> - - + ); } - export default App; diff --git a/frontend/src/Pages/About.tsx b/frontend/src/Pages/About.tsx new file mode 100644 index 0000000..0691b86 --- /dev/null +++ b/frontend/src/Pages/About.tsx @@ -0,0 +1,130 @@ +import React from "react" + +function About() { + return ( +
+ {/* Main Heading */} +

+ About DebateAI +

+ + {/* Intro Paragraph */} +

+ DebateAI is a platform dedicated to helping you sharpen your argumentation + and public speaking skills through interactive, AI-enhanced debates. + Whether you’re a seasoned debater or just starting out, you’ll find + exciting real-time challenges, structured debate formats, and a vibrant + community ready to engage with you. +

+ + {/* Our Mission */} +
+

+ Our Mission +

+

+ We believe that strong communication skills are essential in every area + of life. Our goal is to make debate practice accessible, fun, and + effective. Through DebateAI, you can learn to construct compelling + arguments, understand multiple perspectives, and boost your confidence + in presenting your ideas—all in an engaging, interactive environment. +

+
+ + {/* Key Features */} +
+

+ Key Features +

+
    +
  • + AI-Enhanced Debates: Challenge an AI-driven opponent + that adapts to your arguments in real time. +
  • +
  • + Real-Time User Matchups: Engage in live debates with + fellow users on topics ranging from pop culture to global issues. +
  • +
  • + Structured Formats: Practice formal debate rounds + including opening statements, rebuttals, and closing arguments. +
  • +
  • + Personalized Progress Tracking: Keep tabs on your + debate history, ratings, and skill improvements. +
  • +
  • + Community-Driven Topics: Suggest new debate topics + and vote on trending issues to keep discussions fresh and relevant. +
  • +
+
+ + {/* How It Benefits You */} +
+

+ How DebateAI Benefits You +

+

+ By combining modern AI technology with interactive debate formats, + DebateAI helps you: +

+
    +
  • + Build critical thinking and persuasive communication skills. +
  • +
  • + Gain confidence in articulating your viewpoints in front of others. +
  • +
  • + Explore diverse perspectives and expand your knowledge on current + events. +
  • +
  • + Receive instant feedback from both AI opponents and community + members. +
  • +
+
+ + {/* Contributing / Community Involvement */} +
+

+ Get Involved +

+

+ We’re always looking for passionate debaters, topic curators, and + community members who want to help us grow. Here’s how you can + contribute: +

+
    +
  • + Suggest New Features: Have an idea to improve + DebateAI? Share it in our feedback forum. +
  • +
  • + Submit Debate Topics: Propose topics you’re + passionate about and spark meaningful discussions. +
  • +
  • + Join the Community: Participate in forums, attend + online meetups, and help new members get started. +
  • +
+
+ + {/* Closing */} +

+ Thank you for being a part of DebateAI. Together, let’s make + argumentation and critical thinking skills accessible to everyone! +

+ + {/* Footer */} +
+ © 2016-2025 AOSSIE. All rights reserved. +
+
+ ) +} + +export default About \ No newline at end of file diff --git a/frontend/src/Pages/Authentication/forms.tsx b/frontend/src/Pages/Authentication/forms.tsx index c580dca..75a045f 100644 --- a/frontend/src/Pages/Authentication/forms.tsx +++ b/frontend/src/Pages/Authentication/forms.tsx @@ -1,9 +1,8 @@ -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { useContext, useState } from 'react'; -import { AuthContext } from '../../context/authContext'; // Adjust import path - +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { useContext, useState, useEffect } from 'react'; +import { AuthContext } from '../../context/authContext'; interface LoginFormProps { startForgotPassword: () => void; @@ -13,20 +12,52 @@ interface LoginFormProps { export const LoginForm: React.FC = ({ startForgotPassword, infoMessage }) => { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); - const [passwordVisible, setPasswordVisible] = useState(false) + const [passwordVisible, setPasswordVisible] = useState(false); const authContext = useContext(AuthContext); if (!authContext) { throw new Error('LoginForm must be used within an AuthProvider'); } - const { login, error, loading } = authContext; + const { login, googleLogin, error, loading } = authContext; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); await login(email, password); }; + const handleGoogleLogin = (response: { credential: string; select_by: string }) => { + const idToken = response.credential; + googleLogin(idToken); + }; + + useEffect(() => { + const google = window.google; + if (!google?.accounts) { + console.warn('Google Sign-In script not loaded'); + return; + } + + google.accounts.id.initialize({ + client_id: import.meta.env.VITE_GOOGLE_CLIENT_ID, + callback: handleGoogleLogin, + }); + + const buttonElement = document.getElementById('googleSignInButton'); + if (buttonElement) { + google.accounts.id.renderButton(buttonElement, { + theme: 'outline', + size: 'large', + text: 'signin_with', + width: '100%', + }); + } + + return () => { + google.accounts.id.cancel(); + }; + }, []); + return (
{infoMessage &&

{infoMessage}

} @@ -61,14 +92,14 @@ export const LoginForm: React.FC = ({ startForgotPassword, infoM Reset Password

- +
); }; - interface SignUpFormProps { startOtpVerification: (email: string) => void; } @@ -76,7 +107,7 @@ interface SignUpFormProps { export const SignUpForm: React.FC = ({ startOtpVerification }) => { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); - const [passwordVisible, setPasswordVisible] = useState(false) + const [passwordVisible, setPasswordVisible] = useState(false); const [confirmPassword, setConfirmPassword] = useState(''); const authContext = useContext(AuthContext); @@ -84,7 +115,7 @@ export const SignUpForm: React.FC = ({ startOtpVerification }) throw new Error('SignUpForm must be used within an AuthProvider'); } - const { signup, error, loading } = authContext; + const { signup, googleLogin, error, loading } = authContext; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -98,6 +129,38 @@ export const SignUpForm: React.FC = ({ startOtpVerification }) startOtpVerification(email); }; + const handleGoogleLogin = (response: { credential: string; select_by: string }) => { + const idToken = response.credential; + googleLogin(idToken); + }; + + useEffect(() => { + const google = window.google; + if (!google?.accounts) { + console.warn('Google Sign-In script not loaded'); + return; + } + + google.accounts.id.initialize({ + client_id: import.meta.env.VITE_GOOGLE_CLIENT_ID, + callback: handleGoogleLogin, + }); + + const buttonElement = document.getElementById('googleSignUpButton'); + if (buttonElement) { + google.accounts.id.renderButton(buttonElement, { + theme: 'outline', + size: 'large', + text: 'signup_with', + width: '100%', + }); + } + + return () => { + google.accounts.id.cancel(); + }; + }, []); + return (
= ({ startOtpVerification })
show password
{error &&

{error}

} - +
); }; @@ -181,9 +245,8 @@ export const OTPVerificationForm: React.FC = ({ email, ); }; - interface ForgotPasswordFormProps { - startResetPassword: (email: string) => void; // Accept the new prop + startResetPassword: (email: string) => void; } export const ForgotPasswordForm: React.FC = ({ @@ -210,7 +273,6 @@ export const ForgotPasswordForm: React.FC = ({ return; } - // Move to the ResetPasswordForm startResetPassword(email); } catch { setError('An unexpected error occurred. Please try again later.'); @@ -238,12 +300,6 @@ export const ForgotPasswordForm: React.FC = ({ ); }; - -interface ResetPasswordFormProps { - email: string; - handlePasswordReset: () => void; -} - interface ResetPasswordFormProps { email: string; handlePasswordReset: () => void; @@ -253,7 +309,7 @@ export const ResetPasswordForm: React.FC = ({ email, han const [code, setCode] = useState(''); const [newPassword, setNewPassword] = useState(''); const [confirmNewPassword, setConfirmNewPassword] = useState(''); - const [passwordVisible, setPasswordVisible] = useState(false) + const [passwordVisible, setPasswordVisible] = useState(false); const authContext = useContext(AuthContext); if (!authContext) { @@ -300,16 +356,16 @@ export const ResetPasswordForm: React.FC = ({ email, han placeholder="Confirm New Password" className="w-full mb-4" /> -
-
- setPasswordVisible(e.target.checked)} - /> +
+
+ setPasswordVisible(e.target.checked)} + /> +
+
show password
-
show password
-
{error &&

{error}

}
); -}; \ No newline at end of file +}; diff --git a/frontend/src/Pages/BotSelection.tsx b/frontend/src/Pages/BotSelection.tsx new file mode 100644 index 0000000..e90c93f --- /dev/null +++ b/frontend/src/Pages/BotSelection.tsx @@ -0,0 +1,278 @@ +import React, { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Button } from "../components/ui/button"; +import { Input } from "../components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Separator } from "../components/ui/separator"; +import { createDebate } from "@/services/vsbot"; // Adjust the import path as necessary + +// Bot definitions with avatars, hover quotes, and ratings +const bots = [ + { name: "Rookie Rick", level: "Easy", desc: "A beginner who stumbles over logic.", avatar: "https://avatar.iran.liara.run/public/26", quote: "Uh, wait, what’s your point again?", rating: 1200 }, + { name: "Casual Casey", level: "Easy", desc: "Friendly but not too sharp.", avatar: "https://avatar.iran.liara.run/public/22", quote: "Let’s just chill and chat, okay?", rating: 1300 }, + { name: "Moderate Mike", level: "Medium", desc: "Balanced and reasonable.", avatar: "https://avatar.iran.liara.run/public/38", quote: "I see your side, but here’s mine.", rating: 1500 }, + { name: "Sassy Sarah", level: "Medium", desc: "Witty with decent arguments.", avatar: "https://avatar.iran.liara.run/public/78", quote: "Oh honey, you’re in for it now!", rating: 1600 }, + { name: "Innovative Iris", level: "Medium", desc: "A creative thinker", avatar: "https://avatar.iran.liara.run/public/72", quote: "Fresh ideas fuel productive debates.", rating: 1550 }, + { name: "Tough Tony", level: "Hard", desc: "Logical and relentless.", avatar: "https://avatar.iran.liara.run/public/37", quote: "Prove it or step aside.", rating: 1700 }, + { name: "Expert Emma", level: "Hard", desc: "Master of evidence and rhetoric.", avatar: "https://avatar.iran.liara.run/public/90", quote: "Facts don’t care about your feelings.", rating: 1800 }, + { name: "Grand Greg", level: "Expert", desc: "Unbeatable debate titan.", avatar: "https://avatar.iran.liara.run/public/45", quote: "Checkmate. Your move.", rating: 2000 }, +]; + +// Predefined debate topics +const predefinedTopics = [ + "Should AI rule the world?", + "Is space exploration worth the cost?", + "Should social media be regulated?", + "Is climate change humanity’s fault?", + "Should college education be free?", +]; + +// Default phase timings (in seconds, same for user and bot) +const defaultPhaseTimings = [ + { name: "Opening Statements", time: 240 }, + { name: "Cross-Examination", time: 180 }, + { name: "Closing Statements", time: 180 }, +]; + +// Loader component +const Loader: React.FC = () => ( +
+
+
+

Creating your room...

+

Getting your bot ready, please wait.

+
+
+); + +// Returns a custom special message for each bot +const getBotSpecialMessage = (botName: string | null) => { + switch (botName) { + case "Rookie Rick": + return "Get ready for a charming, underdog performance!"; + case "Casual Casey": + return "Relax and enjoy the laid-back debate vibe!"; + case "Moderate Mike": + return "A balanced challenge awaits you!"; + case "Sassy Sarah": + return "Prepare for sass and a bit of spice in the debate!"; + case "Innovative Iris": + return "Expect creative insights and fresh ideas!"; + case "Tough Tony": + return "Brace yourself for a no-nonsense, hard-hitting debate!"; + case "Expert Emma": + return "Expert-level debate incoming – sharpen your wit!"; + case "Grand Greg": + return "A legendary showdown is about to begin!"; + default: + return ""; + } +}; + +const BotSelection: React.FC = () => { + const [selectedBot, setSelectedBot] = useState(null); + const [topic, setTopic] = useState("custom"); + const [customTopic, setCustomTopic] = useState(""); + const [stance, setStance] = useState("random"); + const [phaseTimings, setPhaseTimings] = useState(defaultPhaseTimings); + const [isLoading, setIsLoading] = useState(false); + const navigate = useNavigate(); + + const effectiveTopic = topic === "custom" ? customTopic : topic; + + // Update phase timing ensuring the value is within the allowed range. + const updatePhaseTiming = (phaseIndex: number, value: string) => { + const newTimings = [...phaseTimings]; + const timeInSeconds = Math.max(60, Math.min(600, parseInt(value) || 0)); + newTimings[phaseIndex].time = timeInSeconds; + setPhaseTimings(newTimings); + }; + + const startDebate = async () => { + if (selectedBot && effectiveTopic) { + const bot = bots.find((b) => b.name === selectedBot); + + // Determine the final stance. If the user selected "random", pick one randomly. + const finalStance = stance === "random" ? (Math.random() < 0.5 ? "for" : "against") : stance; + + // Build payload + const debatePayload = { + botName: bot!.name, + botLevel: bot!.level, + topic: effectiveTopic, + stance: finalStance, + history: [], + phaseTimings, // Already in correct format + }; + + try { + setIsLoading(true); + const data = await createDebate(debatePayload); + const state = { ...data, phaseTimings, stance: finalStance }; + console.log("Navigation state:", state); + navigate(`/debate/${data.debateId}`, { state }); + } catch (error) { + console.error("Error starting debate:", error); + } finally { + setIsLoading(false); + } + } + }; + + return ( + <> + {isLoading && } +
+
+

+ Pick Your Debate Rival! +

+

+ Select a bot and set up your debate challenge. +

+
+ +
+ {/* Bot Selection Section */} +
+ {bots.map((bot) => ( +
setSelectedBot(bot.name)} + className={`z-20 relative cursor-pointer transition-transform duration-300 hover:scale-105 rounded-md border ${ + selectedBot === bot.name ? "border-2 border-primary" : "border border-gray-300" + } bg-white shadow-sm group overflow-visible`} // Added overflow-visible + style={{ height: "200px" }} + > +
+ {/* Avatar */} +
+ {bot.name} +
+ + {/* Chat Bubble - Moved outside avatar container */} +
+
+ {bot.quote} +
+ + + +
+
+
+ + {/* Bot Info */} +

{bot.name}

+

{bot.level}

+

{bot.rating}

+

+ {bot.desc} +

+
+
+ + ))} +
+ + {/* Debate Setup Section */} +
+
+

Debate Setup

+

+ Configure your topic, stance, and phase timings. +

+ {selectedBot && ( +
+ {getBotSpecialMessage(selectedBot)} +
+)} + +
+ +
+
+ {/* Topic Selection */} +
+ + + {topic === "custom" && ( + setCustomTopic(e.target.value)} + placeholder="Enter your custom topic" + className="mt-2 bg-white text-gray-800" + /> + )} +
+ + {/* Stance Selection */} +
+ + +
+
+ + {/* Responsive Timer Section */} +
+ +
+ {phaseTimings.map((phase, index) => ( +
+ {phase.name} + updatePhaseTiming(index, e.target.value)} + className="text-xs bg-white text-gray-800" + min="60" + max="600" + /> +
+ ))} +
+
+ + +
+
+
+
+ + ); +}; + +export default BotSelection; diff --git a/frontend/src/Pages/CoachPage.tsx b/frontend/src/Pages/CoachPage.tsx new file mode 100644 index 0000000..c3554a7 --- /dev/null +++ b/frontend/src/Pages/CoachPage.tsx @@ -0,0 +1,142 @@ +import React from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Link } from "react-router-dom"; +import { ArrowRight, BookOpen, Sparkles } from "lucide-react"; // Assuming Lucide icons are installed + +const CoachPage: React.FC = () => { + return ( +
+ {/* Hero Section */} +
+
+

+ AI Debate Coach +

+

+ Elevate your debating skills with personalized, AI-driven guidance designed to make you a master debater. +

+ + + +
+
+ + {/* Featured Learning Paths */} +
+
+

Featured Learning Paths

+
+ {/* Strengthen Argument Card */} + + + + + Strengthen Argument + + + +

+ Master the art of crafting compelling, persuasive arguments that win debates. +

+ + + +
+
+ + {/* Pros and Cons Challenge Card */} + + + + + Pros and Cons Challenge + + + +

+ Test your critical thinking by crafting up to 5 pros and cons for engaging debate topics. +

+ + + +
+
+
+
+
+ + {/* Upcoming Learning Paths */} +
+
+

More Learning Paths Coming Soon

+
+ {[ + { + title: "Rebuttal Techniques", + desc: "Learn to dismantle opponents' arguments with precision and confidence.", + }, + { + title: "Evidence Evaluation", + desc: "Assess and leverage evidence to bolster your debate performance.", + }, + { + title: "Debate Strategy", + desc: "Develop winning strategies to outsmart your opponents.", + }, + { + title: "Public Speaking Skills", + desc: "Enhance your delivery and captivate any audience.", + }, + ].map((path, index) => ( + + + {path.title} + + +

{path.desc}

+ Coming Soon +
+
+ ))} +
+
+
+ + {/* Footer */} +
+
+

Stay Updated

+

+ Subscribe to get the latest updates on new learning paths and features! +

+
+ + +
+

+ © 2025 ArgueHub. All rights reserved. +

+
+
+
+ ); +}; + +export default CoachPage; \ No newline at end of file diff --git a/frontend/src/Pages/DebateRoom.tsx b/frontend/src/Pages/DebateRoom.tsx new file mode 100644 index 0000000..17c0067 --- /dev/null +++ b/frontend/src/Pages/DebateRoom.tsx @@ -0,0 +1,599 @@ +import React, { useState, useEffect, useRef } from "react"; +import { useLocation } from "react-router-dom"; +import { Button } from "../components/ui/button"; +import { Input } from "../components/ui/input"; +import { sendDebateMessage, judgeDebate } from "@/services/vsbot"; +import JudgmentPopup from "@/components/JudgementPopup"; +import { Mic, MicOff } from "lucide-react"; + +type Message = { sender: "User" | "Bot" | "Judge"; text: string; phase: string }; + +type DebateProps = { + userId: string; + botName: string; + botLevel: string; + topic: string; + stance: string; + phaseTimings: { name: string; time: number }[]; + debateId: string; +}; + +type DebateState = { + messages: Message[]; + currentPhase: number; + phaseStep: number; + isBotTurn: boolean; + userStance: string; + botStance: string; + timer: number; + isDebateEnded: boolean; +}; + +type JudgmentData = { + opening_statement: { user: { score: number; reason: string }; bot: { score: number; reason: string } }; + cross_examination: { user: { score: number; reason: string }; bot: { score: number; reason: string } }; + answers: { user: { score: number; reason: string }; bot: { score: number; reason: string } }; + closing: { user: { score: number; reason: string }; bot: { score: number; reason: string } }; + total: { user: number; bot: number }; + verdict: { winner: string; reason: string; congratulations: string; opponent_analysis: string }; +}; + +const bots = [ + { name: "Rookie Rick", level: "Easy", desc: "A beginner who stumbles over logic.", avatar: "https://avatar.iran.liara.run/public/26" }, + { name: "Casual Casey", level: "Easy", desc: "Friendly but not too sharp.", avatar: "https://avatar.iran.liara.run/public/22" }, + { name: "Moderate Mike", level: "Medium", desc: "Balanced and reasonable.", avatar: "https://avatar.iran.liara.run/public/38" }, + { name: "Sassy Sarah", level: "Medium", desc: "Witty with decent arguments.", avatar: "https://avatar.iran.liara.run/public/78" }, + { name: "Innovative Iris", level: "Medium", desc: "A creative thinker", avatar: "https://avatar.iran.liara.run/public/72" }, + { name: "Tough Tony", level: "Hard", desc: "Logical and relentless.", avatar: "https://avatar.iran.liara.run/public/37" }, + { name: "Expert Emma", level: "Hard", desc: "Master of evidence and rhetoric.", avatar: "https://avatar.iran.liara.run/public/90" }, + { name: "Grand Greg", level: "Expert", desc: "Unbeatable debate titan.", avatar: "https://avatar.iran.liara.run/public/45" }, +]; + +const phaseSequences = [["For", "Against"], ["For", "Against", "Against", "For"], ["For", "Against"]]; +const turnTypes = [["statement", "statement"], ["question", "answer", "question", "answer"], ["statement", "statement"]]; + +const extractJSON = (response: string): string => { + const fenceRegex = /```(?:json)?\s*([\s\S]*?)\s*```/; + const match = fenceRegex.exec(response); + if (match && match[1]) return match[1].trim(); + return response; +}; + +const DebateRoom: React.FC = () => { + const location = useLocation(); + const debateData = location.state as DebateProps; + const phases = debateData.phaseTimings; + const debateKey = `debate_${debateData.userId}_${debateData.topic}_${debateData.debateId}`; + + const [state, setState] = useState(() => { + const savedState = localStorage.getItem(debateKey); + return savedState + ? JSON.parse(savedState) + : { + messages: [], + currentPhase: 0, + phaseStep: 0, + isBotTurn: false, + userStance: "", + botStance: "", + timer: phases[0].time, + isDebateEnded: false, + }; + }); + const [finalInput, setFinalInput] = useState(""); + const [interimInput, setInterimInput] = useState(""); + const [popup, setPopup] = useState<{ show: boolean; message: string; isJudging?: boolean }>({ show: false, message: "" }); + const [judgmentData, setJudgmentData] = useState(null); + const [showJudgment, setShowJudgment] = useState(false); + const [isRecognizing, setIsRecognizing] = useState(false); + const timerRef = useRef(null); + const botTurnRef = useRef(false); + const messagesEndRef = useRef(null); + const recognitionRef = useRef(null); + + const bot = bots.find((b) => b.name === debateData.botName) || bots[0]; + const userAvatar = "https://avatar.iran.liara.run/public/10"; + + const [isMuted, setIsMuted] = useState(false); + + const toggleMute = () => { + setIsMuted((prev) => !prev); + if (!isMuted) { + window.speechSynthesis.cancel(); // Stop ongoing speech when muting + } + }; + + // Initialize SpeechRecognition with debug logging + useEffect(() => { + if ("SpeechRecognition" in window || "webkitSpeechRecognition" in window) { + const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; + recognitionRef.current = new SpeechRecognition(); + recognitionRef.current.continuous = true; + recognitionRef.current.interimResults = true; + recognitionRef.current.lang = "en-US"; + + recognitionRef.current.onresult = (event) => { + console.log("Speech recognition result received:", event.results); + let newFinalTranscript = ""; + let newInterimTranscript = ""; + for (let i = event.resultIndex; i < event.results.length; i++) { + const result = event.results[i]; + if (result.isFinal) { + newFinalTranscript += result[0].transcript + " "; + } else { + newInterimTranscript = result[0].transcript; + } + } + console.log("Final Transcript:", newFinalTranscript); + console.log("Interim Transcript:", newInterimTranscript); + + if (newFinalTranscript) { + setFinalInput((prev) => (prev ? prev + " " + newFinalTranscript.trim() : newFinalTranscript.trim())); + setInterimInput(""); + } else { + setInterimInput(newInterimTranscript); + } + }; + + recognitionRef.current.onend = () => { + console.log("Speech recognition ended"); + setIsRecognizing(false); + }; + recognitionRef.current.onerror = (event) => { + console.error("Speech recognition error:", event.error); + setIsRecognizing(false); + }; + } else { + console.warn("Speech Recognition not supported in this browser."); + } + + return () => { + if (recognitionRef.current) recognitionRef.current.stop(); + }; + }, []); + + // Start/Stop Speech Recognition + const startRecognition = () => { + if (recognitionRef.current && !isRecognizing) { + console.log("Starting speech recognition..."); + recognitionRef.current.start(); + setIsRecognizing(true); + } + }; + + const stopRecognition = () => { + if (recognitionRef.current && isRecognizing) { + console.log("Stopping speech recognition..."); + recognitionRef.current.stop(); + setIsRecognizing(false); + } + }; + + // Text-to-Speech Function with Promise + const speak = (text: string): Promise => { + return new Promise((resolve) => { + if ("speechSynthesis" in window && !isMuted) { + window.speechSynthesis.cancel(); // Clear any ongoing speech + const utterance = new SpeechSynthesisUtterance(text); + utterance.lang = "en-US"; + utterance.onend = () => resolve(); + utterance.onerror = () => resolve(); + window.speechSynthesis.speak(utterance); + } else { + resolve(); + } + }); + }; + + // Cleanup Speech Synthesis on Unmount + useEffect(() => { + return () => { + window.speechSynthesis.cancel(); + }; + }, []); + + useEffect(() => { + localStorage.setItem(debateKey, JSON.stringify(state)); + }, [state, debateKey]); + + useEffect(() => { + return () => { + localStorage.removeItem(debateKey); + }; + }, [debateKey]); + + useEffect(() => { + if (!state.userStance) { + const stanceNormalized = + debateData.stance.toLowerCase() === "for" || debateData.stance.toLowerCase() === "against" + ? debateData.stance.toLowerCase() === "for" + ? "For" + : "Against" + : "For"; + setState((prev) => ({ + ...prev, + userStance: stanceNormalized, + botStance: stanceNormalized === "For" ? "Against" : "For", + isBotTurn: stanceNormalized === "Against", + })); + } + }, [state.userStance, debateData.stance]); + + useEffect(() => { + if (state.timer > 0 && !state.isDebateEnded) { + timerRef.current = setInterval(() => { + setState((prev) => { + if (prev.timer <= 1) { + clearInterval(timerRef.current!); + if (!prev.isBotTurn) { + // User's turn is up, send the message + sendMessage(); + } else { + // Bot's turn is up, just advance + advanceTurn(prev); + } + return { ...prev, timer: 0 }; + } + return { ...prev, timer: prev.timer - 1 }; + }); + }, 1000); + } + return () => { + if (timerRef.current) clearInterval(timerRef.current); + }; + }, [state.timer, state.isDebateEnded, state.isBotTurn, finalInput]); + + useEffect(() => { + if (state.isBotTurn && !state.isDebateEnded && !botTurnRef.current) { + botTurnRef.current = true; + handleBotTurn(); + } + }, [state.isBotTurn, state.currentPhase, state.phaseStep, state.isDebateEnded]); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [state.messages]); + + const getPhaseInstructions = (phaseIndex: number) => { + switch (phaseIndex) { + case 0: + return "Each side presents an opening statement."; + case 1: + return "Cross Examination: one side questions and the other answers, then vice versa."; + case 2: + return "Both sides deliver their closing statements."; + default: + return ""; + } + }; + + const advanceTurn = (currentState: DebateState) => { + const currentSequence = phaseSequences[currentState.currentPhase]; + if (currentState.phaseStep + 1 < currentSequence.length) { + const nextStep = currentState.phaseStep + 1; + const nextStance = currentSequence[nextStep]; + const nextEntity = currentState.userStance === nextStance ? "User" : "Bot"; + setState((prev) => ({ + ...prev, + phaseStep: nextStep, + isBotTurn: nextEntity === "Bot", + timer: phases[currentState.currentPhase].time, + })); + } else if (currentState.currentPhase < phases.length - 1) { + const newPhase = currentState.currentPhase + 1; + setPopup({ + show: true, + message: `${phases[currentState.currentPhase].name} completed. Next: ${phases[newPhase].name} - ${getPhaseInstructions(newPhase)}`, + }); + setTimeout(() => { + setPopup({ show: false, message: "" }); + setState((prevState) => ({ + ...prevState, + currentPhase: newPhase, + phaseStep: 0, + isBotTurn: prevState.userStance === phaseSequences[newPhase][0] ? false : true, + timer: phases[newPhase].time, + })); + }, 4000); + } else { + setPopup({ + show: true, + message: "Calculating scores and judging results...", + isJudging: true, + }); + setState((prev) => ({ ...prev, isDebateEnded: true })); + judgeDebateResult(currentState.messages); + } + }; + + const sendMessage = async () => { + if (!finalInput.trim() || state.isBotTurn || state.timer === 0) return; + + const newMessage: Message = { + sender: "User", + text: finalInput, + phase: phases[state.currentPhase].name, + }; + + setState((prev) => { + const updatedState = { + ...prev, + messages: [...prev.messages, newMessage], + timer: phases[prev.currentPhase].time, + }; + clearInterval(timerRef.current!); + advanceTurn(updatedState); + return updatedState; + }); + + setFinalInput(""); + setInterimInput(""); + if (isRecognizing) stopRecognition(); + }; + + const handleBotTurn = async () => { + try { + const turnType = turnTypes[state.currentPhase][state.phaseStep]; + let context = ""; + if (turnType === "statement") { + context = "Make your statement"; + } else if (turnType === "question") { + context = "Ask a clear and concise question challenging your opponent."; + } else if (turnType === "answer") { + const lastMessage = state.messages[state.messages.length - 1]; + context = lastMessage ? `Answer this question: ${lastMessage.text}` : "Provide your answer"; + } + + const { response } = await sendDebateMessage({ + botLevel: debateData.botLevel, + topic: debateData.topic, + history: state.messages, + botName: debateData.botName, + stance: state.botStance, + context, + }); + + const botMessage: Message = { + sender: "Bot", + text: response || "I need to think about that...", + phase: phases[state.currentPhase].name, + }; + + setState((prev) => ({ + ...prev, + messages: [...prev.messages, botMessage], + })); + + await speak(botMessage.text); + + setState((prev) => { + const updatedState = { + ...prev, + timer: phases[prev.currentPhase].time, + }; + clearInterval(timerRef.current!); + advanceTurn(updatedState); + return updatedState; + }); + } catch (error) { + console.error("Bot error:", error); + setState((prev) => { + advanceTurn(prev); + return prev; + }); + } finally { + botTurnRef.current = false; + } + }; + + const judgeDebateResult = async (messages: Message[]) => { + try { + const { result } = await judgeDebate({ + history: messages, + userId: debateData.userId, + }); + const jsonString = extractJSON(result); + const judgment: JudgmentData = JSON.parse(jsonString); + setJudgmentData(judgment); + setPopup({ show: false, message: "" }); + setShowJudgment(true); + } catch (error) { + console.error("Judging error:", error); + setJudgmentData({ + opening_statement: { user: { score: 0, reason: "Error" }, bot: { score: 0, reason: "Error" } }, + cross_examination: { user: { score: 0, reason: "Error" }, bot: { score: 0, reason: "Error" } }, + answers: { user: { score: 0, reason: "Error" }, bot: { score: 0, reason: "Error" } }, + closing: { user: { score: 0, reason: "Error" }, bot: { score: 0, reason: "Error" } }, + total: { user: 0, bot: 0 }, + verdict: { winner: "None", reason: "Judgment failed", congratulations: "", opponent_analysis: "" }, + }); + setPopup({ show: false, message: "" }); + setShowJudgment(true); + } + }; + + const formatTime = (seconds: number) => { + const timeStr = `${Math.floor(seconds / 60)}:${(seconds % 60).toString().padStart(2, "0")}`; + return ( + + {timeStr} + + ); + }; + + const renderPhaseMessages = (sender: "User" | "Bot") => { + const phaseMessages = state.messages.filter((msg) => msg.sender === sender); + return ( +
+ {phaseMessages.map((msg, idx) => ( +
+ {msg.phase} + {msg.text} +
+ ))} +
+
+ ); + }; + + const currentStance = phaseSequences[state.currentPhase][state.phaseStep]; + const currentEntity = state.userStance === currentStance ? "User" : "Bot"; + const currentTurnType = turnTypes[state.currentPhase][state.phaseStep]; + + return ( +
+
+
+

+ Debate: {debateData.topic} +

+

+ Phase: {phases[state.currentPhase]?.name || "Finished"} | Current Turn:{" "} + + {currentEntity === "User" ? "You" : debateData.botName} to{" "} + {currentTurnType === "statement" ? "make a statement" : currentTurnType === "question" ? "ask a question" : "answer"} + +

+
+
+ + {popup.show && ( +
+
+ {popup.isJudging ? ( +
+
+
+

{popup.message}

+
+
+ ) : ( + <> +

Phase Transition

+

{popup.message}

+ + )} +
+
+ )} + + {showJudgment && judgmentData && ( + setShowJudgment(false)} + /> + )} + +
+ {/* Bot Section */} +
+
+
+ {debateData.botName} +
+
+
{debateData.botName}
+
{bot.level}
+
{bot.desc}
+
+ +
+
+

Stance: {state.botStance}

+

+ Time: {formatTime(state.isBotTurn ? state.timer : phases[state.currentPhase]?.time || 0)} +

+ {renderPhaseMessages("Bot")} +
+
+ + {/* User Section */} +
+
+
+ You +
+
+
You
+
Debater
+
Ready to argue!
+
+
+
+

Stance: {state.userStance}

+

+ Time: {formatTime(!state.isBotTurn ? state.timer : phases[state.currentPhase]?.time || 0)} +

+
{renderPhaseMessages("User")}
+ {!state.isDebateEnded && ( +
+ !isRecognizing && setFinalInput(e.target.value)} + readOnly={isRecognizing} + disabled={state.isBotTurn || state.timer === 0} + placeholder={ + currentTurnType === "statement" + ? "Make your statement" + : currentTurnType === "question" + ? "Ask your question" + : "Provide your answer" + } + className="flex-1 border-gray-300 focus:border-orange-400 rounded-md text-sm" + /> + + +
+ )} +
+
+
+ + +
+ ); +}; + +export default DebateRoom; \ No newline at end of file diff --git a/frontend/src/Pages/Leaderboard.tsx b/frontend/src/Pages/Leaderboard.tsx new file mode 100644 index 0000000..f408055 --- /dev/null +++ b/frontend/src/Pages/Leaderboard.tsx @@ -0,0 +1,241 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { + Table, + TableHeader, + TableHead, + TableRow, + TableBody, + TableCell, +} from "@/components/ui/table"; +import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; +import { Card } from "@/components/ui/card"; +import { FaCrown, FaMedal, FaChessQueen } from "react-icons/fa"; +import { Button } from "@/components/ui/button"; +import { fetchLeaderboardData } from "@/services/leaderboardService"; + +interface Debater { + id: string; + currentUser: boolean; + rank: number; + avatarUrl: string; + name: string; + score: number; +} + +interface Stat { + icon: string; + value: number | string; + label: string; +} + +interface LeaderboardData { + debaters: Debater[]; + stats: Stat[]; +} + +const getRankClasses = (rank: number) => { + if (rank === 1) return "bg-amber-100 border-2 border-amber-300"; + if (rank === 2) return "bg-slate-100 border-2 border-slate-300"; + if (rank === 3) return "bg-orange-100 border-2 border-orange-300"; + return "bg-muted/20 text-muted-foreground"; +}; + +const mapIcon = (icon: string) => { + switch (icon) { + case "crown": + return ; + case "medal": + return ; + case "chessQueen": + return ; + default: + return ; + } +}; + +const Leaderboard: React.FC = () => { + const [visibleCount, setVisibleCount] = useState(5); + const [debaters, setDebaters] = useState([]); + const [stats, setStats] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const loadData = async () => { + try { + setLoading(true); + const token = localStorage.getItem("token"); + if (!token) return; + const data: LeaderboardData = await fetchLeaderboardData(token); + setDebaters(data.debaters); + setStats(data.stats); + } catch { + setError("Failed to load leaderboard data. Please try again later."); + } finally { + setLoading(false); + } + }; + + loadData(); + }, []); + + const currentUserIndex = debaters.findIndex((debater) => debater.currentUser); + + const getVisibleDebaters = () => { + if (!debaters.length) return []; + const initialList = debaters + .filter((debater, index) => !debater.currentUser || index < visibleCount) + .slice(0, visibleCount); + if (currentUserIndex !== -1 && currentUserIndex >= visibleCount) { + return [...initialList.slice(0, -1), debaters[currentUserIndex]]; + } + return initialList; + }; + + const showMore = () => + setVisibleCount((prev) => Math.min(prev + 5, debaters.length)); + + const visibleDebaters = getVisibleDebaters(); + + if (loading) return
Loading Leaderboard...
; + + if (error) { + return ( +
+

{error}

+
+ ); + } + + return ( +
+
+

+ Hone your skills and see how you stack up against top debaters! 🏆 +

+ +
+
+ + + + + + Rank + + + Debater + + + Score + + + + + {visibleDebaters.map((debater) => ( + + +
+ {debater.rank === 1 && ( + + )} + {debater.rank === 2 && ( + + )} + {debater.rank === 3 && ( + + )} + {debater.rank > 3 && ( + #{debater.rank} + )} +
+
+ +
+ + + + {debater.name.charAt(0)} + + +
+
+ {debater.name} +
+
+
+
+ +
+ + {debater.score} + +
+
+ + + ))} + +
+
+ + {visibleCount < debaters.length && ( +
+ +
+ )} +
+ +
+
+
+ {stats.map((stat, index) => ( +
+
+
+ {mapIcon(stat.icon)} +
+
+ {stat.value} +
+
+ {stat.label} +
+
+
+ ))} +
+

+ (Data fetched from backend) +

+
+
+
+
+
+ ); +}; + +export default Leaderboard; diff --git a/frontend/src/Pages/MatchLogs.tsx b/frontend/src/Pages/MatchLogs.tsx new file mode 100644 index 0000000..914b0a1 --- /dev/null +++ b/frontend/src/Pages/MatchLogs.tsx @@ -0,0 +1,210 @@ +import React from "react"; + +interface Score { + [key: string]: string; +} + +interface MatchLog { + match: string; + score?: Score; + timestamp: string; + duration: string; + viewers: number; +} + +const logs: MatchLog[] = [ + { + match: "First Round Match 1: Rishit Tiwari vs Aarav Singh", + score: { + opening: "10-8", + QA: "9-7", + closing: "9-9", + total: "28-24", + }, + timestamp: "April 21, 2025 - 8:00 AM", + duration: "25 mins", + viewers: 85, + }, + { + match: "First Round Match 2: Ishaan Mehta vs Vihaan Kapoor", + score: { + opening: "9-9", + QA: "8-8", + closing: "10-8", + total: "27-25", + }, + timestamp: "April 21, 2025 - 8:30 AM", + duration: "25 mins", + viewers: 90, + }, + { + match: "First Round Match 3: Ayaan Khanna vs Vivaan Sharma", + score: { + opening: "8-9", + QA: "9-8", + closing: "9-9", + total: "26-26", + }, + timestamp: "April 21, 2025 - 9:00 AM", + duration: "25 mins", + viewers: 80, + }, + { + match: "First Round Match 4: Devansh Joshi vs Kabir Malhotra", + score: { + opening: "9-10", + QA: "7-9", + closing: "8-9", + total: "24-28", + }, + timestamp: "April 21, 2025 - 9:30 AM", + duration: "25 mins", + viewers: 95, + }, + { + match: "Semifinal A: Rishit Tiwari vs Ayaan Khanna", + score: { + opening: "10-9", + QA: "9-8", + closing: "10-10", + total: "29-27", + }, + timestamp: "April 21, 2025 - 10:00 AM", + duration: "30 mins", + viewers: 123, + }, + { + match: "Semifinal B: Ishaan Mehta vs Kabir Malhotra", + score: { + opening: "9-10", + QA: "8-9", + closing: "10-9", + total: "27-28", + }, + timestamp: "April 21, 2025 - 11:00 AM", + duration: "30 mins", + viewers: 98, + }, + { + match: "Final: Rishit Tiwari vs Kabir Malhotra", + score: { + opening: "10-9", + QA: "9-9", + closing: "10-8", + total: "29-26", + }, + timestamp: "April 21, 2025 - 12:00 PM", + duration: "40 mins", + viewers: 156, + }, +]; + +const MatchLogs: React.FC = () => { + const getMatchDetails = (log: MatchLog) => { + const [player1, player2] = log.match.split(" vs "); + const stage = log.match.includes("First Round") + ? "First Round" + : log.match.includes("Semifinal") + ? "Semifinal" + : "Final"; + let winner = ""; + if (log.score && log.score.total) { + const [score1, score2] = log.score.total.split("-").map(Number); + if (score1 > score2) winner = player1.split(": ")[1]; + else if (score2 > score1) winner = player2; + else winner = stage === "First Round Match 3" ? "Ayaan Khanna (Tiebreaker)" : ""; + } + return { player1: player1.split(": ")[1] || player1, player2, stage, winner }; + }; + + return ( +
+

Match Logs

+
+ {[...logs].reverse().map((log, index) => { + const { player1, player2, stage, winner } = getMatchDetails(log); + return ( +
+
+
+ + {stage} + +
+
+

+ {player1} vs {player2} +

+ {log.timestamp} +
+
+
+
+
+ Category + {player1} + {player2} + {Object.entries(log.score || {}).map(([key, value]) => { + if (key === "total") return null; + const [score1, score2] = value.split("-"); + return ( + + {key} + parseInt(score2) ? "text-primary font-medium" : ""}>{score1} + parseInt(score1) ? "text-primary font-medium" : ""}>{score2} + + ); + })} + Total + parseInt(log.score.total.split("-")[1]) ? "text-primary" : "" + }`} + > + {log.score?.total.split("-")[0]} + + parseInt(log.score.total.split("-")[0]) ? "text-primary" : "" + }`} + > + {log.score?.total.split("-")[1]} + +
+ {stage === "First Round Match 3" && ( +

+ * Ayaan Khanna advanced via tiebreaker +

+ )} +
+
+
+ {winner && ( + <> + 🏆 + Winner: {winner} + + )} +
+
+ ⏱ {log.duration} + 👁 {log.viewers} viewers +
+
+
+ ); + })} +
+
+ ); +}; + +export default MatchLogs; diff --git a/frontend/src/Pages/OnlineDebateRoom.tsx b/frontend/src/Pages/OnlineDebateRoom.tsx new file mode 100644 index 0000000..1e0d469 --- /dev/null +++ b/frontend/src/Pages/OnlineDebateRoom.tsx @@ -0,0 +1,856 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { Button } from '../components/ui/button'; +import { Mic, MicOff } from 'lucide-react'; +import JudgmentPopup from '@/components/JudgementPopup'; + +// Utility function to get authentication token +const getAuthToken = (): string => { + return localStorage.getItem('token') || ''; +}; + +// Define debate phases as an enum +enum DebatePhase { + Setup = 'setup', + OpeningFor = 'openingFor', + OpeningAgainst = 'openingAgainst', + CrossForQuestion = 'crossForQuestion', + CrossAgainstAnswer = 'crossAgainstAnswer', + CrossAgainstQuestion = 'crossAgainstQuestion', + CrossForAnswer = 'crossForAnswer', + ClosingFor = 'closingFor', + ClosingAgainst = 'closingAgainst', + Finished = 'finished', +} + +// Define debate roles +type DebateRole = 'for' | 'against'; + +type JudgmentData = { + opening_statement: { for: { score: number; reason: string }; against: { score: number; reason: string } }; + cross_examination_questions: { for: { score: number; reason: string }; against: { score: number; reason: string } }; + cross_examination_answers: { for: { score: number; reason: string }; against: { score: number; reason: string } }; + closing: { for: { score: number; reason: string }; against: { score: number; reason: string } }; + total: { for: number; against: number }; + verdict: { winner: string; reason: string; congratulations: string; opponent_analysis: string }; +}; + +// Define WebSocket message structure +interface WSMessage { + type: string; + topic?: string; + role?: DebateRole; + ready?: boolean; + phase?: DebatePhase; + offer?: RTCSessionDescriptionInit; + answer?: RTCSessionDescriptionInit; + candidate?: RTCIceCandidateInit; + message?: string; +} + +// Define message structure +type Message = { sender: DebateRole; text: string; phase: DebatePhase }; + +// Define phase durations in seconds +const phaseDurations: { [key in DebatePhase]?: number } = { + [DebatePhase.OpeningFor]: 60, + [DebatePhase.OpeningAgainst]: 60, + [DebatePhase.CrossForQuestion]: 30, + [DebatePhase.CrossAgainstAnswer]: 30, + [DebatePhase.CrossAgainstQuestion]: 30, + [DebatePhase.CrossForAnswer]: 30, + [DebatePhase.ClosingFor]: 45, + [DebatePhase.ClosingAgainst]: 45, +}; + +const localAvatar = localStorage.getItem('userAvatar') || 'https://avatar.iran.liara.run/public/40'; // Default fallback +const opponentAvatar = localStorage.getItem('opponentAvatar') || 'https://avatar.iran.liara.run/public/31'; // Default fallback + +// Function to extract JSON from response +const extractJSON = (response: string): string => { + const fenceRegex = /```(?:json)?\s*([\s\S]*?)\s*```/; + const match = fenceRegex.exec(response); + if (match && match[1]) return match[1].trim(); + return response; +}; + +const OnlineDebateRoom: React.FC = () => { + const { roomId } = useParams<{ roomId: string }>(); + + // Refs for WebSocket, PeerConnection, and media elements + const wsRef = useRef(null); + const pcRef = useRef(null); + const localVideoRef = useRef(null); + const remoteVideoRef = useRef(null); + const recognitionRef = useRef(null); + const timerRef = useRef(null); + + // State for debate setup and signaling + const [topic, setTopic] = useState(''); + const [localRole, setLocalRole] = useState(null); + const [peerRole, setPeerRole] = useState(null); + const [localReady, setLocalReady] = useState(false); + const [peerReady, setPeerReady] = useState(false); + const [debatePhase, setDebatePhase] = useState(DebatePhase.Setup); + + // State for media streams + const [localStream, setLocalStream] = useState(null); + const [remoteStream, setRemoteStream] = useState(null); + const [mediaError, setMediaError] = useState(null); + + // Timer state + const [timer, setTimer] = useState(0); + + // Speech recognition and transcript state + const [messages, setMessages] = useState([]); + const [finalInput, setFinalInput] = useState(''); + const [interimInput, setInterimInput] = useState(''); + const [isRecognizing, setIsRecognizing] = useState(false); + + // Popup and countdown state + const [showSetupPopup, setShowSetupPopup] = useState(true); + const [countdown, setCountdown] = useState(null); + + // Phase-wise transcripts + const [transcripts, setTranscripts] = useState<{ + [key in DebatePhase]?: { [key in DebateRole]?: string }; + }>({}); + + // Judgment states + const [popup, setPopup] = useState<{ show: boolean; message: string; isJudging?: boolean }>({ show: false, message: "" }); + const [judgmentData, setJudgmentData] = useState(null); + const [showJudgment, setShowJudgment] = useState(false); + + // Ordered list of debate phases + const phaseOrder: DebatePhase[] = [ + DebatePhase.OpeningFor, + DebatePhase.OpeningAgainst, + DebatePhase.CrossForQuestion, + DebatePhase.CrossAgainstAnswer, + DebatePhase.CrossAgainstQuestion, + DebatePhase.CrossForAnswer, + DebatePhase.ClosingFor, + DebatePhase.ClosingAgainst, + DebatePhase.Finished, + ]; + + // Determine if it's the local user's turn to speak + const isMyTurn = localRole === (debatePhase.includes('For') ? 'for' : debatePhase.includes('Against') ? 'against' : null); + + // Function to send transcripts to backend + const sendTranscriptsToBackend = async (roomId: string, role: DebateRole, transcripts: { [key in DebatePhase]?: string }) => { + const token = getAuthToken(); + console.log("-----------------------------------------------------------------------"); + console.log(`Attempting to send transcripts for role: ${role}`, { roomId, transcripts }); + try { + const response = await fetch(`http://localhost:1313/api/submit-transcripts`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ + roomId, + role, + transcripts, + }), + }); + if (!response.ok) { + throw new Error(`Failed to send transcripts: ${response.status} ${response.statusText}`); + } + const result = await response.json(); + console.log(`Response from backend for ${role}:`, result); + + if (result.message === "Waiting for opponent submission") { + // Poll for the result periodically until judgment is available + const pollResult = async () => { + const pollInterval = setInterval(async () => { + const pollResponse = await fetch(`http://localhost:1313/api/submit-transcripts`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ roomId, role, transcripts: {} }), // Empty transcripts to just check result + }); + const pollData = await pollResponse.json(); + if (pollData.message === "Debate judged" || pollData.message === "Debate already judged") { + clearInterval(pollInterval); + const jsonString = extractJSON(pollData.result); + const judgment: JudgmentData = JSON.parse(jsonString); + setJudgmentData(judgment); + setPopup({ show: false, message: "" }); + setShowJudgment(true); + } + }, 2000); // Poll every 2 seconds + }; + pollResult(); + return null; // Return null to indicate waiting + } else if (result.message === "Debate judged" || result.message === "Debate already judged") { + const jsonString = extractJSON(result.result); + const judgment: JudgmentData = JSON.parse(jsonString); + return judgment; + } + } catch (error) { + console.error(`Error submitting transcripts for ${role}:`, error); + throw error; + } + }; + + // Log message history, collect transcripts, and send to backend + const logMessageHistory = async () => { + if (!localRole) { + console.log("Cannot log message history: localRole is not defined yet."); + setPopup({ show: true, message: "Please select a role before the debate ends.", isJudging: false }); + return; + } + + console.log(`logMessageHistory called for role: ${localRole}`); + console.log('Debate Message History:'); + const debateTranscripts: { [key in DebatePhase]?: string } = {}; + + const phasesForRole = localRole === 'for' + ? [DebatePhase.OpeningFor, DebatePhase.CrossForQuestion, DebatePhase.CrossForAnswer, DebatePhase.ClosingFor] + : [DebatePhase.OpeningAgainst, DebatePhase.CrossAgainstAnswer, DebatePhase.CrossAgainstQuestion, DebatePhase.ClosingAgainst]; + + phasesForRole.forEach((phase) => { + const transcript = localStorage.getItem(`${roomId}_${phase}_${localRole}`) || 'No response'; + debateTranscripts[phase] = transcript; + }); + console.log(`Collected transcripts for ${localRole}:`, debateTranscripts); + + setPopup({ show: true, message: "Submitting transcripts and awaiting judgment...", isJudging: true }); + + if (roomId && localRole) { + try { + console.log(`Sending transcripts to backend for ${localRole}`); + const judgment = await sendTranscriptsToBackend(roomId, localRole, debateTranscripts); + if (judgment) { + setJudgmentData(judgment); + setPopup({ show: false, message: "" }); + setShowJudgment(true); + } // If null, polling is already handling the wait + } catch (error) { + console.error(`Failed to send transcripts to backend for ${localRole}:`, error); + setPopup({ show: false, message: "Error occurred while judging. Please try again." }); + } + } else { + console.log(`Cannot send transcripts. roomId: ${roomId}, localRole: ${localRole}`); + setPopup({ show: false, message: "" }); + } + }; + + // Set timer based on phase duration + useEffect(() => { + if (phaseDurations[debatePhase]) { + setTimer(phaseDurations[debatePhase]!); + } else { + setTimer(0); + } + }, [debatePhase]); + + // Timer countdown and phase transition + useEffect(() => { + if (timer > 0 && debatePhase !== DebatePhase.Finished) { + timerRef.current = setInterval(() => { + setTimer((prev) => { + if (prev <= 1) { + clearInterval(timerRef.current!); + if (isMyTurn && localRole) { // Added localRole check + const transcriptToSave = finalInput.trim() || 'No response'; + setTranscripts((prev) => { + const phaseTranscripts = prev[debatePhase] || {}; + const roleTranscripts = phaseTranscripts[localRole] || ''; + return { + ...prev, + [debatePhase]: { + ...phaseTranscripts, + [localRole]: roleTranscripts + ' ' + transcriptToSave, + }, + }; + }); + localStorage.setItem(`${roomId}_${debatePhase}_${localRole}`, transcriptToSave); + console.log(`Timer expired for ${localRole} in ${debatePhase}. Transcript saved:`, transcriptToSave); + if (finalInput.trim()) { + sendMessage(); + } + } + handlePhaseDone(); + return 0; + } + return prev - 1; + }); + }, 1000); + } + return () => { + if (timerRef.current) clearInterval(timerRef.current); + }; + }, [timer, debatePhase, isMyTurn, finalInput, localRole, roomId]); + + // Initialize WebSocket, RTCPeerConnection, and media + useEffect(() => { + const token = getAuthToken(); + if (!token || !roomId) return; + + const ws = new WebSocket(`ws://localhost:1313/ws?room=${roomId}&token=${token}`); + wsRef.current = ws; + + ws.onopen = () => { + console.log('WebSocket connected'); + ws.send(JSON.stringify({ type: 'join', room: roomId })); + getMedia(); + }; + + ws.onmessage = async (event) => { + const data: WSMessage = JSON.parse(event.data); + switch (data.type) { + case 'topicChange': + if (data.topic !== undefined) setTopic(data.topic); + break; + case 'roleSelection': + if (data.role) setPeerRole(data.role); + break; + case 'ready': + if (data.ready !== undefined) setPeerReady(data.ready); + break; + case 'phaseChange': + if (data.phase) { + console.log(`Received phase change to ${data.phase}. Local role: ${localRole}`); + setDebatePhase(data.phase); + } + break; + case 'message': + if (data.message && peerRole) { + setMessages((prev) => [ + ...prev, + { sender: peerRole, text: data.message, phase: debatePhase }, + ]); + setTranscripts((prev) => { + const phaseTranscripts = prev[debatePhase] || {}; + const roleTranscripts = phaseTranscripts[peerRole] || ''; + return { + ...prev, + [debatePhase]: { + ...phaseTranscripts, + [peerRole]: roleTranscripts + ' ' + data.message, + }, + }; + }); + } + break; + case 'offer': + if (pcRef.current) { + await pcRef.current.setRemoteDescription(data.offer!); + const answer = await pcRef.current.createAnswer(); + await pcRef.current.setLocalDescription(answer); + wsRef.current?.send(JSON.stringify({ type: 'answer', answer })); + } + break; + case 'answer': + if (pcRef.current) await pcRef.current.setRemoteDescription(data.answer!); + break; + case 'candidate': + if (pcRef.current) await pcRef.current.addIceCandidate(data.candidate!); + break; + } + }; + + ws.onerror = (err) => console.error('WebSocket error:', err); + ws.onclose = () => console.log('WebSocket closed'); + + const pc = new RTCPeerConnection({ + iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], + }); + pcRef.current = pc; + + pc.onicecandidate = (event) => { + if (event.candidate && wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: 'candidate', candidate: event.candidate })); + } + }; + + pc.ontrack = (event) => { + setRemoteStream(event.streams[0]); + }; + + const getMedia = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { width: 1280, height: 720 }, + audio: true, + }); + setLocalStream(stream); + stream.getTracks().forEach((track) => pc.addTrack(track, stream)); + } catch (err) { + setMediaError('Failed to access camera/microphone. Please check permissions.'); + console.error('Media error:', err); + } + }; + + return () => { + if (localStream) localStream.getTracks().forEach((track) => track.stop()); + ws.close(); + pc.close(); + }; + }, [roomId]); + + // Attach streams to video elements + useEffect(() => { + if (localVideoRef.current && localStream) { + localVideoRef.current.srcObject = localStream; + localVideoRef.current.play().catch((err) => console.error('Error playing local video:', err)); + } + if (remoteVideoRef.current && remoteStream) { + remoteVideoRef.current.srcObject = remoteStream; + remoteVideoRef.current.play().catch((err) => console.error('Error playing remote video:', err)); + } + }, [localStream, remoteStream]); + + // Initialize SpeechRecognition + useEffect(() => { + if ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window) { + const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; + recognitionRef.current = new SpeechRecognition(); + recognitionRef.current.continuous = true; + recognitionRef.current.interimResults = true; + recognitionRef.current.lang = 'en-US'; + + recognitionRef.current.onresult = (event) => { + let newFinalTranscript = ''; + let newInterimTranscript = ''; + for (let i = event.resultIndex; i < event.results.length; i++) { + const result = event.results[i]; + if (result.isFinal) { + newFinalTranscript += result[0].transcript + ' '; + } else { + newInterimTranscript = result[0].transcript; + } + } + if (newFinalTranscript) { + setFinalInput((prev) => (prev ? prev + ' ' + newFinalTranscript.trim() : newFinalTranscript.trim())); + setInterimInput(''); + } else { + setInterimInput(newInterimTranscript); + } + }; + + recognitionRef.current.onend = () => setIsRecognizing(false); + recognitionRef.current.onerror = (event) => { + console.error('Speech recognition error:', event.error); + setIsRecognizing(false); + }; + } + return () => { + if (recognitionRef.current) recognitionRef.current.stop(); + }; + }, []); + + // Start/Stop Speech Recognition + const startRecognition = () => { + if (recognitionRef.current && !isRecognizing && isMyTurn) { + try { + recognitionRef.current.start(); + setIsRecognizing(true); + } catch (error) { + console.error('Error starting recognition:', error); + setIsRecognizing(false); + } + } + }; + + const stopRecognition = () => { + if (recognitionRef.current && isRecognizing) { + recognitionRef.current.stop(); + setIsRecognizing(false); + } + }; + + // Auto-start/stop recognition based on turn + useEffect(() => { + if (isMyTurn && debatePhase !== DebatePhase.Setup && debatePhase !== DebatePhase.Finished) { + startRecognition(); + } else { + stopRecognition(); + } + }, [isMyTurn, debatePhase]); + + // Send message (transcript) + const sendMessage = () => { + if (!finalInput.trim() || !isMyTurn || timer === 0 || !localRole) return; + + const newMessage: Message = { + sender: localRole, + text: finalInput, + phase: debatePhase, + }; + + setMessages((prev) => [...prev, newMessage]); + wsRef.current?.send(JSON.stringify({ type: 'message', message: finalInput })); + + setTranscripts((prev) => { + const phaseTranscripts = prev[debatePhase] || {}; + const roleTranscripts = phaseTranscripts[localRole] || ''; + return { + ...prev, + [debatePhase]: { + ...phaseTranscripts, + [localRole]: roleTranscripts + ' ' + finalInput.trim(), + }, + }; + }); + + localStorage.setItem(`${roomId}_${debatePhase}_${localRole}`, finalInput.trim()); + console.log(`Message sent by ${localRole} in ${debatePhase}:`, finalInput.trim()); + + setFinalInput(''); + setInterimInput(''); + if (isRecognizing) stopRecognition(); + }; + + // Handle phase completion + const handlePhaseDone = () => { + const currentIndex = phaseOrder.indexOf(debatePhase); + console.log(`handlePhaseDone called for ${localRole}. Current phase: ${debatePhase}, Index: ${currentIndex}`); + if (currentIndex >= 0 && currentIndex < phaseOrder.length - 1) { + const nextPhase = phaseOrder[currentIndex + 1]; + console.log(`Transitioning to next phase: ${nextPhase} for role: ${localRole}`); + setDebatePhase(nextPhase); + wsRef.current?.send(JSON.stringify({ type: 'phaseChange', phase: nextPhase })); + } else if (!localRole || !peerRole) { + console.log("Cannot finish debate: Both roles must be selected."); + setPopup({ show: true, message: "Both debaters must select roles to finish the debate." }); + } else { + console.log(`Debate finished for ${localRole}`); + } + }; + + // Trigger logMessageHistory when debatePhase changes to Finished + useEffect(() => { + if (debatePhase === DebatePhase.Finished && localRole) { + logMessageHistory(); + } + }, [debatePhase, localRole]); + + // Handlers for user actions + const handleTopicChange = (e: React.ChangeEvent) => { + const newTopic = e.target.value; + setTopic(newTopic); + wsRef.current?.send(JSON.stringify({ type: 'topicChange', topic: newTopic })); + }; + + const handleRoleSelection = (role: DebateRole) => { + if (peerRole === role) { + alert(`Your opponent already chose "${role}". Please select the other side.`); + return; + } + setLocalRole(role); + wsRef.current?.send(JSON.stringify({ type: 'roleSelection', role })); + console.log(`Role selected: ${role}`); + }; + + const toggleReady = () => { + const newReadyState = !localReady; + setLocalReady(newReadyState); + wsRef.current?.send(JSON.stringify({ type: 'ready', ready: newReadyState })); + console.log(`Ready toggled to ${newReadyState} for ${localRole}`); + }; + + // Manage setup popup visibility + useEffect(() => { + if (localReady && peerReady) { + setShowSetupPopup(false); + setCountdown(3); + console.log(`Both ready. Starting countdown for ${localRole}`); + } else { + setShowSetupPopup(true); + } + }, [localReady, peerReady]); + + // Countdown logic + useEffect(() => { + if (countdown !== null && countdown > 0) { + const timer = setTimeout(() => setCountdown(countdown - 1), 1000); + return () => clearTimeout(timer); + } else if (countdown === 0) { + setDebatePhase(DebatePhase.OpeningFor); + wsRef.current?.send(JSON.stringify({ type: 'phaseChange', phase: DebatePhase.OpeningFor })); + console.log(`Countdown finished. Starting debate at ${DebatePhase.OpeningFor} for ${localRole}`); + if (localRole === 'for') { + pcRef.current + ?.createOffer() + .then((offer) => pcRef.current!.setLocalDescription(offer).then(() => offer)) + .then((offer) => wsRef.current?.send(JSON.stringify({ type: 'offer', offer }))) + .catch((err) => console.error('Error creating offer:', err)); + } + } + }, [countdown, localRole]); + + // Clear input fields on phase change + useEffect(() => { + setFinalInput(''); + setInterimInput(''); + }, [debatePhase]); + + const formatTime = (seconds: number) => { + const timeStr = `${Math.floor(seconds / 60)}:${(seconds % 60).toString().padStart(2, '0')}`; + return ( + + {timeStr} + + ); + }; + + // Render UI + return ( +
+
+
+

Debate: {topic || 'No topic set'}

+

+ Phase: {debatePhase} | Current Turn:{' '} + + {isMyTurn ? 'You' : 'Opponent'} to{' '} + {debatePhase.includes('Question') ? 'ask a question' : debatePhase.includes('Answer') ? 'answer' : 'make a statement'} + +

+
+
+ + {/* Setup Popup */} + {showSetupPopup && ( +
+
+ {/* Header with title and close icon */} +
+

Debate Setup

+
+ {/* Debate Topic */} +
+ + +
+ {/* Avatars and Role Selection */} +
+ {/* Your Avatar and Role Selection */} +
+
+ You +
+
+
+ + +
+
+ {localRole ? (localRole === "for" ? "For" : "Against") : "Not selected"} +
+
+ {/* Opponent Avatar */} +
+
+ Opponent +
+
+
+ {peerRole ? (peerRole === "for" ? "For" : "Against") : "Not selected"} +
+
+
+ {/* Ready Button */} +
+ +
+
+
+ )} + + {/* Countdown Popup */} + {countdown !== null && countdown > 0 && ( +
+
+

Debate starting in {countdown}

+
+
+ )} + + {/* Judging Popup */} + {popup.show && ( +
+
+ {popup.isJudging ? ( +
+
+

{popup.message}

+
+ ) : ( + <> +

Phase Transition

+

{popup.message}

+ + )} +
+
+ )} + + {/* Judgment Popup */} + {showJudgment && judgmentData && ( + setShowJudgment(false)} + /> + )} + +
+ {/* Local User Section */} +
+
+
+ You +
+
+
You
+
Role: {localRole || 'Not selected'}
+
+
+
+

Stance: {localRole}

+

Time: {formatTime(isMyTurn ? timer : phaseDurations[debatePhase] || 0)}

+
+
+ + {/* Remote User Section */} +
+
+
+ Opponent +
+
+
Opponent
+
Role: {peerRole || 'Not selected'}
+
+
+
+

Stance: {peerRole}

+

Time: {formatTime(!isMyTurn ? timer : phaseDurations[debatePhase] || 0)}

+
+
+
+ + {/* Media Error Display */} + {mediaError &&

{mediaError}

} + + +
+ ); +}; + +export default OnlineDebateRoom; \ No newline at end of file diff --git a/frontend/src/Pages/Profile.tsx b/frontend/src/Pages/Profile.tsx new file mode 100644 index 0000000..dcfcbe8 --- /dev/null +++ b/frontend/src/Pages/Profile.tsx @@ -0,0 +1,472 @@ +// src/components/Profile.tsx +"use client"; + +import React, { useState, useEffect } from "react"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/text-area"; +import { Separator } from "@/components/ui/separator"; +import defaultAvatar from "@/assets/avatar2.jpg"; +import { + CheckCircle, + XCircle, + MinusCircle, + Medal, + Twitter, +} from "lucide-react"; +import { + PieChart, + Pie, + ResponsiveContainer, + LineChart, + Line, + LabelList, +} from "recharts"; +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { getProfile, updateProfile } from "@/services/profileService"; +import { getAuthToken } from "@/utils/auth"; + +interface ProfileData { + displayName: string; + email: string; + bio: string; + eloRating: number; + twitter?: string; + avatarUrl?: string; +} + +interface LeaderboardEntry { + rank: number; + name: string; + score: number; + avatarUrl: string; + currentUser?: boolean; +} + +interface DebateResult { + topic: string; + result: "win" | "loss" | "draw"; + eloChange: number; +} + +interface StatData { + wins: number; + losses: number; + draws: number; + eloHistory: { month: string; elo: number }[]; +} + +interface DashboardData { + profile: ProfileData; + leaderboard: LeaderboardEntry[]; + debateHistory: DebateResult[]; + stats: StatData; +} + +const Profile: React.FC = () => { + const [dashboard, setDashboard] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const [successMessage, setSuccessMessage] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchDashboard = async () => { + const token = getAuthToken(); + if (!token) { + setErrorMessage("Please log in to view your profile."); + setLoading(false); + return; + } + + try { + const data = await getProfile(token); + setDashboard(data); + } catch (err) { + setErrorMessage("Failed to load dashboard data."); + console.error(err); + } finally { + setLoading(false); + } + }; + + fetchDashboard(); + }, []); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!dashboard?.profile) return; + const token = getAuthToken(); + if (!token) { + setErrorMessage("Authentication token is missing."); + return; + } + try { + await updateProfile( + token, + dashboard.profile.displayName, + dashboard.profile.bio + ); + setSuccessMessage("Profile updated successfully!"); + setErrorMessage(""); + setIsEditing(false); + } catch (err) { + setErrorMessage("Failed to update profile."); + console.error(err); + } + }; + + if (loading) { + return
Loading Profile...
; + } + + if (!dashboard) { + return
{errorMessage}
; + } + + const { profile, leaderboard, debateHistory, stats } = dashboard; + + const donutChartData = [ + { label: "Losses", value: stats.losses, fill: "hsl(var(--chart-1))" }, + { label: "Wins", value: stats.wins, fill: "hsl(var(--chart-2))" }, + { label: "Draws", value: stats.draws, fill: "hsl(var(--chart-3))" }, + ]; + const totalMatches = donutChartData.reduce( + (acc, curr) => acc + curr.value, + 0 + ); + + const donutChartConfig: ChartConfig = { + value: { label: "Matches" }, + wins: { label: "Wins", color: "hsl(var(--chart-2))" }, + losses: { label: "Losses", color: "hsl(var(--chart-1))" }, + draws: { label: "Draws", color: "hsl(var(--chart-3))" }, + }; + + const eloChartConfig: ChartConfig = { + elo: { label: "Elo", color: "hsl(var(--primary))" }, + }; + + return ( +
+ {/* Left Column: Profile Details */} +
+ {successMessage && ( +
+ {successMessage} +
+ )} + {errorMessage && ( +
+ {errorMessage} +
+ )} +
+
+ Avatar +
+

+ {profile.displayName} +

+

+ Elo: {profile.eloRating} +

+
+ +

+ Email: {profile.email} +

+ {profile.twitter && !isEditing && ( + + @{profile.twitter} + + )} + {!isEditing ? ( + <> +

+ {profile.bio} +

+ + + ) : ( +
+
+ + + setDashboard({ + ...dashboard, + profile: { ...profile, displayName: e.target.value }, + }) + } + className="mt-1" + /> +
+
+ +