diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 6be5702..8a2a872 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,43 @@ 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) + // 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 +62,31 @@ 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("/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) + } return router -} \ No newline at end of file +} diff --git a/backend/config/config.go b/backend/config/config.go index a71fe8b..a023ac6 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -2,37 +2,47 @@ 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"` } +// 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..a5b2396 100644 --- a/backend/config/config.prod.yml +++ b/backend/config/config.prod.yml @@ -1,12 +1,17 @@ server: - port: 1313 + port: 1313 cognito: - appClientId: - appClientSecret: - userPoolId: - region: + appClientId: + appClientSecret: + userPoolId: + region: eu-north-1 openai: - gptApiKey: - \ No newline at end of file + gptApiKey: + +database: + uri: "" + +gemini: + apiKey: "" \ No newline at end of file diff --git a/backend/controllers/auth.go b/backend/controllers/auth.go index 25595a8..fc6a06e 100644 --- a/backend/controllers/auth.go +++ b/backend/controllers/auth.go @@ -1,18 +1,27 @@ package controllers import ( - "arguehub/config" - "arguehub/structs" - "arguehub/utils" + "context" "fmt" "log" + "net/http" "os" "strings" + "time" + + "arguehub/config" + "arguehub/db" + "arguehub/models" + "arguehub/structs" + "arguehub/utils" + "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" - "github.com/gin-gonic/gin" "github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider/types" + "github.com/gin-gonic/gin" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" ) func SignUp(ctx *gin.Context) { @@ -65,17 +74,67 @@ func Login(ctx *gin.Context) { var request structs.LoginRequest if err := ctx.ShouldBindJSON(&request); err != nil { - ctx.JSON(400, gin.H{"error": "Invalid input", "message": "Check email and password format"}) + ctx.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid input", + "message": "Check email and password format", + }) return } - token, err := loginWithCognito(cfg.Cognito.AppClientId, cfg.Cognito.AppClientSecret, request.Email, request.Password, ctx) + // 1) Attempt Cognito login + token, err := loginWithCognito( + cfg.Cognito.AppClientId, + cfg.Cognito.AppClientSecret, + request.Email, + request.Password, + ctx, + ) if err != nil { - ctx.JSON(401, gin.H{"error": "Failed to sign in", "message": "Invalid email or password"}) + ctx.JSON(http.StatusUnauthorized, gin.H{ + "error": "Failed to sign in", + "message": "Invalid email or password", + }) return } - ctx.JSON(200, gin.H{"message": "Sign-in successful", "accessToken": token}) + // 2) After successful Cognito login, check if user exists in MongoDB + dbCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var existingUser models.User + findErr := db.MongoDatabase.Collection("users"). + FindOne(dbCtx, bson.M{"email": request.Email}). + Decode(&existingUser) + + if findErr != nil { + if findErr == mongo.ErrNoDocuments { + // User does NOT exist in the database. Creating a new user with default Elo=1200. + newUser := models.User{ + Email: request.Email, + EloRating: 1200, + } + + _, insertErr := db.MongoDatabase.Collection("users").InsertOne(dbCtx, newUser) + if insertErr != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to create user in database", + "message": insertErr.Error(), + }) + return + } + + } else { + ctx.JSON(http.StatusInternalServerError, gin.H{ + "error": "Database error", + "message": findErr.Error(), + }) + return + } + } + ctx.JSON(http.StatusOK, gin.H{ + "message": "Sign-in successful", + "accessToken": token, + }) } func ForgotPassword(ctx *gin.Context) { @@ -168,7 +227,12 @@ func loadConfig(ctx *gin.Context) *config.Config { } func signUpWithCognito(appClientId, appClientSecret, email, password string, ctx *gin.Context) error { - config, err := awsConfig.LoadDefaultConfig(ctx, awsConfig.WithRegion("ap-south-1")) + cfg := loadConfig(ctx) + if cfg == nil { + return fmt.Errorf("failed to load configuration") + } + + config, err := awsConfig.LoadDefaultConfig(ctx, awsConfig.WithRegion(cfg.Cognito.Region)) if err != nil { log.Println("Error loading AWS config:", err) return fmt.Errorf("failed to load AWS config: %v", err) @@ -206,9 +270,13 @@ func signUpWithCognito(appClientId, appClientSecret, email, password string, ctx } func verifyEmailWithCognito(appClientId, appClientSecret, email, confirmationCode string, ctx *gin.Context) error { - config, err := awsConfig.LoadDefaultConfig(ctx, awsConfig.WithRegion("ap-south-1")) + cfg := loadConfig(ctx) + if cfg == nil { + return fmt.Errorf("failed to load configuration") + } + + config, err := awsConfig.LoadDefaultConfig(ctx, awsConfig.WithRegion(cfg.Cognito.Region)) if err != nil { - log.Println("Error loading AWS config:", err) return fmt.Errorf("failed to load AWS config: %v", err) } @@ -234,7 +302,12 @@ func verifyEmailWithCognito(appClientId, appClientSecret, email, confirmationCod } func loginWithCognito(appClientId, appClientSecret, email, password string, ctx *gin.Context) (string, error) { - config, err := awsConfig.LoadDefaultConfig(ctx, awsConfig.WithRegion("ap-south-1")) + cfg := loadConfig(ctx) + if cfg == nil { + return "", fmt.Errorf("failed to load configuration") + } + + config, err := awsConfig.LoadDefaultConfig(ctx, awsConfig.WithRegion(cfg.Cognito.Region)) if err != nil { return "", fmt.Errorf("failed to load AWS config") } @@ -261,7 +334,12 @@ func loginWithCognito(appClientId, appClientSecret, email, password string, ctx } func initiateForgotPassword(appClientId, appClientSecret, email string, ctx *gin.Context) (*cognitoidentityprovider.ForgotPasswordOutput, error) { - config, err := awsConfig.LoadDefaultConfig(ctx, awsConfig.WithRegion("ap-south-1")) + cfg := loadConfig(ctx) + if cfg == nil { + return nil, fmt.Errorf("failed to load configuration") + } + + config, err := awsConfig.LoadDefaultConfig(ctx, awsConfig.WithRegion(cfg.Cognito.Region)) if err != nil { return nil, fmt.Errorf("failed to load AWS config") } @@ -284,7 +362,12 @@ func initiateForgotPassword(appClientId, appClientSecret, email string, ctx *gin } func confirmForgotPassword(appClientId, appClientSecret, email, code, newPassword string, ctx *gin.Context) (*cognitoidentityprovider.ConfirmForgotPasswordOutput, error) { - config, err := awsConfig.LoadDefaultConfig(ctx, awsConfig.WithRegion("ap-south-1")) + cfg := loadConfig(ctx) + if cfg == nil { + return nil, fmt.Errorf("failed to load configuration") + } + + config, err := awsConfig.LoadDefaultConfig(ctx, awsConfig.WithRegion(cfg.Cognito.Region)) if err != nil { return nil, fmt.Errorf("failed to load AWS config") } @@ -307,22 +390,28 @@ func confirmForgotPassword(appClientId, appClientSecret, email, code, newPasswor return output, nil } - 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") + cfg := loadConfig(ctx) + if cfg == nil { + return false, fmt.Errorf("failed to load configuration") } + config, err := awsConfig.LoadDefaultConfig(ctx, awsConfig.WithRegion(cfg.Cognito.Region)) + if err != nil { + return false, fmt.Errorf("failed to load AWS config: %v", err) + } cognitoClient := cognitoidentityprovider.NewFromConfig(config) - _, err = cognitoClient.GetUser(ctx, &cognitoidentityprovider.GetUserInput{ - AccessToken: aws.String(token), + getUserOutput, err := cognitoClient.GetUser(ctx, &cognitoidentityprovider.GetUserInput{ + AccessToken: &token, }) if err != nil { - log.Println("Token verification failed:", err) return false, fmt.Errorf("token validation failed: %v", err) } + for _, attr := range getUserOutput.UserAttributes { + log.Printf(" %s = %s\n", *attr.Name, *attr.Value) + } + return true, nil -} \ No newline at end of file +} 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..765a037 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,51 @@ 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/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..bfb2a65 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,37 @@ 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/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 +114,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 +132,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..804f9a7 --- /dev/null +++ b/backend/middlewares/auth.go @@ -0,0 +1,60 @@ +package middlewares + +import ( + "fmt" + "log" + "net/http" + "strings" + + "arguehub/utils" + + "github.com/gin-gonic/gin" +) + +// AuthMiddleware verifies JWT and sets user email in context +func AuthMiddleware(configPath string) gin.HandlerFunc { + return func(c *gin.Context) { + // Check Authorization header first + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + // Fallback to query parameter "token" + tokenQuery := c.Query("token") + if tokenQuery != "" { + authHeader = "Bearer " + tokenQuery + } + } + + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing Authorization token"}) + c.Abort() + return + } + + // Split the header value into two parts (Bearer and the token) + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Authorization token format"}) + c.Abort() + return + } + token := parts[1] + + // Validate token and fetch email using your utility function + valid, email, err := utils.ValidateTokenAndFetchEmail(configPath, token, c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": fmt.Sprintf("Token validation error: %v", err)}) + c.Abort() + return + } + if !valid { + log.Println("Invalid or expired token:", token) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"}) + c.Abort() + return + } + + // Set the user's email in the context for later use + c.Set("userEmail", email) + c.Next() + } +} diff --git a/backend/models/debate.go b/backend/models/debate.go new file mode 100644 index 0000000..f02e772 --- /dev/null +++ b/backend/models/debate.go @@ -0,0 +1,17 @@ +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"` +} 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..8aa6da9 --- /dev/null +++ b/backend/models/user.go @@ -0,0 +1,18 @@ +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"` + CreatedAt time.Time `bson:"createdAt" json:"createdAt"` +} 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/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/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/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..ad9b406 100644 --- a/backend/utils/auth.go +++ b/backend/utils/auth.go @@ -4,20 +4,90 @@ import ( "crypto/hmac" "crypto/sha256" "encoding/base64" + "errors" + "fmt" + "log" "regexp" + + "arguehub/config" + + awsConfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider" + "github.com/gin-gonic/gin" ) +// Config holds Cognito secret configuration +type Config struct { + CognitoSecret string `json:"cognito_secret"` +} + +// GenerateSecretHash creates a secret hash for Cognito flows func GenerateSecretHash(username, clientId, clientSecret string) string { hmacInstance := hmac.New(sha256.New, []byte(clientSecret)) hmacInstance.Write([]byte(username + clientId)) secretHashByte := hmacInstance.Sum(nil) - - secretHashString := base64.StdEncoding.EncodeToString(secretHashByte) - return secretHashString + return base64.StdEncoding.EncodeToString(secretHashByte) } +// 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 +} + +func ValidateTokenAndFetchEmail(configPath, token string, ctx *gin.Context) (bool, string, error) { + log.Printf("Starting token validation...") + log.Printf("Attempting to load config from path: %s", configPath) // Log path every time + + // Load application config + cfg, err := config.LoadConfig(configPath) + if err != nil { + log.Printf("Error loading config: %v", err) + return false, "", fmt.Errorf("failed to load config: %v", err) + } + log.Println("Config loaded successfully.") + + // Initialize AWS config with region + awsCfg, err := awsConfig.LoadDefaultConfig(ctx, awsConfig.WithRegion(cfg.Cognito.Region)) + if err != nil { + log.Printf("Error loading AWS config: %v", err) + return false, "", fmt.Errorf("failed to load AWS config: %v", err) + } + log.Println("AWS config loaded successfully.") + + // Create Cognito client + cognitoClient := cognitoidentityprovider.NewFromConfig(awsCfg) + log.Println("Cognito client initialized.") + + // Validate token with Cognito GetUser + getUserOutput, err := cognitoClient.GetUser(ctx, &cognitoidentityprovider.GetUserInput{ + AccessToken: &token, + }) + if err != nil { + log.Printf("Token validation failed: %v", err) + return false, "", fmt.Errorf("token validation failed: %v", err) + } + log.Println("Token validation successful.") + + // Extract email from user attributes + var email string + for _, attr := range getUserOutput.UserAttributes { + log.Printf("Found attribute: %s = %s", *attr.Name, *attr.Value) + if *attr.Name == "email" { + email = *attr.Value + break + } + } + + if email == "" { + log.Println("Email not found in token.") + return false, "", errors.New("email not found in token") + } + + log.Printf("Email retrieved successfully: %s", email) + return true, email, 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/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..e9807f6 --- /dev/null +++ b/backend/websocket/websocket.go @@ -0,0 +1,102 @@ +package websocket + +import ( + "log" + "net/http" + "sync" + + "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 +} + +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) +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d134963..6ad96aa 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,9 +11,12 @@ "@radix-ui/react-avatar": "^1.1.2", "@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", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "config": "^3.3.12", @@ -23,6 +26,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 +20222,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 +20886,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 +22153,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 +22175,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 +22261,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" @@ -22237,6 +22350,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 +22467,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 +22507,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 +22628,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 +22665,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 +22705,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 +22757,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 +22848,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 +22936,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 +22952,57 @@ } } }, + "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-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 +23017,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 +23065,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 +24838,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 +25362,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 +26941,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 +26950,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 +27159,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 +27379,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 +27409,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 +27953,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 +28011,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 +28403,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 +28916,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 +29717,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 +29894,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 +31185,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 +31271,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 +31342,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 +31414,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 +32580,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 +32700,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 +33517,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 +33588,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 +49787,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 +50131,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 +50992,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 +51021,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 +51039,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": { @@ -49920,12 +51078,68 @@ "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 +51148,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 +51201,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 +51209,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 +51288,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 +51306,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 +51353,36 @@ } }, "@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-use-callback-ref": { @@ -50021,12 +51391,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 +52535,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 +52892,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 +53958,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 +53966,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 +54103,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 +54243,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 +54267,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 +54647,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 +54692,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 +54961,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 +55296,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 +55788,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 +56748,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 +56792,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 +56838,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 +56895,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 +57682,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 +57764,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 +58183,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 +58217,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..90288f4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,9 +13,12 @@ "@radix-ui/react-avatar": "^1.1.2", "@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", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "config": "^3.3.12", @@ -25,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" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8ebbe24..04aff88 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"; -const ProtectedRoute = () => { - const auth = useContext(AuthContext); - const [isLoading, setIsLoading] = useState(true); +import { AuthProvider, AuthContext } from "./context/authContext"; +import { ThemeProvider } from "./context/theme-provider"; - useEffect(() => { - setIsLoading(false); - }, [auth?.isAuthenticated]); +// 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"; - if (isLoading) return
Loading...
; // Show loading screen while checking auth +// Layout +import Layout from "./components/Layout"; +import OnlineDebateRoom from "./Pages/OnlineDebateRoom"; - return auth?.isAuthenticated ? : ; -}; +// Protects routes based on authentication status +function ProtectedRoute() { + const authContext = useContext(AuthContext); + // Throw error if context is undefined (shouldn't happen within AuthProvider) + 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); + + // Throw error if context is undefined (shouldn't happen within AuthProvider) + if (!authContext) { + throw new Error("AppRoutes must be used within an AuthProvider"); + } + + const { isAuthenticated } = authContext; + + return ( + + {/* Public routes */} + + ) : ( + + ) + } + /> + } /> + + {/* Protected routes with layout */} + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + + + {/* Redirect unknown routes */} + } /> + + ); +} + +// Main app with providers function App() { return ( - - } /> - } /> - }> - } /> - - + ); } - -export default App; +export default App; \ No newline at end of file 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/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/DebateRoom.tsx b/frontend/src/Pages/DebateRoom.tsx new file mode 100644 index 0000000..b9635af --- /dev/null +++ b/frontend/src/Pages/DebateRoom.tsx @@ -0,0 +1,600 @@ +import React, { useState, useEffect, useRef } from "react"; +import { useLocation } from "react-router-dom"; +import { Button } from "../components/ui/button"; +import { Card, CardHeader, CardTitle, CardDescription } from "../components/ui/card"; +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/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" + /> +
+
+ +