diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 6be5702..6818238 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -5,7 +5,10 @@ import ( "strconv" "arguehub/config" + "arguehub/db" + "arguehub/middlewares" "arguehub/routes" + "arguehub/utils" "arguehub/websocket" "github.com/gin-contrib/cors" @@ -13,46 +16,78 @@ 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) + // Establish a connection 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") + // Populate initial debate-related data (custom data seeding utility function) + utils.SeedDebateData() + utils.PopulateTestUsers() + + // Set up the Gin router and configure CORS, middleware, and routes + router := setupRouter(cfg) port := strconv.Itoa(cfg.Server.Port) log.Printf("Server starting on port %s", port) + + // Start the server on the configured 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 to prevent reverse proxy issues in certain deployment scenarios router.SetTrustedProxies([]string{"127.0.0.1", "localhost"}) + // Apply CORS policy to allow requests from the frontend (localhost:5173) router.Use(cors.New(cors.Config{ - AllowOrigins: []string{"http://localhost:5173"}, + AllowOrigins: []string{"http://localhost:5173"}, // Allow frontend on localhost AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowHeaders: []string{"Origin", "Content-Type", "Authorization"}, ExposeHeaders: []string{"Content-Length"}, AllowCredentials: true, })) + // Handle preflight OPTIONS requests router.OPTIONS("/*path", func(c *gin.Context) { c.Status(204) }) - 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) - + // Public routes for authentication and user actions + router.POST("/signup", routes.SignUpRouteHandler) // Handle user signup + router.POST("/verifyEmail", routes.VerifyEmailRouteHandler) // Verify user email + router.POST("/login", routes.LoginRouteHandler) // Handle user login + router.POST("/forgotPassword", routes.ForgotPasswordRouteHandler) // Handle forgotten password requests + router.POST("/confirmForgotPassword", routes.VerifyForgotPasswordRouteHandler) // Verify password reset token + router.POST("/verifyToken", routes.VerifyTokenRouteHandler) // Verify token (JWT or other) + + // WebSocket route for real-time communication router.GET("/ws", websocket.WebsocketHandler) + // Protected routes requiring authentication (JWT validation) + auth := router.Group("/") + auth.Use(middlewares.AuthMiddleware("./config/config.prod.yml")) // Apply custom authentication middleware + { + // Profile management routes + auth.GET("/user/fetchprofile", routes.GetProfileRouteHandler) // Fetch user profile data + auth.PUT("/user/updateprofile", routes.UpdateProfileRouteHandler) // Update user profile + + // Get leaderboard with user rankings based on debates + auth.GET("/leaderboard", routes.GetLeaderboardRouteHandler) + + // Update ELO score after a debate (e.g., for leaderboard updates) + auth.POST("/debate/result", routes.UpdateEloAfterDebateRouteHandler) + } + return router -} \ No newline at end of file +} diff --git a/backend/config/config.go b/backend/config/config.go index a71fe8b..02a67fa 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -2,37 +2,43 @@ 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"` + + 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..cd2ddc3 100644 --- a/backend/config/config.prod.yml +++ b/backend/config/config.prod.yml @@ -2,11 +2,13 @@ server: 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: "" \ 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/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/db/db.go b/backend/db/db.go new file mode 100644 index 0000000..b47dcef --- /dev/null +++ b/backend/db/db.go @@ -0,0 +1,47 @@ +package db + +import ( + "context" + "fmt" + "net/url" + "time" + + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var MongoClient *mongo.Client +var MongoDatabase *mongo.Database + +// 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 + MongoDatabase = client.Database(extractDBName(uri)) + return nil +} diff --git a/backend/go.mod b/backend/go.mod index 68ff1cb..68265d5 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -6,8 +6,11 @@ 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/gorilla/websocket v1.5.3 + go.mongodb.org/mongo-driver v1.17.3 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -27,26 +30,34 @@ require ( github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // 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-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/golang/snappy v0.0.4 // 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/kr/text v0.2.0 // 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 golang.org/x/arch v0.8.0 // indirect - golang.org/x/crypto v0.23.0 // indirect + golang.org/x/crypto v0.26.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 + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.23.0 // indirect + golang.org/x/text v0.17.0 // indirect google.golang.org/protobuf v1.34.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 7c0b670..339a9b0 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -34,6 +34,7 @@ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/ github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 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= @@ -55,17 +56,27 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 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.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +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 +86,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.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= 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= @@ -95,26 +110,60 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS 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= 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/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.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= +golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= +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.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +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.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +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= +golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= +golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +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/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= 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..b46fa61 --- /dev/null +++ b/backend/middlewares/auth.go @@ -0,0 +1,48 @@ +package middlewares + +import ( + "fmt" + "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) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing Authorization token"}) + c.Abort() + return + } + + 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 + 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 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"}) + c.Abort() + return + } + + // Set user email in context + 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/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/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/utils/auth.go b/backend/utils/auth.go index 17e521b..7c60595 100644 --- a/backend/utils/auth.go +++ b/backend/utils/auth.go @@ -4,20 +4,76 @@ import ( "crypto/hmac" "crypto/sha256" "encoding/base64" + "errors" + "fmt" "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 +} + +// ValidateTokenAndFetchEmail verifies a Cognito access token and retrieves email +func ValidateTokenAndFetchEmail(configPath, token string, ctx *gin.Context) (bool, string, error) { + // Load application config + cfg, err := config.LoadConfig(configPath) + if err != nil { + return false, "", fmt.Errorf("failed to load config: %v", err) + } + + // Initialize AWS config with region + awsCfg, err := awsConfig.LoadDefaultConfig(ctx, awsConfig.WithRegion(cfg.Cognito.Region)) + if err != nil { + return false, "", fmt.Errorf("failed to load AWS config: %v", err) + } + + // Create Cognito client + cognitoClient := cognitoidentityprovider.NewFromConfig(awsCfg) + + // Validate token with Cognito GetUser + getUserOutput, err := cognitoClient.GetUser(ctx, &cognitoidentityprovider.GetUserInput{ + AccessToken: &token, + }) + if err != nil { + return false, "", fmt.Errorf("token validation failed: %v", err) + } + + // Extract email from user attributes + var email string + for _, attr := range getUserOutput.UserAttributes { + if *attr.Name == "email" { + email = *attr.Value + break + } + } + if email == "" { + return false, "", errors.New("email not found in token") + } + + 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/frontend/package-lock.json b/frontend/package-lock.json index d134963..e1201b7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,9 +11,11 @@ "@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-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 +25,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 +20221,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" @@ -22141,20 +22143,6 @@ } } }, - "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==", - "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-avatar/node_modules/@radix-ui/react-primitive": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz", @@ -22194,11 +22182,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" @@ -22246,6 +22280,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", @@ -22292,10 +22343,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 +22380,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 +22420,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 +22472,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 } } }, @@ -22436,12 +22587,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 +22603,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 +22668,23 @@ } } }, + "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-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", @@ -24157,6 +24375,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", @@ -26195,7 +26467,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 +26476,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 +26685,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", @@ -26544,6 +26930,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 +27474,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 +27532,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", @@ -28021,6 +28429,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 +29230,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 +29407,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" } @@ -30325,6 +30739,35 @@ "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-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 +30789,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 +30861,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 +32027,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", @@ -32513,6 +32995,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 +49194,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" } @@ -49878,12 +50380,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 +50398,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": { @@ -49926,6 +50443,14 @@ "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", @@ -49941,14 +50466,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 +50474,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 +50553,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", @@ -50008,11 +50580,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,6 +50618,14 @@ "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-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", @@ -51104,6 +51709,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", @@ -52465,8 +53124,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 +53132,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 +53269,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", @@ -52688,6 +53428,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 +53808,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 +53853,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", @@ -53693,6 +54452,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 +54944,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", @@ -55164,6 +55927,27 @@ "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-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 +55964,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 +56021,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 +56808,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", @@ -56509,6 +57327,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..0dadb2b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,9 +13,11 @@ "@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-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 +27,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..1511040 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,41 +1,90 @@ -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"; - if (isLoading) return
Loading...
; // Show loading screen while checking auth +// Layout +import Layout from "./components/Layout"; - 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/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/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" + /> +
+
+ +