all repos — go-lift @ 092e7440440b8459d82fb90c05c005f9f38e3c51

Lightweight workout tracker prototype..

Add basic UI
Marco Andronaco andronacomarco@gmail.com
Fri, 23 May 2025 20:11:10 +0200
commit

092e7440440b8459d82fb90c05c005f9f38e3c51

parent

2e1c54060dc6096e5772a13f5248451e86b03f6d

M src/api/crud.gosrc/api/crud.go

@@ -9,11 +9,74 @@ "github.com/birabittoh/go-lift/src/database"

"gorm.io/gorm" ) +type WorkoutStats struct { + TotalWorkouts int64 `json:"totalWorkouts"` + TotalMinutes int `json:"totalMinutes"` + TotalExercises int64 `json:"totalExercises"` + MostFrequentExercise *struct { + Name string `json:"name"` + Count int `json:"count"` + } `json:"mostFrequentExercise,omitempty"` + MostFrequentRoutine *struct { + Name string `json:"name"` + Count int `json:"count"` + } `json:"mostFrequentRoutine,omitempty"` + RecentWorkouts []database.RecordRoutine `json:"recentWorkouts"` +} + +// User handlers +func getUserHandler(db *gorm.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + var user database.User + if err := db.First(&user, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + jsonError(w, http.StatusNotFound, "User not found") + return + } + jsonError(w, http.StatusInternalServerError, "Failed to fetch user: "+err.Error()) + return + } + jsonResponse(w, http.StatusOK, user) + } +} + +func updateUserHandler(db *gorm.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + var user database.User + if err := db.First(&user, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + jsonError(w, http.StatusNotFound, "User not found") + return + } + jsonError(w, http.StatusInternalServerError, "Failed to fetch user: "+err.Error()) + return + } + + if err := json.NewDecoder(r.Body).Decode(&user); err != nil { + jsonError(w, http.StatusBadRequest, "Invalid request body: "+err.Error()) + return + } + if err := db.Save(&user).Error; err != nil { + jsonError(w, http.StatusInternalServerError, "Failed to update user: "+err.Error()) + return + } + jsonResponse(w, http.StatusOK, user) + } +} + // Routines handlers func getRoutinesHandler(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var routines []database.Routine - result := db.Preload("Exercises").Preload("Supersets").Preload("Supersets.Sets").Find(&routines) + result := db. + Preload("RoutineItems"). + Preload("RoutineItems.Exercises"). + Preload("RoutineItems.Supersets"). + Preload("RoutineItems.Supersets.PrimaryExercise"). + Preload("RoutineItems.Supersets.SecondaryExercise"). + Find(&routines) if result.Error != nil { jsonError(w, http.StatusInternalServerError, "Failed to fetch routines: "+result.Error.Error()) return

@@ -26,7 +89,12 @@ func getRoutineHandler(db *gorm.DB) http.HandlerFunc {

return func(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") var routine database.Routine - result := db.Preload("Exercises").Preload("Supersets").Preload("Supersets.Sets").First(&routine, id) + result := db. + Preload("RoutineItems.Exercises"). + Preload("RoutineItems.Supersets"). + Preload("RoutineItems.Supersets.PrimaryExercise"). + Preload("RoutineItems.Supersets.SecondaryExercise"). + First(&routine, id) if result.Error != nil { if errors.Is(result.Error, gorm.ErrRecordNotFound) { jsonError(w, http.StatusNotFound, "Routine not found")

@@ -148,6 +216,17 @@ jsonResponse(w, http.StatusCreated, exercise)

} } +func upsertExercisesHandler(dbStruct *database.Database) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := dbStruct.UpdateExercises(); err != nil { + jsonError(w, http.StatusInternalServerError, "Failed to update exercises: "+err.Error()) + return + } + + jsonResponse(w, http.StatusOK, map[string]string{"message": "Exercises updated successfully"}) + } +} + func updateExerciseHandler(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id")

@@ -282,3 +361,81 @@ }

jsonResponse(w, http.StatusOK, map[string]string{"message": "Record routine deleted successfully"}) } } + +func getStatsHandler(db *gorm.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + stats := WorkoutStats{} + + // Get total workouts + if err := db.Model(&database.RecordRoutine{}).Count(&stats.TotalWorkouts).Error; err != nil { + jsonError(w, http.StatusInternalServerError, "Failed to count workouts: "+err.Error()) + return + } + + // Get total minutes + stats.TotalMinutes = 0 + + // Get total exercises + if err := db.Model(&database.RecordExercise{}).Count(&stats.TotalExercises).Error; err != nil { + jsonError(w, http.StatusInternalServerError, "Failed to count exercises: "+err.Error()) + return + } + + // Get most frequent exercise + var mostFrequentExercise struct { + Name string `gorm:"column:name"` + Count int `gorm:"column:count"` + } + exerciseQuery := db.Model(&database.RecordExercise{}). + Select("exercises.name, COUNT(*) as count"). + Joins("JOIN exercises ON record_exercises.exercise_id = exercises.id"). + Group("exercises.name"). + Order("count DESC"). + Limit(1) + + if err := exerciseQuery.Scan(&mostFrequentExercise).Error; err == nil && mostFrequentExercise.Name != "" { + stats.MostFrequentExercise = &struct { + Name string `json:"name"` + Count int `json:"count"` + }{ + Name: mostFrequentExercise.Name, + Count: mostFrequentExercise.Count, + } + } + + // Get most frequent routine + var mostFrequentRoutine struct { + Name string `gorm:"column:name"` + Count int `gorm:"column:count"` + } + routineQuery := db.Model(&database.RecordRoutine{}). + Select("routines.name, COUNT(*) as count"). + Joins("JOIN routines ON record_routines.routine_id = routines.id"). + Group("routines.name"). + Order("count DESC"). + Limit(1) + + if err := routineQuery.Scan(&mostFrequentRoutine).Error; err == nil && mostFrequentRoutine.Name != "" { + stats.MostFrequentRoutine = &struct { + Name string `json:"name"` + Count int `json:"count"` + }{ + Name: mostFrequentRoutine.Name, + Count: mostFrequentRoutine.Count, + } + } + + // Get recent workouts (last 5) + if err := db. + Preload("RecordRoutineItems"). + Preload("Routine"). + Order("created_at DESC"). + Limit(5). + Find(&stats.RecentWorkouts).Error; err != nil { + jsonError(w, http.StatusInternalServerError, "Failed to fetch recent workouts: "+err.Error()) + return + } + + jsonResponse(w, http.StatusOK, stats) + } +}
M src/api/routes.gosrc/api/routes.go

@@ -2,18 +2,29 @@ package api

import ( "net/http" + "os" + "path/filepath" - "gorm.io/gorm" + "github.com/birabittoh/go-lift/src/database" ) -func GetServeMux(db *gorm.DB) *http.ServeMux { +const uiDir = "ui/dist" + +var fileServer = http.FileServer(http.Dir(uiDir)) + +func GetServeMux(dbStruct *database.Database) *http.ServeMux { mux := http.NewServeMux() + db := dbStruct.DB mux.HandleFunc("GET /authelia/api/user/info", mockAutheliaHandler) mux.HandleFunc("GET /api/ping", pingHandler) mux.HandleFunc("GET /api/connection", connectionHandler(db)) + // Profile routes + mux.HandleFunc("GET /api/users/{id}", getUserHandler(db)) + mux.HandleFunc("PUT /api/users/{id}", updateUserHandler(db)) + // Routines routes mux.HandleFunc("GET /api/routines", getRoutinesHandler(db)) mux.HandleFunc("GET /api/routines/{id}", getRoutineHandler(db))

@@ -25,6 +36,7 @@ // Exercises routes

mux.HandleFunc("GET /api/exercises", getExercisesHandler(db)) mux.HandleFunc("GET /api/exercises/{id}", getExerciseHandler(db)) mux.HandleFunc("POST /api/exercises", createExerciseHandler(db)) + mux.HandleFunc("POST /api/exercises/update", upsertExercisesHandler(dbStruct)) mux.HandleFunc("PUT /api/exercises/{id}", updateExerciseHandler(db)) mux.HandleFunc("DELETE /api/exercises/{id}", deleteExerciseHandler(db))

@@ -34,6 +46,25 @@ mux.HandleFunc("GET /api/recordroutines/{id}", getRecordRoutineHandler(db))

mux.HandleFunc("POST /api/recordroutines", createRecordRoutineHandler(db)) mux.HandleFunc("PUT /api/recordroutines/{id}", updateRecordRoutineHandler(db)) mux.HandleFunc("DELETE /api/recordroutines/{id}", deleteRecordRoutineHandler(db)) + + // Stats routes + mux.HandleFunc("GET /api/stats", getStatsHandler(db)) + + // Static UI route + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + // Check if the file exists at the requested path + requestedFile := filepath.Join(uiDir, r.URL.Path) + _, err := os.Stat(requestedFile) + + // If file exists or it's the root, serve it directly + if err == nil || r.URL.Path == "/" { + fileServer.ServeHTTP(w, r) + return + } + + // For file not found, serve index.html for SPA routing + http.ServeFile(w, r, filepath.Join(uiDir, "index.html")) + }) return mux }
M src/database/data.gosrc/database/data.go

@@ -2,23 +2,27 @@ package database

import ( "log" + "time" +) - "gorm.io/gorm" -) +var defaultUserList = []User{ + { + Name: "Admin", + IsFemale: false, + Height: 180, + Weight: 75, + BirthDate: time.Date(1990, 1, 1, 0, 0, 0, 0, time.UTC), + }, +} // CheckInitialData ensures that all necessary initial data is in the database -func CheckInitialData(db *gorm.DB) (err error) { - err = ensureEquipmentData(db) +func (db *Database) CheckInitialData() (err error) { + err = db.ensureExerciseData() if err != nil { return } - err = ensureMuscleGroupData(db) - if err != nil { - return - } - - err = ensureExerciseData(db) + err = db.ensureUserData() if err != nil { return }

@@ -27,163 +31,36 @@ log.Println("Initial data verification complete")

return } -// ensureEquipmentData checks if equipment data exists and adds it if not -func ensureEquipmentData(db *gorm.DB) error { - equipmentList := []string{ - "None", - "Barbell", - "Dumbbell", - "Kettlebell", - "Machine", - "Plate", - "ResistanceBand", - "Suspension", - "Other", - } - - // Check if equipment data already exists +// ensureExerciseData checks if exercise data exists and adds it if not +func (db *Database) ensureExerciseData() error { + // Check if exercise data already exists var count int64 - if err := db.Model(&Equipment{}).Count(&count).Error; err != nil { + if err := db.Model(&Exercise{}).Count(&count).Error; err != nil { return err } - // If no equipment data, insert the initial data + // If no exercise data, insert the initial data if count == 0 { - log.Println("Adding initial equipment data") - for _, name := range equipmentList { - equipment := Equipment{ - Name: name, - } - if err := db.Create(&equipment).Error; err != nil { - return err - } - } + log.Println("Adding initial exercise data") + db.UpdateExercises() } return nil } -// ensureMuscleGroupData checks if muscle group data exists and adds it if not -func ensureMuscleGroupData(db *gorm.DB) error { - muscleGroupList := []string{ - "Abdominals", - "Abductors", - "Adductors", - "Biceps", - "LowerBack", - "UpperBack", - "Cardio", - "Chest", - "Calves", - "Forearms", - "Glutes", - "Hamstrings", - "Lats", - "Quadriceps", - "Shoulders", - "Triceps", - "Traps", - "Neck", - "FullBody", - "Other", - } - - // Check if muscle group data already exists +// ensureUserData checks if user data exists and adds it if not +func (db *Database) ensureUserData() error { + // Check if user data already exists var count int64 - if err := db.Model(&MuscleGroup{}).Count(&count).Error; err != nil { + if err := db.Model(&User{}).Count(&count).Error; err != nil { return err } - // If no muscle group data, insert the initial data + // If no user data, insert the initial data if count == 0 { - log.Println("Adding initial muscle group data") - for _, name := range muscleGroupList { - muscleGroup := MuscleGroup{ - Name: name, - } - if err := db.Create(&muscleGroup).Error; err != nil { - return err - } - } - } - - return nil -} - -// ensureExerciseData checks if exercise data exists and adds it if not -func ensureExerciseData(db *gorm.DB) error { - exerciseList := []Exercise{ - {Name: "BenchPress", MuscleGroups: []MuscleGroup{{ID: 8}, {ID: 15}, {ID: 16}}, Equipment: []Equipment{{ID: 2}}}, - {Name: "Squat", MuscleGroups: []MuscleGroup{{ID: 14}, {ID: 12}, {ID: 11}, {ID: 5}}, Equipment: []Equipment{{ID: 2}}}, - {Name: "Deadlift", MuscleGroups: []MuscleGroup{{ID: 5}, {ID: 6}, {ID: 12}, {ID: 11}, {ID: 14}}, Equipment: []Equipment{{ID: 2}}}, - {Name: "OverheadPress", MuscleGroups: []MuscleGroup{{ID: 15}, {ID: 16}}, Equipment: []Equipment{{ID: 2}}}, - {Name: "PullUp", MuscleGroups: []MuscleGroup{{ID: 13}, {ID: 6}, {ID: 4}}, Equipment: []Equipment{{ID: 1}}}, - {Name: "PushUp", MuscleGroups: []MuscleGroup{{ID: 8}, {ID: 15}, {ID: 16}, {ID: 1}}, Equipment: []Equipment{{ID: 1}}}, - {Name: "Lunges", MuscleGroups: []MuscleGroup{{ID: 14}, {ID: 12}, {ID: 11}}, Equipment: []Equipment{{ID: 1}}}, - {Name: "Plank", MuscleGroups: []MuscleGroup{{ID: 1}, {ID: 5}}, Equipment: []Equipment{{ID: 1}}}, - {Name: "BicepCurl", MuscleGroups: []MuscleGroup{{ID: 4}, {ID: 10}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "TricepDip", MuscleGroups: []MuscleGroup{{ID: 16}, {ID: 8}, {ID: 15}}, Equipment: []Equipment{{ID: 1}}}, - {Name: "LegPress", MuscleGroups: []MuscleGroup{{ID: 14}, {ID: 12}, {ID: 11}}, Equipment: []Equipment{{ID: 5}}}, - {Name: "LatPulldown", MuscleGroups: []MuscleGroup{{ID: 13}, {ID: 6}, {ID: 4}}, Equipment: []Equipment{{ID: 5}}}, - {Name: "LegExtension", MuscleGroups: []MuscleGroup{{ID: 14}}, Equipment: []Equipment{{ID: 5}}}, - {Name: "LegCurl", MuscleGroups: []MuscleGroup{{ID: 12}}, Equipment: []Equipment{{ID: 5}}}, - {Name: "ShoulderPress", MuscleGroups: []MuscleGroup{{ID: 15}, {ID: 16}}, Equipment: []Equipment{{ID: 2}}}, - {Name: "ChestFly", MuscleGroups: []MuscleGroup{{ID: 8}}, Equipment: []Equipment{{ID: 5}}}, - {Name: "CableRow", MuscleGroups: []MuscleGroup{{ID: 6}, {ID: 13}, {ID: 4}}, Equipment: []Equipment{{ID: 5}}}, - {Name: "SeatedRow", MuscleGroups: []MuscleGroup{{ID: 6}, {ID: 13}, {ID: 4}}, Equipment: []Equipment{{ID: 5}}}, - {Name: "DumbbellFly", MuscleGroups: []MuscleGroup{{ID: 8}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellRow", MuscleGroups: []MuscleGroup{{ID: 6}, {ID: 13}, {ID: 4}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellShoulderPress", MuscleGroups: []MuscleGroup{{ID: 15}, {ID: 16}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellLateralRaise", MuscleGroups: []MuscleGroup{{ID: 15}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellFrontRaise", MuscleGroups: []MuscleGroup{{ID: 15}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellShrug", MuscleGroups: []MuscleGroup{{ID: 17}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellTricepExtension", MuscleGroups: []MuscleGroup{{ID: 16}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellBicepCurl", MuscleGroups: []MuscleGroup{{ID: 4}, {ID: 10}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellLunge", MuscleGroups: []MuscleGroup{{ID: 14}, {ID: 12}, {ID: 11}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellSquat", MuscleGroups: []MuscleGroup{{ID: 14}, {ID: 12}, {ID: 11}, {ID: 5}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellDeadlift", MuscleGroups: []MuscleGroup{{ID: 5}, {ID: 6}, {ID: 12}, {ID: 11}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellChestPress", MuscleGroups: []MuscleGroup{{ID: 8}, {ID: 15}, {ID: 16}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellChestFly", MuscleGroups: []MuscleGroup{{ID: 8}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellBentOverRow", MuscleGroups: []MuscleGroup{{ID: 6}, {ID: 13}, {ID: 4}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellLateralRaise", MuscleGroups: []MuscleGroup{{ID: 15}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellFrontRaise", MuscleGroups: []MuscleGroup{{ID: 15}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellShrug", MuscleGroups: []MuscleGroup{{ID: 17}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellTricepKickback", MuscleGroups: []MuscleGroup{{ID: 16}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellHammerCurl", MuscleGroups: []MuscleGroup{{ID: 4}, {ID: 10}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellConcentrationCurl", MuscleGroups: []MuscleGroup{{ID: 4}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellSkullCrusher", MuscleGroups: []MuscleGroup{{ID: 16}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellWristCurl", MuscleGroups: []MuscleGroup{{ID: 10}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellWristExtension", MuscleGroups: []MuscleGroup{{ID: 10}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellSideBend", MuscleGroups: []MuscleGroup{{ID: 1}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellRussianTwist", MuscleGroups: []MuscleGroup{{ID: 1}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellPlankRow", MuscleGroups: []MuscleGroup{{ID: 1}, {ID: 6}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellSidePlank", MuscleGroups: []MuscleGroup{{ID: 1}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellMountainClimber", MuscleGroups: []MuscleGroup{{ID: 1}, {ID: 14}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellBicycleCrunch", MuscleGroups: []MuscleGroup{{ID: 1}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellLegRaise", MuscleGroups: []MuscleGroup{{ID: 1}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellReverseCrunch", MuscleGroups: []MuscleGroup{{ID: 1}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellFlutterKick", MuscleGroups: []MuscleGroup{{ID: 1}, {ID: 14}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellSideCrunch", MuscleGroups: []MuscleGroup{{ID: 1}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellToeTouch", MuscleGroups: []MuscleGroup{{ID: 1}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellWoodchopper", MuscleGroups: []MuscleGroup{{ID: 1}, {ID: 5}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellSideLegRaise", MuscleGroups: []MuscleGroup{{ID: 2}, {ID: 11}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellGluteBridge", MuscleGroups: []MuscleGroup{{ID: 11}, {ID: 12}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellCalfRaise", MuscleGroups: []MuscleGroup{{ID: 9}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellStepUp", MuscleGroups: []MuscleGroup{{ID: 14}, {ID: 12}, {ID: 11}}, Equipment: []Equipment{{ID: 3}}}, - {Name: "DumbbellBulgarianSplitSquat", MuscleGroups: []MuscleGroup{{ID: 14}, {ID: 12}, {ID: 11}}, Equipment: []Equipment{{ID: 3}}}, - } - - // Check if exercise data already exists - var count int64 - if err := db.Model(&Exercise{}).Count(&count).Error; err != nil { - return err - } - - // If no exercise data, insert the initial data - if count == 0 { - log.Println("Adding initial exercise data") - for _, exercise := range exerciseList { - if err := db.Create(&exercise).Error; err != nil { + log.Println("Adding initial user data") + for _, user := range defaultUserList { + if err := db.Create(&user).Error; err != nil { return err } }
A src/database/exercises.go

@@ -0,0 +1,388 @@

+package database + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "strings" + "time" +) + +// ImportedExercise represents the JSON structure from the input file +type importedExercise struct { + Name string `json:"name"` + Level string `json:"level"` + Category string `json:"category"` + Force *string `json:"force"` + Mechanic *string `json:"mechanic"` + Equipment *string `json:"equipment"` + PrimaryMuscles []string `json:"primaryMuscles"` + SecondaryMuscles []string `json:"secondaryMuscles"` + Instructions []string `json:"instructions"` + Images []string `json:"images"` + ID string `json:"id"` +} + +// upsertLookupEntities creates or updates all lookup table entries and returns lookup maps +func (db *Database) upsertLookupEntities(exercises []importedExercise) (map[string]uint, error) { + // Collect unique values + uniqueValues := make(map[string]bool) + + // Extract unique values from exercises + for _, exercise := range exercises { + for _, muscle := range exercise.PrimaryMuscles { + uniqueValues[muscle] = true + } + for _, muscle := range exercise.SecondaryMuscles { + uniqueValues[muscle] = true + } + } + + // Upsert lookup entities in database + if err := db.upsertMuscles(uniqueValues); err != nil { + return nil, err + } + + // Build lookup map + muscleLookupMap, err := db.buildMuscleLookupMap(uniqueValues) + if err != nil { + return nil, err + } + + log.Printf("Upserted lookup entities: %d muscles", len(uniqueValues)) + + return muscleLookupMap, nil +} + +func (db *Database) upsertMuscles(muscles map[string]bool) error { + for name := range muscles { + muscle := Muscle{Name: name} + if err := db.Where("name = ?", name).FirstOrCreate(&muscle).Error; err != nil { + return fmt.Errorf("failed to upsert muscle %s: %w", name, err) + } + } + return nil +} + +// buildLookupMaps populates the lookup maps with IDs from the database +func (db *Database) buildMuscleLookupMap(uniqueValues map[string]bool) (map[string]uint, error) { + // Build muscles map + maps := make(map[string]uint) + for name := range uniqueValues { + var muscle Muscle + if err := db.Where("name = ?", name).First(&muscle).Error; err != nil { + return nil, fmt.Errorf("failed to find muscle %s: %w", name, err) + } + maps[name] = muscle.ID + } + + return maps, nil +} + +// upsertExercise creates or updates a single exercise with all its related data +func (db *Database) upsertExercise(importedExercise importedExercise, lookupMap map[string]uint) (didSave, isUpdate bool, err error) { + // First, try to find existing exercise by name + var existingExercise Exercise + result := db.Where("name = ?", importedExercise.Name).Preload("PrimaryMuscles").Preload("SecondaryMuscles").First(&existingExercise) + + // Create new exercise with basic info + exercise := Exercise{ + Name: importedExercise.Name, + Level: importedExercise.Level, + Category: importedExercise.Category, + } + + if importedExercise.Force != nil && *importedExercise.Force != "" { + exercise.Force = importedExercise.Force + } + if importedExercise.Mechanic != nil && *importedExercise.Mechanic != "" { + exercise.Mechanic = importedExercise.Mechanic + } + if importedExercise.Equipment != nil && *importedExercise.Equipment != "" { + exercise.Equipment = importedExercise.Equipment + } + + if len(importedExercise.Instructions) > 0 { + // Filter out empty instructions + var filteredInstructions []string + for _, instruction := range importedExercise.Instructions { + clean := strings.TrimSpace(instruction) + if clean != "" { + filteredInstructions = append(filteredInstructions, clean) + } + } + instructions := strings.Join(filteredInstructions, "\n") + exercise.Instructions = &instructions + } + + var exerciseID uint + var exerciseDataChanged bool + var muscleAssociationsChanged bool + + if result.Error == nil { + // Exercise exists, check if it needs updating + isUpdate = true + exerciseID = existingExercise.ID + exercise.ID = exerciseID + exercise.CreatedAt = existingExercise.CreatedAt // Preserve creation time + + // Check if the exercise data has actually changed + exerciseDataChanged = db.exerciseDataChanged(existingExercise, exercise) + + // Check if muscle associations have changed + muscleAssociationsChanged = db.muscleAssociationsChanged(existingExercise, importedExercise, lookupMap) + + // Only update if something has changed + if exerciseDataChanged { + if err := db.Save(&exercise).Error; err != nil { + return false, false, fmt.Errorf("failed to update exercise: %w", err) + } + didSave = true + } + } else { + // Exercise doesn't exist, create it + isUpdate = false + exerciseDataChanged = true // New exercise, so data is "changed" + muscleAssociationsChanged = true // New exercise, so associations are "changed" + + if err := db.Create(&exercise).Error; err != nil { + return false, false, fmt.Errorf("failed to create exercise: %w", err) + } + exerciseID = exercise.ID + didSave = true + } + + // Only update muscle associations if they've changed + if muscleAssociationsChanged { + if err := db.updateMuscleAssociations(exerciseID, importedExercise, lookupMap); err != nil { + return false, false, fmt.Errorf("failed to update muscle associations: %w", err) + } + didSave = true + } + + return +} + +// exerciseDataChanged compares two exercises to see if core data has changed +func (db *Database) exerciseDataChanged(existing, new Exercise) bool { + return existing.Level != new.Level || + existing.Category != new.Category || + !stringPointersEqual(existing.Force, new.Force) || + !stringPointersEqual(existing.Mechanic, new.Mechanic) || + !stringPointersEqual(existing.Equipment, new.Equipment) || + !stringPointersEqual(existing.Instructions, new.Instructions) +} + +// Helper function to compare string pointers +func stringPointersEqual(a, b *string) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return *a == *b +} + +// muscleAssociationsChanged compares existing muscle associations with imported data +func (db *Database) muscleAssociationsChanged(existing Exercise, imported importedExercise, lookupMap map[string]uint) bool { + // Convert existing muscle associations to sets for comparison + existingPrimary := make(map[uint]bool) + existingSecondary := make(map[uint]bool) + + for _, muscle := range existing.PrimaryMuscles { + existingPrimary[muscle.ID] = true + } + for _, muscle := range existing.SecondaryMuscles { + existingSecondary[muscle.ID] = true + } + + // Convert imported muscle names to IDs and create sets + importedPrimary := make(map[uint]bool) + importedSecondary := make(map[uint]bool) + + for _, muscleName := range imported.PrimaryMuscles { + if muscleID, ok := lookupMap[muscleName]; ok { + importedPrimary[muscleID] = true + } + } + for _, muscleName := range imported.SecondaryMuscles { + if muscleID, ok := lookupMap[muscleName]; ok { + importedSecondary[muscleID] = true + } + } + + // Compare primary muscles + if len(existingPrimary) != len(importedPrimary) { + return true + } + for muscleID := range existingPrimary { + if !importedPrimary[muscleID] { + return true + } + } + + // Compare secondary muscles + if len(existingSecondary) != len(importedSecondary) { + return true + } + for muscleID := range existingSecondary { + if !importedSecondary[muscleID] { + return true + } + } + + return false +} + +// updateMuscleAssociations replaces muscle associations for an exercise +func (db *Database) updateMuscleAssociations(exerciseID uint, importedExercise importedExercise, lookupMap map[string]uint) error { + exercise := Exercise{ID: exerciseID} + + // Clear existing associations + if err := db.Model(&exercise).Association("PrimaryMuscles").Clear(); err != nil { + return fmt.Errorf("failed to clear primary muscles: %w", err) + } + if err := db.Model(&exercise).Association("SecondaryMuscles").Clear(); err != nil { + return fmt.Errorf("failed to clear secondary muscles: %w", err) + } + + // Add primary muscles + var primaryMuscles []Muscle + for _, muscleName := range importedExercise.PrimaryMuscles { + if muscleID, ok := lookupMap[muscleName]; ok { + primaryMuscles = append(primaryMuscles, Muscle{ID: muscleID}) + } + } + if len(primaryMuscles) > 0 { + if err := db.Model(&exercise).Association("PrimaryMuscles").Append(&primaryMuscles); err != nil { + return fmt.Errorf("failed to add primary muscles: %w", err) + } + } + + // Add secondary muscles + var secondaryMuscles []Muscle + for _, muscleName := range importedExercise.SecondaryMuscles { + if muscleID, ok := lookupMap[muscleName]; ok { + secondaryMuscles = append(secondaryMuscles, Muscle{ID: muscleID}) + } + } + if len(secondaryMuscles) > 0 { + if err := db.Model(&exercise).Association("SecondaryMuscles").Append(&secondaryMuscles); err != nil { + return fmt.Errorf("failed to add secondary muscles: %w", err) + } + } + + return nil +} + +// downloadExercises downloads exercises from the JSON URL +func downloadExercises() ([]importedExercise, error) { + // Download exercises.json from the URL + resp, err := http.Get(jsonURL) + if err != nil { + return nil, fmt.Errorf("failed to download exercises.json: %w", err) + } + defer resp.Body.Close() + + // Check if the request was successful + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to download exercises.json: HTTP status %d", resp.StatusCode) + } + + // Read the response body + fileData, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var exercises []importedExercise + if err := json.Unmarshal(fileData, &exercises); err != nil { + return nil, fmt.Errorf("failed to parse exercises.json: %w", err) + } + + return exercises, nil +} + +const ( + dbDir = "data" + dbName = "fitness.sqlite" + baseURL = "https://raw.githubusercontent.com/yuhonas/free-exercise-db/main/" + jsonURL = baseURL + "dist/exercises.json" + imageFormat = baseURL + "exercises/%s/%d.jpg" + imageAmount = 2 +) + +var ( + idReplacer = strings.NewReplacer( + " ", "_", + "/", "_", + ",", "", + "(", "", + ")", "", + "-", "-", + "'", "", + ) + lastUpdate = time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) +) + +func (e Exercise) StringID() string { + return idReplacer.Replace(e.Name) +} + +func (e Exercise) Images() (images []string) { + id := e.StringID() + + for i := range imageAmount { + images = append(images, fmt.Sprintf(imageFormat, id, i)) + } + return +} + +func (db *Database) UpdateExercises() (err error) { + // Load exercises + exercises, err := downloadExercises() + if err != nil { + log.Fatalf("Failed to load exercises: %v", err) + } + + log.Printf("Successfully loaded %d exercises from JSON", len(exercises)) + + // Create/update lookup entities and get maps + lookupMaps, err := db.upsertLookupEntities(exercises) + if err != nil { + return errors.New("Failed to upsert lookup entities: " + err.Error()) + } + + var successCount, createCount, updateCount int + + // Import/update exercises + for i, exercise := range exercises { + didSave, isUpdate, err := db.upsertExercise(exercise, lookupMaps) + if err != nil { + log.Printf("Failed to upsert exercise %d (%s): %v", i+1, exercise.Name, err) + continue + } + + successCount++ + if didSave { + if isUpdate { + updateCount++ + } else { + createCount++ + } + } + } + + lastUpdate = time.Now() + + log.Printf("Update completed successfully! Processed %d out of %d exercises (%d created, %d updated)", successCount, len(exercises), createCount, updateCount) + return +} + +func (db *Database) GetLastUpdate() time.Time { + return lastUpdate +}
M src/database/models.gosrc/database/models.go

@@ -11,67 +11,54 @@ "gorm.io/gorm"

"gorm.io/gorm/logger" ) -const ( - dbDir = "data" - dbName = "fitness.db" -) - -type Equipment struct { - ID uint `gorm:"primaryKey" json:"id"` - Name string `gorm:"size:100;not null;uniqueIndex" json:"name"` - Description string `gorm:"size:500" json:"description"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` - - Exercises []Exercise `gorm:"many2many:exercise_equipment" json:"exercises,omitempty"` +type Database struct { + *gorm.DB } -type MuscleGroup struct { +type User struct { ID uint `gorm:"primaryKey" json:"id"` - Name string `gorm:"size:100;not null;uniqueIndex" json:"name"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` + Name string `gorm:"size:50" json:"name"` + IsFemale bool `gorm:"default:false" json:"isFemale"` + Height float64 `json:"height"` // In cm + Weight float64 `json:"weight"` // In kg + BirthDate time.Time `json:"birthDate"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"` +} - Exercises []Exercise `gorm:"many2many:exercise_muscle_groups" json:"exercises,omitempty"` -} +type Exercise struct { + ID uint `gorm:"primaryKey;autoIncrement"` + Name string `gorm:"not null;uniqueIndex"` + Level string `gorm:"size:50;not null"` + Category string `gorm:"size:50;not null"` + Force *string `gorm:"size:50"` + Mechanic *string `gorm:"size:50"` + Equipment *string `gorm:"size:50"` + Instructions *string -type ExerciseMuscleGroup struct { - ID uint `gorm:"primaryKey" json:"id"` - ExerciseID uint `gorm:"uniqueIndex:idx_exercise_muscle_group" json:"exercise_id"` - MuscleGroupID uint `gorm:"uniqueIndex:idx_exercise_muscle_group" json:"muscle_group_id"` + PrimaryMuscles []Muscle `gorm:"many2many:exercise_primary_muscles;constraint:OnDelete:CASCADE"` + SecondaryMuscles []Muscle `gorm:"many2many:exercise_secondary_muscles;constraint:OnDelete:CASCADE"` - Exercise Exercise `json:"exercise"` - MuscleGroup MuscleGroup `json:"muscle_group"` + CreatedAt time.Time + UpdatedAt time.Time } -type Exercise struct { - ID uint `gorm:"primaryKey" json:"id"` - Name string `gorm:"size:100;not null" json:"name"` - Description string `gorm:"size:500" json:"description"` - //UserID uint `gorm:"index" json:"user_id"` - //IsPublic bool `gorm:"default:false" json:"is_public"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` - - //User User `json:"-"` - Equipment []Equipment `gorm:"many2many:exercise_equipment" json:"equipment"` - MuscleGroups []MuscleGroup `gorm:"many2many:exercise_muscle_groups" json:"muscle_groups"` - Sets []Set `json:"sets,omitempty"` +type Muscle struct { + ID uint `gorm:"primaryKey"` + Name string `gorm:"uniqueIndex;size:50;not null"` } type Set struct { ID uint `gorm:"primaryKey" json:"id"` - ExerciseID uint `gorm:"index" json:"exercise_id"` + ExerciseID uint `gorm:"index" json:"exerciseId"` Reps int `json:"reps"` Weight float64 `json:"weight"` Duration int `json:"duration"` // In seconds, for timed exercises - OrderIndex int `gorm:"not null" json:"order_index"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` + OrderIndex int `gorm:"not null" json:"orderIndex"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"` Exercise Exercise `json:"-"` }

@@ -80,31 +67,31 @@ // SuperSet to handle two exercises with single rest time

type SuperSet struct { ID uint `gorm:"primaryKey" json:"id"` Name string `gorm:"size:100" json:"name"` - PrimaryExerciseID uint `gorm:"index" json:"primary_exercise_id"` - SecondaryExerciseID uint `gorm:"index" json:"secondary_exercise_id"` - RestTime int `gorm:"default:0" json:"rest_time"` // In seconds - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` + PrimaryExerciseID uint `gorm:"index" json:"primaryExerciseId"` + SecondaryExerciseID uint `gorm:"index" json:"secondaryExerciseId"` + RestTime int `gorm:"default:0" json:"restTime"` // In seconds + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"` - PrimaryExercise Exercise `json:"primary_exercise"` - SecondaryExercise Exercise `json:"secondary_exercise"` + PrimaryExercise Exercise `json:"primaryExercise"` + SecondaryExercise Exercise `json:"secondaryExercise"` } // RoutineItem represents either an Exercise or a SuperSet in a Routine type RoutineItem struct { ID uint `gorm:"primaryKey" json:"id"` - RoutineID uint `gorm:"index" json:"routine_id"` - ExerciseID *uint `gorm:"index" json:"exercise_id"` - SuperSetID *uint `gorm:"index" json:"super_set_id"` - RestTime int `gorm:"default:0" json:"rest_time"` // In seconds - OrderIndex int `gorm:"not null" json:"order_index"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` + RoutineID uint `gorm:"index" json:"routineId"` + ExerciseID *uint `gorm:"index" json:"exerciseId"` + SuperSetID *uint `gorm:"index" json:"superSetId"` + RestTime int `gorm:"default:0" json:"restTime"` // In seconds + OrderIndex int `gorm:"not null" json:"orderIndex"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"` Routine Routine `json:"-"` - SuperSet *SuperSet `json:"super_set,omitempty"` + SuperSet *SuperSet `json:"superSet,omitempty"` Exercise *Exercise `json:"exercise,omitempty"` }

@@ -112,14 +99,14 @@ type Routine struct {

ID uint `gorm:"primaryKey" json:"id"` Name string `gorm:"size:100;not null" json:"name"` Description string `gorm:"size:500" json:"description"` - //UserID uint `gorm:"index" json:"user_id"` - //IsPublic bool `gorm:"default:false" json:"is_public"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` + //UserID uint `gorm:"index" json:"userId"` + //IsPublic bool `gorm:"default:false" json:"isPublic"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"` //User User `json:"-"` - RoutineItems []RoutineItem `json:"routine_items,omitempty"` + RoutineItems []RoutineItem `json:"routineItems,omitempty"` } /*

@@ -129,95 +116,95 @@ Username string `gorm:"size:50;not null;uniqueIndex" json:"username"`

Email string `gorm:"size:100;not null;uniqueIndex" json:"email"` Password string `gorm:"size:100;not null" json:"-"` Name string `gorm:"size:100" json:"name"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"` Exercises []Exercise `json:"exercises,omitempty"` Routines []Routine `json:"routines,omitempty"` - RecordRoutines []RecordRoutine `json:"record_routines,omitempty"` + RecordRoutines []RecordRoutine `json:"recordRoutines,omitempty"` } */ type RecordRoutine struct { ID uint `gorm:"primaryKey" json:"id"` - //UserID uint `gorm:"index" json:"user_id"` - RoutineID uint `gorm:"index" json:"routine_id"` - StartedAt time.Time `gorm:"not null" json:"started_at"` - EndedAt *time.Time `json:"ended_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` + //UserID uint `gorm:"index" json:"userId"` + RoutineID uint `gorm:"index" json:"routineId"` + StartedAt time.Time `gorm:"not null" json:"startedAt"` + EndedAt *time.Time `json:"endedAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"` //User User `json:"-"` Routine Routine `json:"routine"` - RecordRoutineItems []RecordRoutineItem `json:"record_routine_items,omitempty"` + RecordRoutineItems []RecordRoutineItem `json:"recordRoutineItems,omitempty"` } // RecordRoutineItem represents either a RecordExercise or a RecordSuperSet in a completed routine type RecordRoutineItem struct { ID uint `gorm:"primaryKey" json:"id"` - RecordRoutineID uint `gorm:"index" json:"record_routine_id"` - RecordExerciseID *uint `gorm:"index" json:"record_exercise_id"` - RecordSuperSetID *uint `gorm:"index" json:"record_super_set_id"` - ActualRestTime int `json:"actual_rest_time"` // In seconds - OrderIndex int `gorm:"not null" json:"order_index"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` + RecordRoutineID uint `gorm:"index" json:"recordRoutineId"` + RecordExerciseID *uint `gorm:"index" json:"recordExerciseId"` + RecordSuperSetID *uint `gorm:"index" json:"recordSuperSetId"` + ActualRestTime int `json:"actualRestTime"` // In seconds + OrderIndex int `gorm:"not null" json:"orderIndex"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"` RecordRoutine RecordRoutine `json:"-"` - RecordSuperSet *RecordSuperSet `json:"record_super_set,omitempty"` - RecordExercise *RecordExercise `json:"record_exercise,omitempty"` + RecordSuperSet *RecordSuperSet `json:"recordSuperSet,omitempty"` + RecordExercise *RecordExercise `json:"recordExercise,omitempty"` } // RecordSuperSet records a completed superset type RecordSuperSet struct { ID uint `gorm:"primaryKey" json:"id"` - RecordRoutineID uint `gorm:"index" json:"record_routine_id"` - SuperSetID uint `gorm:"index" json:"super_set_id"` - StartedAt time.Time `gorm:"not null" json:"started_at"` - EndedAt time.Time `gorm:"not null" json:"ended_at"` - ActualRestTime int `json:"actual_rest_time"` // In seconds - OrderIndex int `gorm:"not null" json:"order_index"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` + RecordRoutineID uint `gorm:"index" json:"recordRoutineId"` + SuperSetID uint `gorm:"index" json:"superSetId"` + StartedAt time.Time `gorm:"not null" json:"startedAt"` + EndedAt time.Time `gorm:"not null" json:"endedAt"` + ActualRestTime int `json:"actualRestTime"` // In seconds + OrderIndex int `gorm:"not null" json:"orderIndex"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"` RecordRoutine RecordRoutine `json:"-"` - SuperSet SuperSet `json:"super_set"` + SuperSet SuperSet `json:"superSet"` } type RecordExercise struct { ID uint `gorm:"primaryKey" json:"id"` - RecordRoutineID uint `gorm:"index" json:"record_routine_id"` + RecordRoutineID uint `gorm:"index" json:"recordRoutineId"` RecordRoutine RecordRoutine `json:"-"` - ExerciseID uint `gorm:"index" json:"exercise_id"` + ExerciseID uint `gorm:"index" json:"exerciseId"` Exercise Exercise `json:"exercise"` - StartedAt time.Time `gorm:"not null" json:"started_at"` - EndedAt time.Time `gorm:"not null" json:"ended_at"` - ActualRestTime int `json:"actual_rest_time"` // In seconds - RecordSets []RecordSet `json:"record_sets,omitempty"` - OrderIndex int `gorm:"not null" json:"order_index"` - RecordSuperSetID *uint `gorm:"index" json:"record_super_set_id"` + StartedAt time.Time `gorm:"not null" json:"startedAt"` + EndedAt time.Time `gorm:"not null" json:"endedAt"` + ActualRestTime int `json:"actualRestTime"` // In seconds + RecordSets []RecordSet `json:"recordSets,omitempty"` + OrderIndex int `gorm:"not null" json:"orderIndex"` + RecordSuperSetID *uint `gorm:"index" json:"recordSuperSetId"` RecordSuperSet *RecordSuperSet `json:"-"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"` } type RecordSet struct { ID uint `gorm:"primaryKey" json:"id"` - RecordExerciseID uint `gorm:"index" json:"record_exercise_id"` - SetID uint `gorm:"index" json:"set_id"` - ActualReps int `json:"actual_reps"` - ActualWeight float64 `json:"actual_weight"` - ActualDuration int `json:"actual_duration"` // In seconds - CompletedAt time.Time `gorm:"not null" json:"completed_at"` - OrderIndex int `gorm:"not null" json:"order_index"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` + RecordExerciseID uint `gorm:"index" json:"recordExerciseId"` + SetID uint `gorm:"index" json:"setId"` + ActualReps int `json:"actualReps"` + ActualWeight float64 `json:"actualWeight"` + ActualDuration int `json:"actualDuration"` // In seconds + CompletedAt time.Time `gorm:"not null" json:"completedAt"` + OrderIndex int `gorm:"not null" json:"orderIndex"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"` RecordExercise RecordExercise `json:"-"` Set Set `json:"set"`

@@ -225,12 +212,12 @@ }

type Localization struct { ID uint `gorm:"primaryKey" json:"id"` - LanguageID uint `gorm:"not null;uniqueIndex:idx_lang_keyword" json:"language_id"` - Keyword string `gorm:"size:255;not null;uniqueIndex:idx_lang_keyword" json:"keyword"` + LanguageID uint `gorm:"not null;uniqueIndex:idxLangKeyword" json:"languageId"` + Keyword string `gorm:"size:255;not null;uniqueIndex:idxLangKeyword" json:"keyword"` Text string `gorm:"size:1000;not null" json:"text"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"` } type Language struct {

@@ -238,13 +225,13 @@ ID uint `gorm:"primaryKey" json:"id"`

Name string `gorm:"size:100;not null;uniqueIndex" json:"name"` Code string `gorm:"size:8;not null;uniqueIndex" json:"code"` Flag string `gorm:"size:50" json:"flag"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"` } // InitializeDB creates and initializes the SQLite database with all models -func InitializeDB() (db *gorm.DB, err error) { +func InitializeDB() (db *Database, err error) { // Create the data directory if it doesn't exist if _, err = os.Stat(dbDir); os.IsNotExist(err) { err = os.MkdirAll(dbDir, 0755)

@@ -265,17 +252,17 @@ Colorful: true,

}, ) - dialector := sqlite.Open(dbPath + "?_pragma=foreign_keys(1)") + dialector := sqlite.Open(dbPath + "?Pragma=foreignKeys(1)") config := &gorm.Config{Logger: newLogger} // Open connection to the database - db, err = gorm.Open(dialector, config) + conn, err := gorm.Open(dialector, config) if err != nil { return } // Get the underlying SQL database to set connection parameters - sqlDB, err := db.DB() + sqlDB, err := conn.DB() if err != nil { return nil, err }

@@ -286,16 +273,14 @@ sqlDB.SetMaxOpenConns(100)

sqlDB.SetConnMaxLifetime(time.Hour) // Auto migrate the models - err = db.AutoMigrate( - Equipment{}, - MuscleGroup{}, + err = conn.AutoMigrate( + Muscle{}, Exercise{}, - ExerciseMuscleGroup{}, Set{}, SuperSet{}, RoutineItem{}, Routine{}, - //User{}, + User{}, RecordRoutine{}, RecordExercise{}, RecordSuperSet{},

@@ -307,8 +292,10 @@ if err != nil {

return } + db = &Database{conn} + // Ensure initial data is present - err = CheckInitialData(db) + err = db.CheckInitialData() if err != nil { return nil, err }
A ui/.gitignore

@@ -0,0 +1,24 @@

+# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw?
A ui/README.md

@@ -0,0 +1,31 @@

+# Fitness Tracker App (Vite + React + TypeScript) + +This project is a mobile-first, responsive fitness tracker app built with Vite, React, and TypeScript. + +## Features +- **Home**: View all completed workouts and key statistics. +- **Workouts**: Browse all saved routines and start a new workout from a routine. +- **New Routine**: Create a new routine by combining different exercises. +- **New Exercise**: Add a new exercise to the database. +- **New Workout**: Log a new workout (an execution of a routine). +- **Profile**: View user profile (name, sex, weight, date of birth). + +### UI/UX +- Single-user, no registration or login. +- Entirely in English. +- Mobile-first design, fully responsive. +- On mobile: fixed bottom navigation bar (Apple style) with "Home", "Workout", "Profile". +- On desktop: navigation menu is positioned appropriately for desktop use. + +## Getting Started + +```bash +npm install +npm run dev +``` + +Open the app in your browser at the provided local address. + +--- + +This project was bootstrapped with Vite using the React + TypeScript template.
A ui/eslint.config.js

@@ -0,0 +1,28 @@

+import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +)
A ui/index.html

@@ -0,0 +1,13 @@

+<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" type="image/svg+xml" href="/vite.svg" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Vite + React + TS</title> + </head> + <body> + <div id="root"></div> + <script type="module" src="/src/main.tsx"></script> + </body> +</html>
A ui/package-lock.json

@@ -0,0 +1,3395 @@

+{ + "name": "ui", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ui", + "version": "0.0.0", + "dependencies": { + "@types/react-router-dom": "^5.3.3", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-icons": "^5.5.0", + "react-router-dom": "^7.6.0" + }, + "devDependencies": { + "@eslint/js": "^9.25.0", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@vitejs/plugin-react": "^4.4.1", + "eslint": "^9.25.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.19", + "globals": "^16.0.0", + "typescript": "~5.8.3", + "typescript-eslint": "^8.30.1", + "vite": "^6.3.5" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.2.tgz", + "integrity": "sha512-TUtMJYRPyUb/9aU8f3K0mjmjf6M9N5Woshn2CS6nqJSeJtTtQcpLUXjGt9vbF8ZGff0El99sWkLgzwW3VXnxZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.1.tgz", + "integrity": "sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.1", + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helpers": "^7.27.1", + "@babel/parser": "^7.27.1", + "@babel/template": "^7.27.1", + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.1.tgz", + "integrity": "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.27.1", + "@babel/types": "^7.27.1", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.1.tgz", + "integrity": "sha512-9yHn519/8KvTU5BjTVEEeIM3w9/2yXNKoD82JifINImhpKkARMJKPP59kLo+BafpdN5zgNeIcS4jsGDmd3l58g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.1.tgz", + "integrity": "sha512-FCvFTm0sWV8Fxhpp2McP5/W53GPllQ9QeQ7SiqGWjMf/LVG07lFa5+pgK05IRhVwtvafT22KF+ZSnM9I545CvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.2.tgz", + "integrity": "sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.1.tgz", + "integrity": "sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.1", + "@babel/parser": "^7.27.1", + "@babel/template": "^7.27.1", + "@babel/types": "^7.27.1", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/types": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz", + "integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", + "integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz", + "integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz", + "integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz", + "integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz", + "integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz", + "integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz", + "integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz", + "integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz", + "integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz", + "integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz", + "integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz", + "integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz", + "integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz", + "integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz", + "integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz", + "integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz", + "integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz", + "integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz", + "integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz", + "integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz", + "integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz", + "integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz", + "integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz", + "integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz", + "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", + "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.2.tgz", + "integrity": "sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", + "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.27.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.27.0.tgz", + "integrity": "sha512-G5JD9Tu5HJEu4z2Uo4aHY2sLV64B7CDMXxFzqzjl3NKd6RVzSXNoE80jk7Y0lJkTTkjiIhBAqmlYwjuBY3tvpA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.1.tgz", + "integrity": "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.14.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.41.0.tgz", + "integrity": "sha512-KxN+zCjOYHGwCl4UCtSfZ6jrq/qi88JDUtiEFk8LELEHq2Egfc/FgW+jItZiOLRuQfb/3xJSgFuNPC9jzggX+A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.41.0.tgz", + "integrity": "sha512-yDvqx3lWlcugozax3DItKJI5j05B0d4Kvnjx+5mwiUpWramVvmAByYigMplaoAQ3pvdprGCTCE03eduqE/8mPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.41.0.tgz", + "integrity": "sha512-2KOU574vD3gzcPSjxO0eyR5iWlnxxtmW1F5CkNOHmMlueKNCQkxR6+ekgWyVnz6zaZihpUNkGxjsYrkTJKhkaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.41.0.tgz", + "integrity": "sha512-gE5ACNSxHcEZyP2BA9TuTakfZvULEW4YAOtxl/A/YDbIir/wPKukde0BNPlnBiP88ecaN4BJI2TtAd+HKuZPQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.41.0.tgz", + "integrity": "sha512-GSxU6r5HnWij7FoSo7cZg3l5GPg4HFLkzsFFh0N/b16q5buW1NAWuCJ+HMtIdUEi6XF0qH+hN0TEd78laRp7Dg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.41.0.tgz", + "integrity": "sha512-KGiGKGDg8qLRyOWmk6IeiHJzsN/OYxO6nSbT0Vj4MwjS2XQy/5emsmtoqLAabqrohbgLWJ5GV3s/ljdrIr8Qjg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.41.0.tgz", + "integrity": "sha512-46OzWeqEVQyX3N2/QdiU/CMXYDH/lSHpgfBkuhl3igpZiaB3ZIfSjKuOnybFVBQzjsLwkus2mjaESy8H41SzvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.41.0.tgz", + "integrity": "sha512-lfgW3KtQP4YauqdPpcUZHPcqQXmTmH4nYU0cplNeW583CMkAGjtImw4PKli09NFi2iQgChk4e9erkwlfYem6Lg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.41.0.tgz", + "integrity": "sha512-nn8mEyzMbdEJzT7cwxgObuwviMx6kPRxzYiOl6o/o+ChQq23gfdlZcUNnt89lPhhz3BYsZ72rp0rxNqBSfqlqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.41.0.tgz", + "integrity": "sha512-l+QK99je2zUKGd31Gh+45c4pGDAqZSuWQiuRFCdHYC2CSiO47qUWsCcenrI6p22hvHZrDje9QjwSMAFL3iwXwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.41.0.tgz", + "integrity": "sha512-WbnJaxPv1gPIm6S8O/Wg+wfE/OzGSXlBMbOe4ie+zMyykMOeqmgD1BhPxZQuDqwUN+0T/xOFtL2RUWBspnZj3w==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.41.0.tgz", + "integrity": "sha512-eRDWR5t67/b2g8Q/S8XPi0YdbKcCs4WQ8vklNnUYLaSWF+Cbv2axZsp4jni6/j7eKvMLYCYdcsv8dcU+a6QNFg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.41.0.tgz", + "integrity": "sha512-TWrZb6GF5jsEKG7T1IHwlLMDRy2f3DPqYldmIhnA2DVqvvhY2Ai184vZGgahRrg8k9UBWoSlHv+suRfTN7Ua4A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.41.0.tgz", + "integrity": "sha512-ieQljaZKuJpmWvd8gW87ZmSFwid6AxMDk5bhONJ57U8zT77zpZ/TPKkU9HpnnFrM4zsgr4kiGuzbIbZTGi7u9A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.41.0.tgz", + "integrity": "sha512-/L3pW48SxrWAlVsKCN0dGLB2bi8Nv8pr5S5ocSM+S0XCn5RCVCXqi8GVtHFsOBBCSeR+u9brV2zno5+mg3S4Aw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.41.0.tgz", + "integrity": "sha512-XMLeKjyH8NsEDCRptf6LO8lJk23o9wvB+dJwcXMaH6ZQbbkHu2dbGIUindbMtRN6ux1xKi16iXWu6q9mu7gDhQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.41.0.tgz", + "integrity": "sha512-m/P7LycHZTvSQeXhFmgmdqEiTqSV80zn6xHaQ1JSqwCtD1YGtwEK515Qmy9DcB2HK4dOUVypQxvhVSy06cJPEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.41.0.tgz", + "integrity": "sha512-4yodtcOrFHpbomJGVEqZ8fzD4kfBeCbpsUy5Pqk4RluXOdsWdjLnjhiKy2w3qzcASWd04fp52Xz7JKarVJ5BTg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.41.0.tgz", + "integrity": "sha512-tmazCrAsKzdkXssEc65zIE1oC6xPHwfy9d5Ta25SRCDOZS+I6RypVVShWALNuU9bxIfGA0aqrmzlzoM5wO5SPQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.41.0.tgz", + "integrity": "sha512-h1J+Yzjo/X+0EAvR2kIXJDuTuyT7drc+t2ALY0nIcGPbTatNOf0VWdhEA2Z4AAjv6X1NJV7SYo5oCTYRJhSlVA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", + "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/estree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/history": { + "version": "4.7.11", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.1.4", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.4.tgz", + "integrity": "sha512-EB1yiiYdvySuIITtD5lhW4yPyJ31RkJkkDw794LaQYrxCSaQV/47y5o1FMC4zF9ZyjUjzJMZwbovEnT5yHTW6g==", + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.1.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.5.tgz", + "integrity": "sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/@types/react-router": { + "version": "5.1.20", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", + "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "node_modules/@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.32.1.tgz", + "integrity": "sha512-6u6Plg9nP/J1GRpe/vcjjabo6Uc5YQPAMxsgQyGC/I0RuukiG1wIe3+Vtg3IrSCVJDmqK3j8adrtzXSENRtFgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.32.1", + "@typescript-eslint/type-utils": "8.32.1", + "@typescript-eslint/utils": "8.32.1", + "@typescript-eslint/visitor-keys": "8.32.1", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.4.tgz", + "integrity": "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.32.1.tgz", + "integrity": "sha512-LKMrmwCPoLhM45Z00O1ulb6jwyVr2kr3XJp+G+tSEZcbauNnScewcQwtJqXDhXeYPDEjZ8C1SjXm015CirEmGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.32.1", + "@typescript-eslint/types": "8.32.1", + "@typescript-eslint/typescript-estree": "8.32.1", + "@typescript-eslint/visitor-keys": "8.32.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.32.1.tgz", + "integrity": "sha512-7IsIaIDeZn7kffk7qXC3o6Z4UblZJKV3UBpkvRNpr5NSyLji7tvTcvmnMNYuYLyh26mN8W723xpo3i4MlD33vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.32.1", + "@typescript-eslint/visitor-keys": "8.32.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.32.1.tgz", + "integrity": "sha512-mv9YpQGA8iIsl5KyUPi+FGLm7+bA4fgXaeRcFKRDRwDMu4iwrSHeDPipwueNXhdIIZltwCJv+NkxftECbIZWfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.32.1", + "@typescript-eslint/utils": "8.32.1", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.32.1.tgz", + "integrity": "sha512-YmybwXUJcgGqgAp6bEsgpPXEg6dcCyPyCSr0CAAueacR/CCBi25G3V8gGQ2kRzQRBNol7VQknxMs9HvVa9Rvfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.32.1.tgz", + "integrity": "sha512-Y3AP9EIfYwBb4kWGb+simvPaqQoT5oJuzzj9m0i6FCY6SPvlomY2Ei4UEMm7+FXtlNJbor80ximyslzaQF6xhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.32.1", + "@typescript-eslint/visitor-keys": "8.32.1", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.32.1.tgz", + "integrity": "sha512-DsSFNIgLSrc89gpq1LJB7Hm1YpuhK086DRDJSNrewcGvYloWW1vZLHBTIvarKZDcAORIy/uWNx8Gad+4oMpkSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.32.1", + "@typescript-eslint/types": "8.32.1", + "@typescript-eslint/typescript-estree": "8.32.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.32.1.tgz", + "integrity": "sha512-ar0tjQfObzhSaW3C3QNmTc5ofj0hDoNQ5XWrCy6zDyabdr0TWhCkClp+rywGNj/odAFBVzzJrK4tEq5M4Hmu4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.32.1", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.4.1.tgz", + "integrity": "sha512-IpEm5ZmeXAP/osiBXVVP5KjFMzbWOonMs0NaQQl+xYnUAcq4oHUBsF2+p4MgKWG4YMmFYJU8A6sxRPuowllm6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.26.10", + "@babel/plugin-transform-react-jsx-self": "^7.25.9", + "@babel/plugin-transform-react-jsx-source": "^7.25.9", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" + } + }, + "node_modules/acorn": { + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.24.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz", + "integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001716", + "electron-to-chromium": "^1.5.149", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001718", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz", + "integrity": "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.155", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.155.tgz", + "integrity": "sha512-ps5KcGGmwL8VaeJlvlDlu4fORQpv3+GIcF5I3f9tUKUlJ/wsysh6HU8P5L1XWRYeXfA0oJd4PyM8ds8zTFf6Ng==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", + "integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.4", + "@esbuild/android-arm": "0.25.4", + "@esbuild/android-arm64": "0.25.4", + "@esbuild/android-x64": "0.25.4", + "@esbuild/darwin-arm64": "0.25.4", + "@esbuild/darwin-x64": "0.25.4", + "@esbuild/freebsd-arm64": "0.25.4", + "@esbuild/freebsd-x64": "0.25.4", + "@esbuild/linux-arm": "0.25.4", + "@esbuild/linux-arm64": "0.25.4", + "@esbuild/linux-ia32": "0.25.4", + "@esbuild/linux-loong64": "0.25.4", + "@esbuild/linux-mips64el": "0.25.4", + "@esbuild/linux-ppc64": "0.25.4", + "@esbuild/linux-riscv64": "0.25.4", + "@esbuild/linux-s390x": "0.25.4", + "@esbuild/linux-x64": "0.25.4", + "@esbuild/netbsd-arm64": "0.25.4", + "@esbuild/netbsd-x64": "0.25.4", + "@esbuild/openbsd-arm64": "0.25.4", + "@esbuild/openbsd-x64": "0.25.4", + "@esbuild/sunos-x64": "0.25.4", + "@esbuild/win32-arm64": "0.25.4", + "@esbuild/win32-ia32": "0.25.4", + "@esbuild/win32-x64": "0.25.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.27.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.27.0.tgz", + "integrity": "sha512-ixRawFQuMB9DZ7fjU3iGGganFDp3+45bPOdaRurcFHSXO1e/sYwUX/FtQZpLZJR6SjMoJH8hR2pPEAfDyCoU2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.20.0", + "@eslint/config-helpers": "^0.2.1", + "@eslint/core": "^0.14.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.27.0", + "@eslint/plugin-kit": "^0.3.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.3.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.20", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz", + "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", + "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", + "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.14.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.1.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.1.0.tgz", + "integrity": "sha512-aibexHNbb/jiUSObBgpHLj+sIuUmJnYcgXBlrfsiDZ9rt4aF2TFRbyLgZ2iFQuVZ1K5Mx3FVkbKRSgKrbK3K2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, + "node_modules/react-icons": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.0.tgz", + "integrity": "sha512-GGufuHIVCJDbnIAXP3P9Sxzq3UUsddG3rrI3ut1q6m0FI6vxVBF3JoPQ38+W/blslLH4a5Yutp8drkEpXoddGQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.0.tgz", + "integrity": "sha512-DYgm6RDEuKdopSyGOWZGtDfSm7Aofb8CCzgkliTjtu/eDuB0gcsv6qdFhhi8HdtmA+KHkt5MfZ5K2PdzjugYsA==", + "license": "MIT", + "dependencies": { + "react-router": "7.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.41.0.tgz", + "integrity": "sha512-HqMFpUbWlf/tvcxBFNKnJyzc7Lk+XO3FGc3pbNBLqEbOz0gPLRgcrlS3UF4MfUrVlstOaP/q0kM6GVvi+LrLRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.7" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.41.0", + "@rollup/rollup-android-arm64": "4.41.0", + "@rollup/rollup-darwin-arm64": "4.41.0", + "@rollup/rollup-darwin-x64": "4.41.0", + "@rollup/rollup-freebsd-arm64": "4.41.0", + "@rollup/rollup-freebsd-x64": "4.41.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.41.0", + "@rollup/rollup-linux-arm-musleabihf": "4.41.0", + "@rollup/rollup-linux-arm64-gnu": "4.41.0", + "@rollup/rollup-linux-arm64-musl": "4.41.0", + "@rollup/rollup-linux-loongarch64-gnu": "4.41.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.41.0", + "@rollup/rollup-linux-riscv64-gnu": "4.41.0", + "@rollup/rollup-linux-riscv64-musl": "4.41.0", + "@rollup/rollup-linux-s390x-gnu": "4.41.0", + "@rollup/rollup-linux-x64-gnu": "4.41.0", + "@rollup/rollup-linux-x64-musl": "4.41.0", + "@rollup/rollup-win32-arm64-msvc": "4.41.0", + "@rollup/rollup-win32-ia32-msvc": "4.41.0", + "@rollup/rollup-win32-x64-msvc": "4.41.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", + "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.32.1.tgz", + "integrity": "sha512-D7el+eaDHAmXvrZBy1zpzSNIRqnCOrkwTgZxTu3MUqRWk8k0q9m9Ho4+vPf7iHtgUfrK/o8IZaEApsxPlHTFCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.32.1", + "@typescript-eslint/parser": "8.32.1", + "@typescript-eslint/utils": "8.32.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +}
A ui/package.json

@@ -0,0 +1,32 @@

+{ + "name": "ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@types/react-router-dom": "^5.3.3", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-icons": "^5.5.0", + "react-router-dom": "^7.6.0" + }, + "devDependencies": { + "@eslint/js": "^9.25.0", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@vitejs/plugin-react": "^4.4.1", + "eslint": "^9.25.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.19", + "globals": "^16.0.0", + "typescript": "~5.8.3", + "typescript-eslint": "^8.30.1", + "vite": "^6.3.5" + } +}
A ui/src/App.css

@@ -0,0 +1,216 @@

+:root { + --primary-color: #007aff; + --secondary-color: #5ac8fa; + --background-color: #f5f5f7; + --text-color: #1d1d1f; + --light-gray: #d1d1d6; + --dark-gray: #8e8e93; + --success-color: #34c759; + --danger-color: #ff3b30; + --warning-color: #ff9500; + --border-radius: 8px; + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 16px; + --spacing-lg: 24px; + --spacing-xl: 32px; + --bottom-nav-height: 60px; + --side-nav-width: 250px; + --header-height: 60px; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, + Arial, sans-serif; + background-color: var(--background-color); + color: var(--text-color); + line-height: 1.5; +} + +#root { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* App container */ +.app-container { + flex: 1; + display: flex; + flex-direction: column; + max-width: 100%; +} + +/* Mobile layout */ +.mobile-layout .content-area { + padding: var(--spacing-md); + padding-bottom: calc(var(--bottom-nav-height) + var(--spacing-md)); +} + +/* Desktop layout */ +.desktop-layout { + flex-direction: row; +} + +.desktop-layout .content-area { + flex: 1; + margin-left: var(--side-nav-width); + padding: var(--spacing-lg); +} + +/* Page styling */ +.page { + max-width: 1200px; + margin: 0 auto; +} + +/* Typography */ +h1 { + font-size: 24px; + font-weight: 600; + margin-bottom: var(--spacing-lg); +} + +h2 { + font-size: 20px; + font-weight: 500; + margin-bottom: var(--spacing-md); +} + +h3 { + font-size: 18px; + font-weight: 500; + margin-bottom: var(--spacing-md); +} + +/* Form elements */ +.form-group { + margin-bottom: var(--spacing-lg); +} + +label { + display: block; + margin-bottom: var(--spacing-xs); + font-weight: 500; +} + +input, select, textarea { + width: 100%; + padding: var(--spacing-sm); + border: 1px solid var(--light-gray); + border-radius: var(--border-radius); + font-size: 16px; + background-color: white; +} + +input:focus, select:focus, textarea:focus { + outline: none; + border-color: var(--primary-color); +} + +/* Buttons */ +.btn { + display: inline-block; + padding: 10px 16px; + border: none; + border-radius: var(--border-radius); + font-size: 16px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + text-align: center; +} + +.btn-primary { + background-color: var(--primary-color); + color: white; +} + +.btn-secondary { + background-color: var(--light-gray); + color: var(--text-color); +} + +.btn-success { + background-color: var(--success-color); + color: white; +} + +.btn-danger { + background-color: var(--danger-color); + color: white; +} + +.btn-block { + display: block; + width: 100%; +} + +/* Cards */ +.card { + background-color: white; + border-radius: var(--border-radius); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + padding: var(--spacing-lg); + margin-bottom: var(--spacing-lg); +} + +/* Lists */ +.list-item { + padding: var(--spacing-md); + border-bottom: 1px solid var(--light-gray); +} + +.list-item:last-child { + border-bottom: none; +} + +/* Utilities */ +.text-center { + text-align: center; +} + +.text-right { + text-align: right; +} + +.flex { + display: flex; +} + +.flex-col { + flex-direction: column; +} + +.justify-between { + justify-content: space-between; +} + +.items-center { + align-items: center; +} + +.mt-sm { margin-top: var(--spacing-sm); } +.mt-md { margin-top: var(--spacing-md); } +.mt-lg { margin-top: var(--spacing-lg); } + +.mb-sm { margin-bottom: var(--spacing-sm); } +.mb-md { margin-bottom: var(--spacing-md); } +.mb-lg { margin-bottom: var(--spacing-lg); } + +/* Responsive layouts */ +@media (max-width: 767px) { + h1 { + font-size: 22px; + } + + h2 { + font-size: 18px; + } +}
A ui/src/App.tsx

@@ -0,0 +1,50 @@

+import { useEffect, useState } from "react"; +import { BrowserRouter as Router, Routes, Route, Navigate } from "react-router-dom"; +import HomePage from "./pages/HomePage"; +import WorkoutsPage from "./pages/WorkoutsPage"; +import NewRoutinePage from "./pages/NewRoutinePage"; +import NewExercisePage from "./pages/NewExercisePage"; +import NewWorkoutPage from "./pages/NewWorkoutPage"; +import ProfilePage from "./pages/ProfilePage"; +import BottomNav from "./components/BottomNav"; +import { AppProvider } from "./context/AppContext"; +import "./App.css"; + +function App() { + const [isMobile, setIsMobile] = useState(window.innerWidth < 768); + + // Check for device width to determine if mobile or desktop + useEffect(() => { + const handleResize = () => { + setIsMobile(window.innerWidth < 768); + }; + + window.addEventListener('resize', handleResize); + return () => { + window.removeEventListener('resize', handleResize); + }; + }, []); + + return ( + <AppProvider> + <Router> + <div className={`app-container ${isMobile ? 'mobile-layout' : 'desktop-layout'}`}> + <div className="content-area"> + <Routes> + <Route path="/" element={<Navigate to="/home" replace />} /> + <Route path="/home" element={<HomePage />} /> + <Route path="/workouts" element={<WorkoutsPage />} /> + <Route path="/new-routine" element={<NewRoutinePage />} /> + <Route path="/new-exercise" element={<NewExercisePage />} /> + <Route path="/new-workout" element={<NewWorkoutPage />} /> + <Route path="/profile" element={<ProfilePage />} /> + </Routes> + </div> + <BottomNav isMobile={isMobile} /> + </div> + </Router> + </AppProvider> + ); +} + +export default App;
A ui/src/assets/react.svg

@@ -0,0 +1,1 @@

+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
A ui/src/components/BottomNav.css

@@ -0,0 +1,117 @@

+.bottom-nav.mobile { + position: fixed; + left: 0; + right: 0; + bottom: 0; + height: var(--bottom-nav-height); + background: #fff; + border-top: 1px solid var(--light-gray); + display: flex; + justify-content: space-around; + align-items: center; + z-index: 1000; + box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05); +} + +.bottom-nav.mobile a { + color: var(--dark-gray); + text-decoration: none; + font-size: 0.75rem; + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 8px 0; + transition: color 0.2s; +} + +.bottom-nav.mobile a span { + margin-top: 4px; +} + +.bottom-nav.mobile a.active { + color: var(--primary-color); + font-weight: 600; +} + +/* Desktop side navigation */ +.side-nav.desktop { + position: fixed; + left: 0; + top: 0; + bottom: 0; + width: var(--side-nav-width); + background-color: white; + border-right: 1px solid var(--light-gray); + padding: var(--spacing-lg) var(--spacing-md); + display: flex; + flex-direction: column; + z-index: 1000; +} + +.side-nav .app-title { + font-size: 24px; + font-weight: 700; + color: var(--primary-color); + margin-bottom: var(--spacing-xl); + padding: var(--spacing-md); +} + +.side-nav .nav-links { + display: flex; + flex-direction: column; + gap: 8px; +} + +.side-nav a { + display: flex; + align-items: center; + padding: 10px 16px; + border-radius: var(--border-radius); + color: var(--text-color); + text-decoration: none; + transition: all 0.2s; +} + +.side-nav a span { + margin-left: 12px; +} + +.side-nav a:hover { + background-color: rgba(0, 122, 255, 0.1); +} + +.side-nav a.active { + background-color: var(--primary-color); + color: white; +} + +.side-nav .sub-menu { + margin: var(--spacing-md) 0; + padding-left: var(--spacing-md); + border-left: 2px solid var(--light-gray); +} + +.side-nav .sub-menu-title { + font-size: 14px; + font-weight: 600; + color: var(--dark-gray); + margin-bottom: var(--spacing-sm); + padding-left: var(--spacing-md); +} +@media (min-width: 768px) { + .bottom-nav { + position: static; + height: 48px; + border-top: none; + border-bottom: 1px solid #eee; + justify-content: flex-end; + background: #fafbfc; + } + .bottom-nav a { + flex: none; + margin-left: 2em; + font-size: 1rem; + } +}
A ui/src/components/BottomNav.tsx

@@ -0,0 +1,70 @@

+import { NavLink } from "react-router-dom"; +import { + FaHome, + FaDumbbell, + FaUserAlt, + FaPlus +} from "react-icons/fa"; +import "./BottomNav.css"; + +interface BottomNavProps { + isMobile: boolean; +} + +const BottomNav = ({ isMobile }: BottomNavProps) => { + if (isMobile) { + return ( + <nav className="bottom-nav mobile"> + <NavLink to="/home" className={({ isActive }) => isActive ? "active" : ""}> + <FaHome size={20} /> + <span>Home</span> + </NavLink> + <NavLink to="/workouts" className={({ isActive }) => isActive ? "active" : ""}> + <FaDumbbell size={20} /> + <span>Workouts</span> + </NavLink> + <NavLink to="/profile" className={({ isActive }) => isActive ? "active" : ""}> + <FaUserAlt size={20} /> + <span>Profile</span> + </NavLink> + </nav> + ); + } + + return ( + <nav className="side-nav desktop"> + <div className="app-title">Go Lift</div> + <div className="nav-links"> + <NavLink to="/home" className={({ isActive }) => isActive ? "active" : ""}> + <FaHome size={20} /> + <span>Home</span> + </NavLink> + <NavLink to="/workouts" className={({ isActive }) => isActive ? "active" : ""}> + <FaDumbbell size={20} /> + <span>Workouts</span> + </NavLink> + <div className="sub-menu"> + <div className="sub-menu-title">Create New</div> + <NavLink to="/new-exercise" className={({ isActive }) => isActive ? "active" : ""}> + <FaPlus size={16} /> + <span>New Exercise</span> + </NavLink> + <NavLink to="/new-routine" className={({ isActive }) => isActive ? "active" : ""}> + <FaPlus size={16} /> + <span>New Routine</span> + </NavLink> + <NavLink to="/new-workout" className={({ isActive }) => isActive ? "active" : ""}> + <FaPlus size={16} /> + <span>New Workout</span> + </NavLink> + </div> + <NavLink to="/profile" className={({ isActive }) => isActive ? "active" : ""}> + <FaUserAlt size={20} /> + <span>Profile</span> + </NavLink> + </div> + </nav> + ); +}; + +export default BottomNav;
A ui/src/context/AppContext.tsx

@@ -0,0 +1,78 @@

+import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'; +import type { User } from '../types/models'; +import { ProfileService } from '../services/api'; + +interface AppContextType { + user: User | null; + isLoading: boolean; + error: string | null; + updateUser: (profile: User) => Promise<void>; +} + +const defaultProfile: User = { + name: 'User', + isFemale: false, + weight: 85, + height: 180, + birthDate: "01/01/1990", +}; + +const AppContext = createContext<AppContextType>({ + user: defaultProfile, + isLoading: false, + error: null, + updateUser: async () => {}, +}); + +export const useAppContext = () => useContext(AppContext); + +interface AppProviderProps { + children: ReactNode; +} + +export const AppProvider = ({ children }: AppProviderProps) => { + const [user, setUser] = useState<User | null>(null); + const [isLoading, setIsLoading] = useState<boolean>(true); + const [error, setError] = useState<string | null>(null); + + useEffect(() => { + const loadProfile = async () => { + try { + setIsLoading(true); + const profile = await ProfileService.get(); + setUser(profile); + setError(null); + } catch (err) { + console.error('Failed to load user profile:', err); + // For the single-user mode, create a default profile if none exists + setUser(defaultProfile); + setError('Could not load profile. Using default settings.'); + } finally { + setIsLoading(false); + } + }; + + loadProfile(); + }, []); + + const updateUser = async (profile: User) => { + try { + setIsLoading(true); + const updatedProfile = await ProfileService.update(profile); + setUser(updatedProfile); + setError(null); + } catch (err) { + console.error('Failed to update profile:', err); + setError('Failed to update profile. Please try again.'); + throw err; + } finally { + setIsLoading(false); + } + }; + + return ( + <AppContext.Provider value={{ user, isLoading, error, updateUser }}> + {children} + </AppContext.Provider> + ); +};
A ui/src/index.css

@@ -0,0 +1,66 @@

+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); + +:root { + line-height: 1.5; + font-weight: 400; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + height: 100%; +} + +body { + margin: 0; + min-height: 100vh; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, + Arial, sans-serif; + background-color: var(--background-color, #f5f5f7); + color: var(--text-color, #1d1d1f); +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +}
A ui/src/main.tsx

@@ -0,0 +1,10 @@

+import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + <StrictMode> + <App /> + </StrictMode>, +)
A ui/src/pages/HomePage.tsx

@@ -0,0 +1,245 @@

+import { useEffect, useState } from 'react'; +import { FaCalendarCheck, FaClock, FaDumbbell } from 'react-icons/fa'; +import type { RecordRoutine, WorkoutStats } from '../types/models'; +import { WorkoutService } from '../services/api'; + +const HomePage = () => { + const [stats, setStats] = useState<WorkoutStats | null>(null); + const [loading, setLoading] = useState<boolean>(true); + const [error, setError] = useState<string | null>(null); + + useEffect(() => { + const fetchStats = async () => { + try { + setLoading(true); + const data = await WorkoutService.getStats(); + setStats(data); + setError(null); + } catch (err) { + console.error('Failed to fetch workout stats:', err); + setError('Could not load workout statistics. Please try again later.'); + } finally { + setLoading(false); + } + }; + + fetchStats(); + }, []); + + const formatDate = (dateStr: string) => { + const date = new Date(dateStr); + return new Intl.DateTimeFormat('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }).format(date); + }; + + const calculateDuration = (start: string, end: string) => { + const startTime = new Date(start).getTime(); + const endTime = new Date(end).getTime(); + const durationMinutes = Math.round((endTime - startTime) / (1000 * 60)); + + return `${durationMinutes} min`; + }; + + if (loading) { + return ( + <div className="page home-page"> + <h1>Home</h1> + <div className="loading">Loading workout data...</div> + </div> + ); + } + + if (error) { + return ( + <div className="page home-page"> + <h1>Home</h1> + <div className="error-message">{error}</div> + </div> + ); + } + + // Display placeholder if no stats + if (!stats) { + return ( + <div className="page home-page"> + <h1>Workout Overview</h1> + <div className="card"> + <h2>Welcome to Go Lift!</h2> + <p>Start by adding exercises and creating your first workout routine.</p> + <div className="mt-lg"> + <a href="/workouts" className="btn btn-primary">Go to Workouts</a> + </div> + </div> + </div> + ); + } + + return ( + <div className="page home-page"> + <h1>Workout Overview</h1> + + {/* Statistics Cards */} + <div className="stats-grid"> + <div className="card stat-card"> + <div className="stat-icon"> + <FaCalendarCheck size={24} /> + </div> + <div className="stat-content"> + <div className="stat-value">{stats.totalWorkouts}</div> + <div className="stat-label">Total Workouts</div> + </div> + </div> + + <div className="card stat-card"> + <div className="stat-icon"> + <FaClock size={24} /> + </div> + <div className="stat-content"> + <div className="stat-value">{stats.totalMinutes}</div> + <div className="stat-label">Total Minutes</div> + </div> + </div> + + <div className="card stat-card"> + <div className="stat-icon"> + <FaDumbbell size={24} /> + </div> + <div className="stat-content"> + <div className="stat-value">{stats.totalExercises}</div> + <div className="stat-label">Exercises Done</div> + </div> + </div> + </div> + + {/* Favorite Data */} + {(stats.mostFrequentExercise || stats.mostFrequentRoutine) && ( + <div className="card mb-lg"> + <h2>Your Favorites</h2> + {stats.mostFrequentRoutine && ( + <div className="favorite-item"> + <div className="favorite-label">Most Used Routine:</div> + <div className="favorite-value">{stats.mostFrequentRoutine.name} ({stats.mostFrequentRoutine.count}x)</div> + </div> + )} + {stats.mostFrequentExercise && ( + <div className="favorite-item"> + <div className="favorite-label">Most Performed Exercise:</div> + <div className="favorite-value">{stats.mostFrequentExercise.name} ({stats.mostFrequentExercise.count}x)</div> + </div> + )} + </div> + )} + + {/* Recent Workouts */} + <h2>Recent Workouts</h2> + {stats.recentWorkouts && stats.recentWorkouts.length > 0 ? ( + stats.recentWorkouts.map((workout: RecordRoutine) => ( + <div key={workout.id} className="card workout-card"> + <div className="workout-header"> + <h3>{workout.routine?.name || 'Workout'}</h3> + <div className="workout-date">{formatDate(workout.startedAt)}</div> + </div> + {workout.endedAt && ( + <div className="workout-duration"> + Duration: {calculateDuration(workout.startedAt, workout.endedAt)} + </div> + )} + <div className="workout-exercises"> + </div> + </div> + )) + ) : ( + <div className="empty-state"> + <p>No workouts recorded yet. Start your fitness journey today!</p> + <a href="/new-workout" className="btn btn-primary mt-md">Record a Workout</a> + </div> + )} + + <style>{` + .stats-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: var(--spacing-md); + margin-bottom: var(--spacing-lg); + } + + .stat-card { + display: flex; + align-items: center; + } + + .stat-icon { + display: flex; + align-items: center; + justify-content: center; + width: 50px; + height: 50px; + background-color: rgba(0, 122, 255, 0.1); + border-radius: 50%; + color: var(--primary-color); + margin-right: var(--spacing-md); + } + + .stat-value { + font-size: 24px; + font-weight: bold; + } + + .stat-label { + color: var(--dark-gray); + } + + .favorite-item { + display: flex; + justify-content: space-between; + padding: var(--spacing-sm) 0; + border-bottom: 1px solid var(--light-gray); + } + + .favorite-item:last-child { + border-bottom: none; + } + + .favorite-label { + color: var(--dark-gray); + } + + .workout-card { + margin-bottom: var(--spacing-md); + } + + .workout-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-sm); + } + + .workout-date { + color: var(--dark-gray); + font-size: 0.9rem; + } + + .workout-duration, .workout-exercises, .workout-notes { + margin-bottom: var(--spacing-sm); + } + + .workout-feeling { + color: var(--warning-color); + } + + .empty-state { + text-align: center; + padding: var(--spacing-xl); + } + `}</style> + </div> + ); +}; + +export default HomePage;
A ui/src/pages/NewExercisePage.tsx

@@ -0,0 +1,400 @@

+import { useState, useEffect } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { FaSave, FaArrowLeft, FaTrash, FaTimes } from 'react-icons/fa'; +import type { Exercise, Equipment, MuscleGroup } from '../types/models'; +import { ExerciseService, EquipmentService, MuscleGroupService } from '../services/api'; + +const NewExercisePage = () => { + const navigate = useNavigate(); + const location = useLocation(); + const [isLoading, setIsLoading] = useState<boolean>(false); + const [error, setError] = useState<string | null>(null); + const [successMessage, setSuccessMessage] = useState<string | null>(null); + + const [exerciseToEdit, setExerciseToEdit] = useState<Exercise | null>(null); + const [isEditMode, setIsEditMode] = useState<boolean>(false); + + // Available equipment and muscle groups from API + const [availableEquipment, setAvailableEquipment] = useState<Equipment[]>([]); + const [availableMuscleGroups, setAvailableMuscleGroups] = useState<MuscleGroup[]>([]); + + // Selected items + const [selectedEquipment, setSelectedEquipment] = useState<Equipment[]>([]); + const [selectedMuscleGroups, setSelectedMuscleGroups] = useState<MuscleGroup[]>([]); + + // Form state + const [formData, setFormData] = useState<Exercise>({ + name: '', + description: '', + equipment: [], + muscleGroups: [], + sets: [], + }); + + // Fetch equipment and muscle groups from API + useEffect(() => { + const fetchData = async () => { + setIsLoading(true); + try { + const [equipmentData, muscleGroupsData] = await Promise.all([ + EquipmentService.getAll(), + MuscleGroupService.getAll() + ]); + setAvailableEquipment(equipmentData); + setAvailableMuscleGroups(muscleGroupsData); + } catch (err) { + console.error('Failed to fetch data:', err); + setError('Failed to load equipment and muscle groups'); + } finally { + setIsLoading(false); + } + }; + + fetchData(); + }, []); + + // Check if we're editing an existing exercise + useEffect(() => { + if (location.state && location.state.editExercise) { + const exercise = location.state.editExercise as Exercise; + setFormData(exercise); + setSelectedEquipment(exercise.equipment || []); + setSelectedMuscleGroups(exercise.muscleGroups || []); + setExerciseToEdit(exercise); + setIsEditMode(true); + } + }, [location]); + + const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { + const { name, value } = e.target; + + setFormData(prev => ({ + ...prev, + [name]: value, + })); + }; + + const handleEquipmentSelect = (e: React.ChangeEvent<HTMLSelectElement>) => { + const equipmentId = parseInt(e.target.value); + const equipment = availableEquipment.find(eq => eq.id === equipmentId); + + if (equipment && !selectedEquipment.some(e => e.id === equipment.id)) { + const updatedEquipment = [...selectedEquipment, equipment]; + setSelectedEquipment(updatedEquipment); + setFormData(prev => ({ + ...prev, + equipment: updatedEquipment + })); + } + + // Reset select to default + e.target.value = ''; + }; + + const removeEquipment = (equipmentId?: number) => { + if (!equipmentId) return; + const updatedEquipment = selectedEquipment.filter(e => e.id !== equipmentId); + setSelectedEquipment(updatedEquipment); + setFormData(prev => ({ + ...prev, + equipment: updatedEquipment + })); + }; + + const handleMuscleGroupSelect = (e: React.ChangeEvent<HTMLSelectElement>) => { + const muscleGroupId = parseInt(e.target.value); + const muscleGroup = availableMuscleGroups.find(mg => mg.id === muscleGroupId); + + if (muscleGroup && !selectedMuscleGroups.some(mg => mg.id === muscleGroup.id)) { + const updatedMuscleGroups = [...selectedMuscleGroups, muscleGroup]; + setSelectedMuscleGroups(updatedMuscleGroups); + setFormData(prev => ({ + ...prev, + muscleGroups: updatedMuscleGroups + })); + } + + // Reset select to default + e.target.value = ''; + }; + + const removeMuscleGroup = (muscleGroupId?: number) => { + if (!muscleGroupId) return; + const updatedMuscleGroups = selectedMuscleGroups.filter(mg => mg.id !== muscleGroupId); + setSelectedMuscleGroups(updatedMuscleGroups); + setFormData(prev => ({ + ...prev, + muscleGroups: updatedMuscleGroups + })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + setError(null); + + try { + if (formData.muscleGroups.length === 0) { + throw new Error('Please select at least one muscle group'); + } + + if (isEditMode && exerciseToEdit?.id) { + // Update existing exercise + await ExerciseService.update(exerciseToEdit.id, formData); + setSuccessMessage('Exercise updated successfully!'); + } else { + // Create new exercise + await ExerciseService.create(formData); + setSuccessMessage('Exercise created successfully!'); + + // Reset form if creating new + if (!isEditMode) { + setFormData({ + name: '', + description: '', + equipment: [], + muscleGroups: [], + sets: [], + }); + setSelectedEquipment([]); + setSelectedMuscleGroups([]); + } + } + + // Show success message briefly then redirect + setTimeout(() => { + navigate('/workouts'); + }, 1500); + } catch (err: unknown) { + console.error('Failed to save exercise:', err); + setError('Failed to save exercise. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + const handleDelete = async () => { + if (!exerciseToEdit?.id || !confirm('Are you sure you want to delete this exercise?')) { + return; + } + + setIsLoading(true); + try { + await ExerciseService.delete(exerciseToEdit.id); + setSuccessMessage('Exercise deleted successfully!'); + + // Redirect after deletion + setTimeout(() => { + navigate('/workouts'); + }, 1500); + } catch (err) { + console.error('Failed to delete exercise:', err); + setError('Failed to delete exercise. It might be used in one or more routines.'); + } finally { + setIsLoading(false); + } + }; + + return ( + <div className="page new-exercise-page"> + <div className="page-header"> + <button + onClick={() => navigate(-1)} + className="btn btn-secondary back-button" + > + <FaArrowLeft /> Back + </button> + <h1>{isEditMode ? 'Edit Exercise' : 'New Exercise'}</h1> + </div> + + {error && <div className="error-message">{error}</div>} + {successMessage && <div className="success-message">{successMessage}</div>} + + <div className="card"> + <form onSubmit={handleSubmit}> + <div className="form-group"> + <label htmlFor="name">Exercise Name*</label> + <input + type="text" + id="name" + name="name" + value={formData.name} + onChange={handleInputChange} + placeholder="e.g. Bench Press" + required + disabled={isLoading} + /> + </div> + + <div className="form-group"> + <label htmlFor="description">Description</label> + <textarea + id="description" + name="description" + value={formData.description} + onChange={handleInputChange} + placeholder="Describe how to perform this exercise properly..." + rows={4} + disabled={isLoading} + /> + </div> + + <div className="form-group"> + <label htmlFor="muscleGroups">Muscle Groups*</label> + <select + id="muscleGroups" + name="muscleGroups" + onChange={handleMuscleGroupSelect} + disabled={isLoading} + > + <option value="">Select muscle group...</option> + {availableMuscleGroups.map(group => ( + <option key={group.id} value={group.id}>{group.name}</option> + ))} + </select> + + <div className="tags-container"> + {selectedMuscleGroups.map(group => ( + <div key={group.id} className="tag"> + {group.name} + <button + type="button" + className="tag-remove" + onClick={() => removeMuscleGroup(group.id)} + disabled={isLoading} + > + <FaTimes /> + </button> + </div> + ))} + </div> + </div> + + <div className="form-group"> + <label htmlFor="equipment">Equipment</label> + <select + id="equipment" + name="equipment" + onChange={handleEquipmentSelect} + disabled={isLoading} + > + <option value="">Select equipment...</option> + {availableEquipment.map(eq => ( + <option key={eq.id} value={eq.id}>{eq.name}</option> + ))} + </select> + + <div className="tags-container"> + {selectedEquipment.map(eq => ( + <div key={eq.id} className="tag"> + {eq.name} + <button + type="button" + className="tag-remove" + onClick={() => removeEquipment(eq.id)} + disabled={isLoading} + > + <FaTimes /> + </button> + </div> + ))} + </div> + </div> + + <div className="form-actions"> + <button + type="submit" + className="btn btn-primary btn-block" + disabled={isLoading} + > + <FaSave /> {isEditMode ? 'Update Exercise' : 'Save Exercise'} + </button> + + {isEditMode && exerciseToEdit?.id && ( + <button + type="button" + className="btn btn-danger btn-block mt-md" + onClick={handleDelete} + disabled={isLoading} + > + <FaTrash /> Delete Exercise + </button> + )} + </div> + </form> + </div> + + <style>{` + .page-header { + display: flex; + align-items: center; + margin-bottom: var(--spacing-lg); + } + + .back-button { + margin-right: var(--spacing-md); + padding: var(--spacing-sm) var(--spacing-md); + } + + .form-actions { + margin-top: var(--spacing-xl); + } + + textarea { + resize: vertical; + } + + .error-message { + background-color: rgba(255, 59, 48, 0.1); + color: var(--danger-color); + padding: var(--spacing-md); + border-radius: var(--border-radius); + margin-bottom: var(--spacing-lg); + } + + .success-message { + background-color: rgba(52, 199, 89, 0.1); + color: var(--success-color); + padding: var(--spacing-md); + border-radius: var(--border-radius); + margin-bottom: var(--spacing-lg); + } + + .tags-container { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-sm); + margin-top: var(--spacing-sm); + } + + .tag { + display: flex; + align-items: center; + background-color: var(--primary-color-light); + color: var(--primary-color-dark); + padding: var(--spacing-xs) var(--spacing-sm); + border-radius: var(--border-radius); + font-size: 0.9rem; + } + + .tag-remove { + border: none; + background: none; + color: var(--primary-color-dark); + margin-left: var(--spacing-xs); + padding: 0; + font-size: 0.8rem; + display: flex; + align-items: center; + cursor: pointer; + } + + .mt-md { + margin-top: var(--spacing-md); + } + `}</style> + </div> + ); +}; + +export default NewExercisePage;
A ui/src/pages/NewRoutinePage.tsx

@@ -0,0 +1,655 @@

+import { useState, useEffect } from 'react'; +import { useNavigate, useLocation, Link } from 'react-router-dom'; +import { + FaPlus, + FaSave, + FaArrowLeft, + FaTrash, + FaArrowUp, + FaArrowDown, + FaFilter, +} from 'react-icons/fa'; +import type { Routine, Exercise, RoutineItem } from '../types/models'; +import { RoutineService, ExerciseService } from '../services/api'; + +const NewRoutinePage = () => { + const navigate = useNavigate(); + const location = useLocation(); + + // State for exercises + const [exercises, setExercises] = useState<Exercise[]>([]); + const [selectedItems, setSelectedItems] = useState<RoutineItem[]>([]); + const [searchTerm, setSearchTerm] = useState<string>(''); + const [muscleFilter, setMuscleFilter] = useState<string>(''); + + // State for routine data + const [routineName, setRoutineName] = useState<string>(''); + const [routineDescription, setRoutineDescription] = useState<string>(''); + const [estimatedDuration, setEstimatedDuration] = useState<number>(0); + + // UI state + const [isLoading, setIsLoading] = useState<boolean>(true); + const [isSaving, setIsSaving] = useState<boolean>(false); + const [error, setError] = useState<string | null>(null); + const [successMessage, setSuccessMessage] = useState<string | null>(null); + + // Track if we're editing an existing routine + const [isEditMode, setIsEditMode] = useState<boolean>(false); + const [routineToEdit, setRoutineToEdit] = useState<Routine | null>(null); + + // Fetch available exercises and check if we're in edit mode + useEffect(() => { + const fetchExercises = async () => { + try { + setIsLoading(true); + const data = await ExerciseService.getAll(); + setExercises(data); + + // Check if we're editing an existing routine + if (location.state && location.state.editRoutine) { + const routine = location.state.editRoutine as Routine; + setRoutineName(routine.name); + setRoutineDescription(routine.description); + + // Set selected items from the routine + if (routine.routineItems && routine.routineItems.length > 0) { + setSelectedItems(routine.routineItems); + } + + setRoutineToEdit(routine); + setIsEditMode(true); + } + + setError(null); + } catch (err) { + console.error('Failed to fetch exercises:', err); + setError('Could not load exercises. Please try again later.'); + } finally { + setIsLoading(false); + } + }; + + fetchExercises(); + }, [location]); + + // Find unique muscle groups for filtering + const muscleGroups = [...new Set(exercises.flatMap(ex => + ex.muscleGroups.map(mg => mg.name) + ))].sort(); + + // Filter exercises based on search and muscle filter + const filteredExercises = exercises.filter(ex => { + const matchesSearch = ex.name.toLowerCase().includes(searchTerm.toLowerCase()) || + ex.description.toLowerCase().includes(searchTerm.toLowerCase()); + const matchesMuscle = !muscleFilter || + ex.muscleGroups.some(mg => mg.name === muscleFilter); + return matchesSearch && matchesMuscle; + }); + + // Handle adding an exercise to the routine + const handleAddExercise = (exercise: Exercise) => { + const newItem: RoutineItem = { + routineId: routineToEdit?.id || 0, + exerciseId: exercise.id, + superSetId: null, + restTime: 60, // Default rest time 60 seconds + orderIndex: selectedItems.length, + exercise: exercise, + superSet: null, + }; + + setSelectedItems([...selectedItems, newItem]); + }; + + // Handle removing an exercise from the routine + const handleRemoveItem = (index: number) => { + const newSelectedItems = [...selectedItems]; + newSelectedItems.splice(index, 1); + + // Update order values + const reorderedItems = newSelectedItems.map((item, i) => ({ + ...item, + orderIndex: i, + })); + + setSelectedItems(reorderedItems); + }; + + // Handle updating exercise details + const handleItemChange = (index: number, field: string, value: any) => { + const newSelectedItems = [...selectedItems]; + // @ts-ignore + newSelectedItems[index][field] = value; + setSelectedItems(newSelectedItems); + }; + + // Handle moving exercises up/down in the order + const handleMoveItem = (index: number, direction: 'up' | 'down') => { + if ( + (direction === 'up' && index === 0) || + (direction === 'down' && index === selectedItems.length - 1) + ) { + return; + } + + const newSelectedItems = [...selectedItems]; + const swapIndex = direction === 'up' ? index - 1 : index + 1; + + // Swap items + [newSelectedItems[index], newSelectedItems[swapIndex]] = + [newSelectedItems[swapIndex], newSelectedItems[index]]; + + // Update order values + const reorderedItems = newSelectedItems.map((item, i) => ({ + ...item, + orderIndex: i, + })); + + setSelectedItems(reorderedItems); + }; + + // Calculate estimated duration based on exercises and rest times + useEffect(() => { + let totalMinutes = 0; + + selectedItems.forEach(item => { + if (item.exercise) { + // Estimate time for each set (1 min per set) + rest time + const setCount = item.exercise.sets?.length || 3; // Default to 3 if no sets defined + const restTime = item.restTime / 60; // Convert seconds to minutes + totalMinutes += setCount + restTime; + } else if (item.superSet) { + // For supersets, account for both exercises + const primarySets = item.superSet.primaryExercise?.sets?.length || 3; + const secondarySets = item.superSet.secondaryExercise?.sets?.length || 3; + const restTime = item.superSet.restTime / 60; + totalMinutes += primarySets + secondarySets + restTime; + } + }); + + // Add some buffer time for transitions between exercises + totalMinutes += selectedItems.length > 0 ? Math.ceil(selectedItems.length / 3) : 0; + + setEstimatedDuration(Math.ceil(totalMinutes)); + }, [selectedItems]); + + // Handle saving the routine + const handleSaveRoutine = async (e: React.FormEvent) => { + e.preventDefault(); + + if (selectedItems.length === 0) { + setError('Please add at least one exercise to your routine.'); + return; + } + + setIsSaving(true); + setError(null); + + // Prepare the routineItems by removing circular references + const sanitizedItems = selectedItems.map(item => { + const { exercise, superSet, ...rest } = item; + return rest; + }); + + const routineData: Routine = { + name: routineName, + description: routineDescription, + routineItems: sanitizedItems, + }; + + try { + if (isEditMode && routineToEdit?.id) { + // Update existing routine + await RoutineService.update(routineToEdit.id, routineData); + setSuccessMessage('Routine updated successfully!'); + } else { + // Create new routine + await RoutineService.create(routineData); + setSuccessMessage('Routine created successfully!'); + } + + // Show success message briefly then redirect + setTimeout(() => { + navigate('/workouts'); + }, 1500); + } catch (err) { + console.error('Failed to save routine:', err); + setError('Failed to save routine. Please try again.'); + } finally { + setIsSaving(false); + } + }; + + return ( + <div className="page new-routine-page"> + <div className="page-header"> + <button + onClick={() => navigate(-1)} + className="btn btn-secondary back-button" + > + <FaArrowLeft /> Back + </button> + <h1>{isEditMode ? 'Edit Routine' : 'Create Routine'}</h1> + </div> + + {error && <div className="error-message">{error}</div>} + {successMessage && <div className="success-message">{successMessage}</div>} + + {isLoading ? ( + <div className="loading">Loading exercises...</div> + ) : ( + <div className="routine-builder"> + <div className="routine-details card"> + <h2>Routine Details</h2> + <form onSubmit={handleSaveRoutine}> + <div className="form-group"> + <label htmlFor="routineName">Routine Name*</label> + <input + type="text" + id="routineName" + value={routineName} + onChange={(e) => setRoutineName(e.target.value)} + placeholder="e.g. Upper Body Strength" + required + disabled={isSaving} + /> + </div> + + <div className="form-group"> + <label htmlFor="routineDescription">Description</label> + <textarea + id="routineDescription" + value={routineDescription} + onChange={(e) => setRoutineDescription(e.target.value)} + placeholder="Describe your routine..." + rows={3} + disabled={isSaving} + /> + </div> + + <div className="routine-summary"> + <div className="summary-item"> + <span className="summary-label">Exercises:</span> + <span className="summary-value">{selectedItems.length}</span> + </div> + <div className="summary-item"> + <span className="summary-label">Est. Duration:</span> + <span className="summary-value">{estimatedDuration} min</span> + </div> + </div> + + <div className="selected-exercises"> + <h3>Selected Exercises</h3> + + {selectedItems.length === 0 ? ( + <div className="empty-state"> + <p>No exercises added yet.</p> + <p className="hint">Select exercises from the list below to add them to your routine.</p> + </div> + ) : ( + <div className="exercise-list"> + {selectedItems.map((item, index) => ( + <div key={`${index}-${item.exerciseId || item.superSetId}`} className="selected-exercise-item"> + <div className="exercise-order">#{index + 1}</div> + + <div className="exercise-content"> + <div className="exercise-name">{item.exercise?.name || (item.superSet ? `${item.superSet.primaryExercise.name} + ${item.superSet.secondaryExercise.name}` : "Unknown Exercise")}</div> + + <div className="exercise-details"> + <div className="detail-item"> + <label htmlFor={`rest-${index}`}>Rest (sec):</label> + <input + id={`rest-${index}`} + type="number" + min="0" + max="300" + step="15" + value={item.restTime} + onChange={(e) => handleItemChange(index, 'restTime', parseInt(e.target.value))} + disabled={isSaving} + /> + </div> + </div> + </div> + + <div className="exercise-actions"> + <button + type="button" + onClick={() => handleMoveItem(index, 'up')} + disabled={index === 0 || isSaving} + className="btn btn-secondary action-btn" + title="Move up" + > + <FaArrowUp /> + </button> + <button + type="button" + onClick={() => handleMoveItem(index, 'down')} + disabled={index === selectedItems.length - 1 || isSaving} + className="btn btn-secondary action-btn" + title="Move down" + > + <FaArrowDown /> + </button> + <button + type="button" + onClick={() => handleRemoveItem(index)} + disabled={isSaving} + className="btn btn-danger action-btn" + title="Remove" + > + <FaTrash /> + </button> + </div> + </div> + ))} + </div> + )} + </div> + + <div className="form-actions"> + <button + type="submit" + className="btn btn-primary btn-block" + disabled={isSaving || selectedItems.length === 0 || !routineName} + > + <FaSave /> {isEditMode ? 'Update Routine' : 'Save Routine'} + </button> + </div> + </form> + </div> + + <div className="exercise-picker card"> + <h2>Available Exercises</h2> + + <div className="exercise-filters"> + <div className="search-input-container"> + <FaFilter /> + <input + type="text" + placeholder="Search exercises..." + value={searchTerm} + onChange={(e) => setSearchTerm(e.target.value)} + className="search-input" + /> + </div> + + <div className="muscle-filter"> + <select + value={muscleFilter} + onChange={(e) => setMuscleFilter(e.target.value)} + > + <option value="">All Muscle Groups</option> + {muscleGroups.map(group => ( + <option key={group} value={group}>{group}</option> + ))} + </select> + </div> + </div> + + {filteredExercises.length === 0 && ( + <div className="empty-state"> + <p>No exercises found.</p> + <Link to="/new-exercise" className="btn btn-primary mt-md"> + <FaPlus /> Create New Exercise + </Link> + </div> + )} + + <div className="available-exercises"> + {filteredExercises.map(exercise => ( + <div key={exercise.id} className="exercise-item"> + <div className="exercise-info"> + <h4>{exercise.name}</h4> + <div className="exercise-metadata"> + <span>{exercise.muscleGroups.map(mg => mg.name).join(', ')}</span> + <span>{exercise.equipment.map(eq => eq.name).join(', ')}</span> + </div> + {exercise.description && ( + <div className="exercise-description">{exercise.description}</div> + )} + </div> + <button + type="button" + onClick={() => handleAddExercise(exercise)} + className="btn btn-primary" + disabled={isSaving} + > + <FaPlus /> Add + </button> + </div> + ))} + </div> + + <div className="text-center mt-lg"> + <Link to="/new-exercise" className="btn btn-secondary"> + <FaPlus /> Create New Exercise + </Link> + </div> + </div> + </div> + )} + + <style>{` + .page-header { + display: flex; + align-items: center; + margin-bottom: var(--spacing-lg); + } + + .back-button { + margin-right: var(--spacing-md); + padding: var(--spacing-sm) var(--spacing-md); + } + + .routine-builder { + display: grid; + gap: var(--spacing-lg); + } + + @media (min-width: 768px) { + .routine-builder { + grid-template-columns: 1fr 1fr; + } + } + + .routine-summary { + display: flex; + gap: var(--spacing-lg); + margin: var(--spacing-md) 0; + } + + .summary-item { + padding: var(--spacing-sm) var(--spacing-md); + background-color: rgba(0, 122, 255, 0.1); + border-radius: var(--border-radius); + display: flex; + align-items: center; + gap: var(--spacing-sm); + } + + .summary-label { + font-weight: 500; + } + + .selected-exercises { + margin-top: var(--spacing-lg); + } + + h3 { + margin-bottom: var(--spacing-md); + } + + .selected-exercise-item { + display: flex; + align-items: center; + padding: var(--spacing-md); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + margin-bottom: var(--spacing-sm); + background-color: white; + } + + .exercise-order { + font-weight: bold; + width: 30px; + height: 30px; + border-radius: 50%; + background-color: var(--primary-color); + color: white; + display: flex; + align-items: center; + justify-content: center; + margin-right: var(--spacing-md); + } + + .exercise-content { + flex: 1; + } + + .exercise-name { + font-weight: 600; + margin-bottom: var(--spacing-xs); + } + + .exercise-details { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-md); + } + + .detail-item { + display: flex; + align-items: center; + gap: var(--spacing-xs); + } + + .detail-item input { + width: 60px; + padding: 4px; + text-align: center; + } + + .exercise-actions { + display: flex; + gap: var(--spacing-xs); + } + + .action-btn { + padding: 5px; + font-size: 0.8rem; + } + + .exercise-filters { + display: flex; + gap: var(--spacing-md); + margin-bottom: var(--spacing-md); + } + + .search-input-container { + position: relative; + flex: 1; + } + + .search-input-container .fa-filter { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + color: var(--text-muted); + } + + .search-input { + padding-left: 30px; + width: 100%; + } + + .available-exercises { + max-height: 500px; + overflow-y: auto; + } + + .exercise-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-md); + border-bottom: 1px solid var(--border-color); + } + + .exercise-info { + flex: 1; + } + + .exercise-info h4 { + margin: 0; + margin-bottom: var(--spacing-xs); + } + + .exercise-metadata { + font-size: 0.85rem; + color: var(--text-muted); + display: flex; + gap: var(--spacing-md); + margin-bottom: var(--spacing-xs); + } + + .exercise-description { + font-size: 0.9rem; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + } + + .empty-state { + padding: var(--spacing-lg); + text-align: center; + color: var(--text-muted); + } + + .hint { + font-size: 0.9rem; + margin-top: var(--spacing-md); + } + + .mt-md { + margin-top: var(--spacing-md); + } + + .mt-lg { + margin-top: var(--spacing-lg); + } + + .text-center { + text-align: center; + } + + .loading { + text-align: center; + padding: var(--spacing-lg); + color: var(--text-muted); + } + + .error-message { + background-color: rgba(255, 59, 48, 0.1); + color: var(--danger-color); + padding: var(--spacing-md); + border-radius: var(--border-radius); + margin-bottom: var(--spacing-lg); + } + + .success-message { + background-color: rgba(52, 199, 89, 0.1); + color: var(--success-color); + padding: var(--spacing-md); + border-radius: var(--border-radius); + margin-bottom: var(--spacing-lg); + } + `}</style> + </div> + ); +}; + +export default NewRoutinePage;
A ui/src/pages/NewWorkoutPage.tsx

@@ -0,0 +1,1090 @@

+import { useState, useEffect } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { + FaArrowLeft, + FaCheck, + FaSave, + FaPlay, + FaStop, + FaStar, + FaRegStar, + FaForward +} from 'react-icons/fa'; +import type { + Routine, + Exercise, + RecordRoutine, + RecordExercise, + RecordSet, + Set, + RecordRoutineItem +} from '../types/models'; +import { RoutineService, WorkoutService } from '../services/api'; + +interface SetForWorkout { + id?: number; + setId: number; + actualReps: number; + actualWeight: number; + actualDuration: number; + originalSet: Set; + completed: boolean; +} + +interface ExerciseForWorkout { + id?: number; + exerciseId: number; + exercise: Exercise; + sets: SetForWorkout[]; + startedAt?: string; + endedAt?: string; + actualRestTime: number; // in seconds + notes: string; +} + +const NewWorkoutPage = () => { + const navigate = useNavigate(); + const location = useLocation(); + + // Routines state + const [routines, setRoutines] = useState<Routine[]>([]); + const [selectedRoutine, setSelectedRoutine] = useState<Routine | null>(null); + const [isLoading, setIsLoading] = useState<boolean>(true); + const [error, setError] = useState<string | null>(null); + + // Workout tracking state + const [workoutStarted, setWorkoutStarted] = useState<boolean>(false); + const [workoutCompleted, setWorkoutCompleted] = useState<boolean>(false); + const [startTime, setStartTime] = useState<string>(''); + const [endTime, setEndTime] = useState<string | null>(null); + const [elapsedSeconds, setElapsedSeconds] = useState<number>(0); + const [intervalId, setIntervalId] = useState<number | null>(null); + + // Exercise tracking state + const [currentExerciseIndex, setCurrentExerciseIndex] = useState<number>(0); + const [workoutExercises, setWorkoutExercises] = useState<ExerciseForWorkout[]>([]); + + // Workout notes and rating + const [workoutNotes, setWorkoutNotes] = useState<string>(''); + const [feelingRating, setFeelingRating] = useState<number>(3); + + // Success message state + const [successMessage, setSuccessMessage] = useState<string | null>(null); + + // Load routines and check for pre-selected routine + useEffect(() => { + const fetchRoutines = async () => { + try { + setIsLoading(true); + const data = await RoutineService.getAll(); + setRoutines(data); + + // Check if a routine was pre-selected (from workouts page) + if (location.state && location.state.routineId) { + const routineId = location.state.routineId; + const routine = data.find(r => r.id === routineId); + + if (routine) { + handleSelectRoutine(routine); + } + } + + setError(null); + } catch (err) { + console.error('Failed to fetch routines:', err); + setError('Could not load workout routines. Please try again later.'); + } finally { + setIsLoading(false); + } + }; + + fetchRoutines(); + }, [location]); + + // Setup the workout when a routine is selected + const handleSelectRoutine = (routine: Routine) => { + setSelectedRoutine(routine); + + // Initialize workout exercises from routine items + const exercises: ExerciseForWorkout[] = []; + + // Process routine items into exercises for the workout + routine.routineItems.forEach(item => { + if (item.exercise && item.exerciseId) { + // This is a regular exercise item + const exercise = item.exercise; + + // Get the sets from the exercise or create default ones + const exerciseSets = exercise.sets || []; + const setsForWorkout: SetForWorkout[] = exerciseSets.map(set => ({ + setId: set.id || 0, + originalSet: set, + actualReps: set.reps, + actualWeight: set.weight, + actualDuration: set.duration, + completed: false + })); + + // If there are no sets defined, create a default set + if (setsForWorkout.length === 0) { + setsForWorkout.push({ + setId: 0, + originalSet: { + id: 0, + exerciseId: exercise.id || 0, + reps: 10, + weight: 0, + duration: 0, + orderIndex: 0 + }, + actualReps: 10, + actualWeight: 0, + actualDuration: 0, + completed: false + }); + } + + exercises.push({ + exerciseId: exercise.id || 0, + exercise: exercise, + sets: setsForWorkout, + actualRestTime: item.restTime, + notes: '' + }); + } + // We could handle supersets here if needed + }); + + setWorkoutExercises(exercises); + setCurrentExerciseIndex(0); + }; + + // Start the workout + const startWorkout = () => { + if (!selectedRoutine) return; + + const now = new Date().toISOString(); + setStartTime(now); + setWorkoutStarted(true); + + // Mark first exercise as started + if (workoutExercises.length > 0) { + const updatedExercises = [...workoutExercises]; + updatedExercises[0].startedAt = now; + setWorkoutExercises(updatedExercises); + } + + // Start the timer + const id = window.setInterval(() => { + setElapsedSeconds(prev => prev + 1); + }, 1000); + + setIntervalId(id); + }; + + // Complete the workout + const completeWorkout = () => { + if (intervalId) { + clearInterval(intervalId); + setIntervalId(null); + } + + const now = new Date().toISOString(); + setEndTime(now); + + // Mark current exercise as completed if not already + if (workoutExercises.length > 0 && currentExerciseIndex < workoutExercises.length) { + const updatedExercises = [...workoutExercises]; + const currentExercise = updatedExercises[currentExerciseIndex]; + + if (currentExercise.startedAt && !currentExercise.endedAt) { + currentExercise.endedAt = now; + } + + setWorkoutExercises(updatedExercises); + } + + setWorkoutCompleted(true); + }; + + // Format timer display + const formatTime = (seconds: number) => { + const hrs = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + return `${hrs > 0 ? hrs + ':' : ''}${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + }; + + // Handle set completion toggle + const toggleSetCompleted = (exerciseIndex: number, setIndex: number) => { + const updatedExercises = [...workoutExercises]; + const currentSet = updatedExercises[exerciseIndex].sets[setIndex]; + currentSet.completed = !currentSet.completed; + + setWorkoutExercises(updatedExercises); + }; + + // Handle weight, reps or duration change + const handleSetDataChange = ( + exerciseIndex: number, + setIndex: number, + field: 'actualReps' | 'actualWeight' | 'actualDuration', + value: number + ) => { + const updatedExercises = [...workoutExercises]; + const currentSet = updatedExercises[exerciseIndex].sets[setIndex]; + currentSet[field] = value; + + setWorkoutExercises(updatedExercises); + }; + + // Move to next exercise + const nextExercise = () => { + if (currentExerciseIndex >= workoutExercises.length - 1) return; + + const now = new Date().toISOString(); + const updatedExercises = [...workoutExercises]; + + // Complete current exercise + const currentExercise = updatedExercises[currentExerciseIndex]; + if (currentExercise.startedAt && !currentExercise.endedAt) { + currentExercise.endedAt = now; + } + + // Start next exercise + const nextIndex = currentExerciseIndex + 1; + const nextExercise = updatedExercises[nextIndex]; + nextExercise.startedAt = now; + + setWorkoutExercises(updatedExercises); + setCurrentExerciseIndex(nextIndex); + }; + + // Handle notes for an exercise + const handleExerciseNotes = (exerciseIndex: number, notes: string) => { + const updatedExercises = [...workoutExercises]; + updatedExercises[exerciseIndex].notes = notes; + + setWorkoutExercises(updatedExercises); + }; + + // Create RecordSets from workout exercise sets + const createRecordSets = (exercise: ExerciseForWorkout): RecordSet[] => { + return exercise.sets.map((set, index) => ({ + recordExerciseId: 0, // Will be filled in by backend + setId: set.setId, + actualReps: set.actualReps, + actualWeight: set.actualWeight, + actualDuration: set.actualDuration, + completedAt: exercise.endedAt || new Date().toISOString(), + orderIndex: index, + set: set.originalSet + })); + }; + + // Save workout record + const saveWorkout = async () => { + if (!selectedRoutine || !startTime) return; + + try { + const now = new Date().toISOString(); + + // Ensure all exercises have start/end times + const completedExercises = workoutExercises.map((ex) => { + if (!ex.startedAt) { + ex.startedAt = startTime; + } + if (!ex.endedAt) { + ex.endedAt = endTime || now; + } + return ex; + }); + + // Create RecordExercises from completed exercises + const recordExercises: RecordExercise[] = completedExercises.map((ex, index) => ({ + id: undefined, + recordRoutineId: 0, // Will be filled in by backend + exerciseId: ex.exerciseId, + startedAt: ex.startedAt || startTime, + endedAt: ex.endedAt || now, + actualRestTime: ex.actualRestTime, + orderIndex: index, + recordSets: createRecordSets(ex), + exercise: ex.exercise + })); + + // Create RecordRoutineItems from recordExercises + const recordRoutineItems: RecordRoutineItem[] = recordExercises.map((ex, index) => ({ + recordRoutineId: 0, // Will be filled in by backend + recordExerciseId: undefined, // Will be filled in after recordExercise is created + recordSuperSetId: null, + actualRestTime: workoutExercises[index].actualRestTime, + orderIndex: index, + recordExercise: ex, + recordSuperSet: null + })); + + const workoutRecord: RecordRoutine = { + routineId: selectedRoutine.id!, + startedAt: startTime, + endedAt: endTime || now, + routine: selectedRoutine, + recordRoutineItems: recordRoutineItems + }; + + await WorkoutService.create(workoutRecord); + setSuccessMessage('Workout saved successfully!'); + + // Redirect after a brief delay + setTimeout(() => { + navigate('/home'); + }, 1500); + } catch (err) { + console.error('Failed to save workout:', err); + setError('Failed to save your workout. Please try again.'); + } + }; + + // Check if all sets in current exercise are completed + const isCurrentExerciseComplete = () => { + if (currentExerciseIndex >= workoutExercises.length) return false; + + const currentExercise = workoutExercises[currentExerciseIndex]; + return currentExercise.sets.every(set => set.completed); + }; + + // Progress status percentage + const calculateProgress = () => { + if (workoutExercises.length === 0) return 0; + + const totalSets = workoutExercises.reduce((total, ex) => total + ex.sets.length, 0); + const completedSets = workoutExercises.reduce((total, ex) => { + return total + ex.sets.filter(set => set.completed).length; + }, 0); + + return Math.round((completedSets / totalSets) * 100); + }; + + return ( + <div className="page new-workout-page"> + <div className="page-header"> + <button + onClick={() => navigate(-1)} + className="btn btn-secondary back-button" + > + <FaArrowLeft /> Back + </button> + <h1>New Workout</h1> + </div> + + {error && <div className="error-message">{error}</div>} + {successMessage && <div className="success-message">{successMessage}</div>} + + {!selectedRoutine ? ( + // Routine selection view + <div className="routine-selection card"> + <h2>Select a Routine</h2> + + {isLoading ? ( + <div className="loading">Loading routines...</div> + ) : routines.length === 0 ? ( + <div className="empty-state"> + <p>No routines found.</p> + <p>Create a routine to start working out.</p> + <button + onClick={() => navigate('/new-routine')} + className="btn btn-primary mt-md" + > + Create Routine + </button> + </div> + ) : ( + <div className="routines-list"> + {routines.map(routine => ( + <div key={routine.id} className="routine-item" onClick={() => handleSelectRoutine(routine)}> + <h3>{routine.name}</h3> + {routine.description && <p>{routine.description}</p>} + <div className="routine-meta"> + <span>{routine.routineItems.length} exercises</span> + </div> + </div> + ))} + </div> + )} + </div> + ) : !workoutStarted ? ( + // Workout ready view + <div className="workout-ready card"> + <h2>{selectedRoutine.name}</h2> + {selectedRoutine.description && <p className="routine-description">{selectedRoutine.description}</p>} + + <div className="workout-details"> + <div className="detail-item"> + <span className="detail-label">Exercises:</span> + <span className="detail-value">{workoutExercises.length}</span> + </div> + <div className="detail-item"> + <span className="detail-label">Sets:</span> + <span className="detail-value"> + {workoutExercises.reduce((total, ex) => total + ex.sets.length, 0)} + </span> + </div> + </div> + + <div className="exercise-preview"> + <h3>Exercises</h3> + <ul className="exercise-list"> + {workoutExercises.map((exercise, index) => ( + <li key={`${exercise.exerciseId}-${index}`}> + <div className="exercise-name">{exercise.exercise.name}</div> + <div className="exercise-sets">{exercise.sets.length} sets</div> + </li> + ))} + </ul> + </div> + + <div className="action-buttons"> + <button + className="btn btn-primary btn-lg btn-block" + onClick={startWorkout} + > + <FaPlay /> Start Workout + </button> + <button + className="btn btn-secondary btn-block mt-md" + onClick={() => setSelectedRoutine(null)} + > + Select Different Routine + </button> + </div> + </div> + ) : workoutCompleted ? ( + // Workout complete view + <div className="workout-complete card"> + <div className="workout-summary"> + <h2>Workout Complete!</h2> + + <div className="summary-stats"> + <div className="stat"> + <span className="stat-label">Duration</span> + <span className="stat-value">{formatTime(elapsedSeconds)}</span> + </div> + + <div className="stat"> + <span className="stat-label">Completed</span> + <span className="stat-value">{calculateProgress()}%</span> + </div> + </div> + + <div className="feeling-rating"> + <p>How was your workout?</p> + <div className="stars"> + {[1, 2, 3, 4, 5].map(rating => ( + <button + key={rating} + onClick={() => setFeelingRating(rating)} + className="star-btn" + > + {rating <= feelingRating ? <FaStar /> : <FaRegStar />} + </button> + ))} + </div> + </div> + + <div className="workout-notes form-group"> + <label htmlFor="workout-notes">Workout Notes</label> + <textarea + id="workout-notes" + value={workoutNotes} + onChange={e => setWorkoutNotes(e.target.value)} + placeholder="Add notes about the overall workout..." + rows={3} + /> + </div> + + <div className="action-buttons"> + <button + onClick={saveWorkout} + className="btn btn-primary btn-lg btn-block" + > + <FaSave /> Save Workout + </button> + </div> + </div> + </div> + ) : ( + // Active workout view + <div className="active-workout"> + <div className="workout-header card"> + <h2>{selectedRoutine.name}</h2> + + <div className="workout-timer"> + <div className="timer-value">{formatTime(elapsedSeconds)}</div> + <div className="progress-bar"> + <div + className="progress" + style={{ width: `${calculateProgress()}%` }} + ></div> + </div> + </div> + </div> + + <div className="current-exercise card"> + {currentExerciseIndex < workoutExercises.length ? ( + <> + <h3 className="exercise-name"> + {workoutExercises[currentExerciseIndex].exercise.name} + </h3> + + <div className="exercise-sets"> + <table className="sets-table"> + <thead> + <tr> + <th>Set</th> + <th>Weight</th> + <th>Reps</th> + {workoutExercises[currentExerciseIndex].sets.some(s => s.originalSet.duration > 0) && ( + <th>Time</th> + )} + <th>Done</th> + </tr> + </thead> + <tbody> + {workoutExercises[currentExerciseIndex].sets.map((set, setIndex) => ( + <tr key={setIndex} className={set.completed ? 'completed' : ''}> + <td>{setIndex + 1}</td> + <td> + <input + type="number" + min="0" + step="1" + value={set.actualWeight} + onChange={e => handleSetDataChange( + currentExerciseIndex, + setIndex, + 'actualWeight', + parseFloat(e.target.value) || 0 + )} + /> + </td> + <td> + <input + type="number" + min="0" + step="1" + value={set.actualReps} + onChange={e => handleSetDataChange( + currentExerciseIndex, + setIndex, + 'actualReps', + parseInt(e.target.value) || 0 + )} + /> + </td> + {workoutExercises[currentExerciseIndex].sets.some(s => s.originalSet.duration > 0) && ( + <td> + <input + type="number" + min="0" + step="1" + value={set.actualDuration} + onChange={e => handleSetDataChange( + currentExerciseIndex, + setIndex, + 'actualDuration', + parseInt(e.target.value) || 0 + )} + /> + </td> + )} + <td> + <button + className={`btn-check ${set.completed ? 'completed' : ''}`} + onClick={() => toggleSetCompleted(currentExerciseIndex, setIndex)} + > + {set.completed && <FaCheck />} + </button> + </td> + </tr> + ))} + </tbody> + </table> + </div> + + <div className="exercise-notes form-group"> + <label htmlFor="exercise-notes">Exercise Notes</label> + <textarea + id="exercise-notes" + value={workoutExercises[currentExerciseIndex].notes} + onChange={e => handleExerciseNotes(currentExerciseIndex, e.target.value)} + placeholder="Add notes for this exercise..." + rows={2} + /> + </div> + + <div className="exercise-navigation"> + {currentExerciseIndex < workoutExercises.length - 1 && ( + <button + onClick={nextExercise} + className={`btn btn-primary ${isCurrentExerciseComplete() ? 'pulse' : ''}`} + disabled={!isCurrentExerciseComplete() && workoutExercises[currentExerciseIndex].sets.length > 0} + > + <FaForward /> Next Exercise + </button> + )} + + {currentExerciseIndex === workoutExercises.length - 1 && isCurrentExerciseComplete() && ( + <button + onClick={completeWorkout} + className="btn btn-primary pulse" + > + <FaStop /> Finish Workout + </button> + )} + </div> + </> + ) : ( + <div> + <p>All exercises completed!</p> + <button + onClick={completeWorkout} + className="btn btn-primary" + > + <FaStop /> Finish Workout + </button> + </div> + )} + </div> + + <div className="workout-nav"> + <div className="exercises-list card"> + <h3>Progress</h3> + <ul> + {workoutExercises.map((ex, index) => ( + <li + key={`${ex.exerciseId}-${index}`} + className={` + ${index === currentExerciseIndex ? 'active' : ''} + ${ex.sets.every(s => s.completed) ? 'completed' : ''} + `} + onClick={() => setCurrentExerciseIndex(index)} + > + <span className="exercise-number">{index + 1}</span> + <span className="exercise-list-name">{ex.exercise.name}</span> + <span className="exercise-progress"> + {ex.sets.filter(s => s.completed).length}/{ex.sets.length} + </span> + </li> + ))} + </ul> + </div> + + <div className="workout-actions card"> + <button + onClick={completeWorkout} + className="btn btn-danger btn-block" + > + <FaStop /> End Workout + </button> + </div> + </div> + </div> + )} + + <style>{` + .page-header { + display: flex; + align-items: center; + margin-bottom: var(--spacing-lg); + } + + .back-button { + margin-right: var(--spacing-md); + padding: var(--spacing-sm) var(--spacing-md); + } + + /* Routine Selection */ + .routines-list { + display: grid; + gap: var(--spacing-md); + } + + .routine-item { + padding: var(--spacing-md); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + cursor: pointer; + transition: all 0.2s; + } + + .routine-item:hover { + background-color: rgba(0, 122, 255, 0.05); + border-color: var(--primary-color); + } + + .routine-item h3 { + margin: 0; + margin-bottom: var(--spacing-xs); + } + + .routine-item p { + margin: 0; + margin-bottom: var(--spacing-sm); + color: var(--text-muted); + } + + .routine-meta { + font-size: 0.9rem; + color: var(--text-muted); + } + + /* Workout Ready */ + .routine-description { + color: var(--text-muted); + margin-bottom: var(--spacing-lg); + } + + .workout-details { + display: flex; + gap: var(--spacing-lg); + margin-bottom: var(--spacing-lg); + } + + .detail-item { + display: flex; + flex-direction: column; + align-items: center; + background-color: rgba(0, 122, 255, 0.1); + padding: var(--spacing-md) var(--spacing-lg); + border-radius: var(--border-radius); + } + + .detail-label { + color: var(--text-muted); + font-size: 0.9rem; + } + + .detail-value { + font-size: 1.2rem; + font-weight: bold; + } + + .exercise-preview { + margin-bottom: var(--spacing-lg); + } + + .exercise-list { + list-style: none; + padding: 0; + margin: 0; + } + + .exercise-list li { + padding: var(--spacing-sm) 0; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + } + + .exercise-list li:last-child { + border-bottom: none; + } + + .exercise-sets { + color: var(--text-muted); + font-size: 0.9rem; + } + + .btn-lg { + padding: var(--spacing-md); + font-size: 1.1rem; + } + + /* Active Workout */ + .workout-header { + margin-bottom: var(--spacing-md); + padding: var(--spacing-md); + } + + .workout-header h2 { + margin-bottom: var(--spacing-sm); + } + + .workout-timer { + text-align: center; + } + + .timer-value { + font-size: 1.5rem; + font-weight: bold; + margin-bottom: var(--spacing-xs); + } + + .progress-bar { + height: 8px; + background-color: var(--light-gray); + border-radius: 4px; + overflow: hidden; + } + + .progress { + height: 100%; + background-color: var(--primary-color); + transition: width 0.3s ease; + } + + .current-exercise { + margin-bottom: var(--spacing-md); + padding: var(--spacing-md); + } + + .exercise-name { + margin-bottom: var(--spacing-md); + font-size: 1.2rem; + } + + /* Sets table */ + .sets-table { + width: 100%; + border-collapse: collapse; + margin-bottom: var(--spacing-md); + } + + .sets-table th { + padding: var(--spacing-sm); + text-align: center; + border-bottom: 1px solid var(--border-color); + font-weight: 600; + } + + .sets-table td { + padding: var(--spacing-sm); + text-align: center; + border-bottom: 1px solid var(--border-color); + } + + .sets-table tr.completed { + background-color: rgba(52, 199, 89, 0.1); + } + + .sets-table input { + width: 60px; + padding: 4px; + text-align: center; + } + + .btn-check { + width: 30px; + height: 30px; + border-radius: 50%; + border: 1px solid var(--border-color); + background: white; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + } + + .btn-check.completed { + background-color: var(--success-color); + color: white; + border-color: var(--success-color); + } + + .exercise-notes { + margin-bottom: var(--spacing-md); + } + + .exercise-navigation { + display: flex; + justify-content: flex-end; + } + + /* Pulse animation for the next button */ + .pulse { + animation: pulse 1.5s infinite; + } + + @keyframes pulse { + 0% { + box-shadow: 0 0 0 0 rgba(0, 122, 255, 0.4); + } + 70% { + box-shadow: 0 0 0 10px rgba(0, 122, 255, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(0, 122, 255, 0); + } + } + + /* Progress list */ + .workout-nav { + margin-bottom: var(--spacing-lg); + } + + .exercises-list ul { + list-style: none; + padding: 0; + margin: 0; + } + + .exercises-list li { + display: flex; + align-items: center; + padding: var(--spacing-sm); + border-bottom: 1px solid var(--border-color); + cursor: pointer; + } + + .exercises-list li.active { + background-color: rgba(0, 122, 255, 0.1); + } + + .exercises-list li.completed { + color: var(--text-muted); + } + + .exercise-number { + width: 24px; + height: 24px; + border-radius: 50%; + background-color: var(--light-gray); + display: flex; + align-items: center; + justify-content: center; + margin-right: var(--spacing-sm); + font-size: 0.8rem; + } + + .exercises-list li.completed .exercise-number { + background-color: var(--success-color); + color: white; + } + + .exercise-list-name { + flex: 1; + } + + .exercise-progress { + font-size: 0.8rem; + color: var(--text-muted); + } + + .workout-actions { + margin-top: var(--spacing-md); + padding: var(--spacing-md); + } + + /* Workout Complete */ + .workout-complete { + text-align: center; + padding: var(--spacing-lg); + } + + .workout-summary h2 { + margin-bottom: var(--spacing-lg); + } + + .summary-stats { + display: flex; + justify-content: center; + gap: var(--spacing-xl); + margin-bottom: var(--spacing-lg); + } + + .stat { + display: flex; + flex-direction: column; + } + + .stat-label { + font-size: 0.9rem; + color: var(--text-muted); + } + + .stat-value { + font-size: 1.5rem; + font-weight: bold; + } + + .feeling-rating { + margin-bottom: var(--spacing-lg); + } + + .stars { + display: flex; + justify-content: center; + gap: var(--spacing-sm); + } + + .star-btn { + background: none; + border: none; + font-size: 1.5rem; + color: #ffb700; + cursor: pointer; + } + + /* Shared */ + .loading { + text-align: center; + padding: var(--spacing-lg); + color: var(--text-muted); + } + + .empty-state { + text-align: center; + padding: var(--spacing-lg); + color: var(--text-muted); + } + + .error-message { + background-color: rgba(255, 59, 48, 0.1); + color: var(--danger-color); + padding: var(--spacing-md); + border-radius: var(--border-radius); + margin-bottom: var(--spacing-lg); + } + + .success-message { + background-color: rgba(52, 199, 89, 0.1); + color: var(--success-color); + padding: var(--spacing-md); + border-radius: var(--border-radius); + margin-bottom: var(--spacing-lg); + } + + .mt-md { + margin-top: var(--spacing-md); + } + + .card { + background-color: white; + border-radius: var(--border-radius); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + padding: var(--spacing-lg); + } + + @media (min-width: 768px) { + .active-workout { + display: grid; + grid-template-columns: 2fr 1fr; + gap: var(--spacing-md); + } + + .workout-header { + grid-column: 1 / -1; + } + + .workout-nav { + grid-column: 2; + grid-row: 2; + } + } + `}</style> + </div> + ); +}; + +export default NewWorkoutPage;
A ui/src/pages/ProfilePage.tsx

@@ -0,0 +1,243 @@

+import { useState, useEffect } from 'react'; +import { useAppContext } from '../context/AppContext'; +import type { User } from '../types/models'; +import { FaUser, FaSave, FaTimes } from 'react-icons/fa'; + +function parseBirthDate(dateString: string) { + return new Date(dateString).toISOString().split('T')[0]; // Format to YYYY-MM-DD +} + +const ProfilePage = () => { + const { user, updateUser, isLoading, error: contextError } = useAppContext(); + const [formData, setFormData] = useState<User | null>(null); + const [isEditing, setIsEditing] = useState(false); + const [error, setError] = useState<string | null>(null); + const [successMessage, setSuccessMessage] = useState<string | null>(null); + + useEffect(() => { + if (user && !formData) { + setFormData({ ...user }); + } + }, [user, formData]); + + const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => { + const { name, value } = e.target; + + setFormData(prev => { + if (!prev) return prev; + return { ...prev, [name]: value }; + }); + }; + + const handleEditToggle = () => { + if (isEditing) { + // Cancel edit - revert changes + setFormData(user ? { ...user } : null); + } + setIsEditing(!isEditing); + setError(null); + setSuccessMessage(null); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!formData) return; + + try { + setError(null); + await updateUser(formData); + setSuccessMessage('Profile updated successfully!'); + setIsEditing(false); + + // Clear success message after a few seconds + setTimeout(() => { + setSuccessMessage(null); + }, 3000); + } catch { + setError('Failed to update profile. Please try again.'); + } + }; + + if (isLoading) { + return ( + <div className="page profile-page"> + <h1>Profile</h1> + <div className="loading">Loading profile data...</div> + </div> + ); + } + + if (!formData) { + return ( + <div className="page profile-page"> + <h1>Profile</h1> + <div className="error-message">Could not load profile data.</div> + </div> + ); + } + + return ( + <div className="page profile-page"> + <div className="profile-header"> + <h1>Your Profile</h1> + + <button + onClick={handleEditToggle} + className={`btn ${isEditing ? 'btn-danger' : 'btn-secondary'}`} + > + {isEditing ? ( + <> + <FaTimes /> Cancel + </> + ) : ( + <>Edit Profile</> + )} + </button> + </div> + + {contextError && <div className="error-message">{contextError}</div>} + {error && <div className="error-message">{error}</div>} + {successMessage && <div className="success-message">{successMessage}</div>} + + <div className="card"> + <form onSubmit={handleSubmit}> + <div className="profile-avatar"> + <div className="avatar-circle"> + <FaUser size={40} /> + </div> + </div> + + <div className="form-group"> + <label htmlFor="name">Name</label> + <input + type="text" + id="name" + name="name" + value={formData.name} + onChange={handleInputChange} + disabled={!isEditing} + required + /> + </div> + + <div className="form-group"> + <label htmlFor="isFemale">Gender</label> + <select + id="isFemale" + name="isFemale" + value={formData.isFemale.toString()} + onChange={handleInputChange} + disabled={!isEditing} + required + > + <option value="false">Male</option> + <option value="true">Female</option> + </select> + </div> + + <div className="form-group"> + <label htmlFor="weight">Weight (kg)</label> + <input + type="number" + id="weight" + name="weight" + min="20" + max="300" + step="1" + value={formData.weight} + onChange={handleInputChange} + disabled={!isEditing} + required + /> + </div> + + <div className="form-group"> + <label htmlFor="height">Height (cm)</label> + <input + type="number" + id="height" + name="height" + min="30" + max="300" + step="1" + value={formData.height} + onChange={handleInputChange} + disabled={!isEditing} + required + /> + </div> + + <div className="form-group"> + <label htmlFor="birthDate">Date of Birth</label> + <input + type="date" + id="birthDate" + name="birthDate" + value={parseBirthDate(formData.birthDate)} + onChange={handleInputChange} + disabled={!isEditing} + required + /> + </div> + + {isEditing && ( + <div className="form-actions"> + <button type="submit" className="btn btn-primary btn-block"> + <FaSave /> Save Changes + </button> + </div> + )} + </form> + </div> + + <style>{` + .profile-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-lg); + } + + .profile-avatar { + display: flex; + justify-content: center; + margin-bottom: var(--spacing-xl); + } + + .avatar-circle { + width: 100px; + height: 100px; + border-radius: 50%; + background-color: var(--light-gray); + display: flex; + align-items: center; + justify-content: center; + color: var(--dark-gray); + } + + .form-actions { + margin-top: var(--spacing-lg); + } + + .error-message { + background-color: rgba(255, 59, 48, 0.1); + color: var(--danger-color); + padding: var(--spacing-md); + border-radius: var(--border-radius); + margin-bottom: var(--spacing-lg); + } + + .success-message { + background-color: rgba(52, 199, 89, 0.1); + color: var(--success-color); + padding: var(--spacing-md); + border-radius: var(--border-radius); + margin-bottom: var(--spacing-lg); + } + `}</style> + </div> + ); +}; + +export default ProfilePage;
A ui/src/pages/WorkoutsPage.tsx

@@ -0,0 +1,257 @@

+import { useEffect, useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { FaPlus, FaPlay, FaEdit, FaTrash, FaFilter } from 'react-icons/fa'; +import type { Routine } from '../types/models'; +import { RoutineService } from '../services/api'; + +const WorkoutsPage = () => { + const [routines, setRoutines] = useState<Routine[]>([]); + const [loading, setLoading] = useState<boolean>(true); + const [error, setError] = useState<string | null>(null); + const [searchTerm, setSearchTerm] = useState<string>(''); + const navigate = useNavigate(); + + useEffect(() => { + const fetchRoutines = async () => { + try { + setLoading(true); + const data = await RoutineService.getAll(); + setRoutines(data); + setError(null); + } catch (err) { + console.error('Failed to fetch routines:', err); + setError('Could not load workout routines. Please try again later.'); + } finally { + setLoading(false); + } + }; + + fetchRoutines(); + }, []); + + const handleStartWorkout = (routine: Routine) => { + // Navigate to new workout page with the selected routine + navigate('/new-workout', { state: { routineId: routine.id } }); + }; + + const handleDeleteRoutine = async (id: number) => { + if (window.confirm('Are you sure you want to delete this routine?')) { + try { + await RoutineService.delete(id); + setRoutines(routines.filter(routine => routine.id !== id)); + } catch (err) { + console.error('Failed to delete routine:', err); + alert('Failed to delete routine. Please try again.'); + } + } + }; + + const filteredRoutines = routines.filter(routine => + routine.name.toLowerCase().includes(searchTerm.toLowerCase()) || + routine.description.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + if (loading) { + return ( + <div className="page workouts-page"> + <h1>Workouts</h1> + <div className="loading">Loading routines...</div> + </div> + ); + } + + if (error) { + return ( + <div className="page workouts-page"> + <h1>Workouts</h1> + <div className="error-message">{error}</div> + <div className="mt-lg"> + <Link to="/new-routine" className="btn btn-primary"> + <FaPlus /> Create New Routine + </Link> + </div> + </div> + ); + } + + return ( + <div className="page workouts-page"> + <h1>Workout Routines</h1> + + {/* Search and Filter */} + <div className="search-bar"> + <div className="search-input-container"> + <FaFilter /> + <input + type="text" + placeholder="Search routines..." + value={searchTerm} + onChange={(e) => setSearchTerm(e.target.value)} + className="search-input" + /> + </div> + </div> + + {/* Create New Button */} + <div className="action-buttons mb-lg"> + <Link to="/new-routine" className="btn btn-primary"> + <FaPlus /> Create New Routine + </Link> + </div> + + {/* Routines List */} + {filteredRoutines.length > 0 ? ( + <div className="routines-list"> + {filteredRoutines.map(routine => ( + <div key={routine.id} className="card routine-card"> + <div className="routine-info"> + <h3>{routine.name}</h3> + <p className="routine-description">{routine.description}</p> + <div className="routine-stats"></div> + </div> + + <div className="routine-actions"> + <button + className="btn btn-primary" + onClick={() => handleStartWorkout(routine)} + > + <FaPlay /> Start + </button> + <div className="routine-action-buttons"> + <Link + to={`/new-routine`} + state={{ editRoutine: routine }} + className="btn btn-secondary action-btn" + > + <FaEdit /> + </Link> + <button + className="btn btn-danger action-btn" + onClick={() => routine.id && handleDeleteRoutine(routine.id)} + > + <FaTrash /> + </button> + </div> + </div> + </div> + ))} + </div> + ) : ( + <div className="empty-state"> + {searchTerm ? ( + <p>No routines found matching "{searchTerm}"</p> + ) : ( + <> + <p>You haven't created any workout routines yet.</p> + <p className="mt-sm">Create your first routine to get started!</p> + <Link to="/new-routine" className="btn btn-primary mt-md"> + <FaPlus /> Create Routine + </Link> + </> + )} + </div> + )} + + <style>{` + .search-bar { + margin-bottom: var(--spacing-md); + } + + .search-input-container { + display: flex; + align-items: center; + background-color: white; + border-radius: var(--border-radius); + padding: 0 var(--spacing-md); + border: 1px solid var(--light-gray); + } + + .search-input { + border: none; + padding: var(--spacing-sm) var(--spacing-sm); + flex: 1; + } + + .search-input:focus { + outline: none; + } + + .action-buttons { + display: flex; + justify-content: flex-end; + margin: var(--spacing-md) 0; + } + + .routines-list { + display: grid; + gap: var(--spacing-md); + } + + .routine-card { + display: flex; + flex-direction: column; + } + + .routine-info { + flex: 1; + margin-bottom: var(--spacing-md); + } + + .routine-description { + color: var(--dark-gray); + margin: var(--spacing-sm) 0; + } + + .routine-stats { + display: flex; + gap: var(--spacing-md); + color: var(--dark-gray); + font-size: 0.9rem; + } + + .routine-actions { + display: flex; + justify-content: space-between; + align-items: center; + } + + .routine-action-buttons { + display: flex; + gap: var(--spacing-sm); + } + + .action-btn { + padding: 8px; + min-width: 40px; + } + + .empty-state { + text-align: center; + padding: var(--spacing-xl) var(--spacing-md); + background-color: white; + border-radius: var(--border-radius); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + } + + @media (min-width: 768px) { + .routine-card { + flex-direction: row; + } + + .routine-info { + margin-bottom: 0; + margin-right: var(--spacing-lg); + } + + .routine-actions { + flex-direction: column; + align-items: flex-end; + justify-content: space-between; + } + } + `}</style> + </div> + ); +}; + +export default WorkoutsPage;
A ui/src/services/api.ts

@@ -0,0 +1,229 @@

+import type { + Exercise, + Routine, + RecordRoutine, + User, + Equipment, + MuscleGroup, + Set, + SuperSet, + RecordExercise, + WorkoutStats +} from '../types/models'; + +// Base API URL - should be configurable via environment variables in a real app +const API_BASE_URL = '/api'; + +// Generic fetch with error handling +async function fetchApi<T>( + endpoint: string, + options: RequestInit = {} +): Promise<T> { + const url = `${API_BASE_URL}${endpoint}`; + + const response = await fetch(url, { + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + ...options, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(error || `API request failed with status ${response.status}`); + } + + return response.json(); +} + +// Equipment API services +export const EquipmentService = { + getAll: () => fetchApi<Equipment[]>('/equipment'), + + getById: (id: number) => fetchApi<Equipment>(`/equipment/${id}`), + + create: (equipment: Equipment) => fetchApi<Equipment>('/equipment', { + method: 'POST', + body: JSON.stringify(equipment), + }), + + update: (id: number, equipment: Equipment) => fetchApi<Equipment>(`/equipment/${id}`, { + method: 'PUT', + body: JSON.stringify(equipment), + }), + + delete: (id: number) => fetchApi<void>(`/equipment/${id}`, { + method: 'DELETE', + }), +}; + +// MuscleGroup API services +export const MuscleGroupService = { + getAll: () => fetchApi<MuscleGroup[]>('/musclegroups'), + + getById: (id: number) => fetchApi<MuscleGroup>(`/musclegroups/${id}`), + + create: (muscleGroup: MuscleGroup) => fetchApi<MuscleGroup>('/musclegroups', { + method: 'POST', + body: JSON.stringify(muscleGroup), + }), + + update: (id: number, muscleGroup: MuscleGroup) => fetchApi<MuscleGroup>(`/musclegroups/${id}`, { + method: 'PUT', + body: JSON.stringify(muscleGroup), + }), + + delete: (id: number) => fetchApi<void>(`/musclegroups/${id}`, { + method: 'DELETE', + }), +}; + +// Exercise API services +export const ExerciseService = { + getAll: () => fetchApi<Exercise[]>('/exercises'), + + getById: (id: number) => fetchApi<Exercise>(`/exercises/${id}`), + + create: (exercise: Exercise) => fetchApi<Exercise>('/exercises', { + method: 'POST', + body: JSON.stringify(exercise), + }), + + update: (id: number, exercise: Exercise) => fetchApi<Exercise>(`/exercises/${id}`, { + method: 'PUT', + body: JSON.stringify(exercise), + }), + + delete: (id: number) => fetchApi<void>(`/exercises/${id}`, { + method: 'DELETE', + }), +}; + +// Set API services +export const SetService = { + getAll: (exerciseId: number) => fetchApi<Set[]>(`/exercises/${exerciseId}/sets`), + + getById: (id: number) => fetchApi<Set>(`/sets/${id}`), + + create: (set: Set) => fetchApi<Set>('/sets', { + method: 'POST', + body: JSON.stringify(set), + }), + + update: (id: number, set: Set) => fetchApi<Set>(`/sets/${id}`, { + method: 'PUT', + body: JSON.stringify(set), + }), + + delete: (id: number) => fetchApi<void>(`/sets/${id}`, { + method: 'DELETE', + }), +}; + +// SuperSet API services +export const SuperSetService = { + getAll: () => fetchApi<SuperSet[]>('/supersets'), + + getById: (id: number) => fetchApi<SuperSet>(`/supersets/${id}`), + + create: (superSet: SuperSet) => fetchApi<SuperSet>('/supersets', { + method: 'POST', + body: JSON.stringify(superSet), + }), + + update: (id: number, superSet: SuperSet) => fetchApi<SuperSet>(`/supersets/${id}`, { + method: 'PUT', + body: JSON.stringify(superSet), + }), + + delete: (id: number) => fetchApi<void>(`/supersets/${id}`, { + method: 'DELETE', + }), +}; + +// Routine API services +export const RoutineService = { + getAll: () => fetchApi<Routine[]>('/routines'), + + getById: (id: number) => fetchApi<Routine>(`/routines/${id}`), + + create: (routine: Routine) => fetchApi<Routine>('/routines', { + method: 'POST', + body: JSON.stringify(routine), + }), + + update: (id: number, routine: Routine) => fetchApi<Routine>(`/routines/${id}`, { + method: 'PUT', + body: JSON.stringify(routine), + }), + + delete: (id: number) => fetchApi<void>(`/routines/${id}`, { + method: 'DELETE', + }), +}; + +// RecordRoutine (Workout) API services +export const WorkoutService = { + getAll: () => fetchApi<RecordRoutine[]>('/recordroutines'), + + getById: (id: number) => fetchApi<RecordRoutine>(`/recordroutines/${id}`), + + create: (workout: RecordRoutine) => fetchApi<RecordRoutine>('/recordroutines', { + method: 'POST', + body: JSON.stringify(workout), + }), + + update: (id: number, workout: RecordRoutine) => fetchApi<RecordRoutine>(`/recordroutines/${id}`, { + method: 'PUT', + body: JSON.stringify(workout), + }), + + delete: (id: number) => fetchApi<void>(`/recordroutines/${id}`, { + method: 'DELETE', + }), + + // Additional method to get workout statistics for the home page + getStats: () => fetchApi<WorkoutStats>('/stats'), +}; + +// RecordExercise API services +export const RecordExerciseService = { + getAll: (recordRoutineId: number) => fetchApi<RecordExercise[]>(`/recordroutines/${recordRoutineId}/exercises`), + + getById: (id: number) => fetchApi<RecordExercise>(`/recordexercises/${id}`), + + create: (recordExercise: RecordExercise) => fetchApi<RecordExercise>('/recordexercises', { + method: 'POST', + body: JSON.stringify(recordExercise), + }), + + update: (id: number, recordExercise: RecordExercise) => fetchApi<RecordExercise>(`/recordexercises/${id}`, { + method: 'PUT', + body: JSON.stringify(recordExercise), + }), + + delete: (id: number) => fetchApi<void>(`/recordexercises/${id}`, { + method: 'DELETE', + }), +}; + +// User profile service +export const ProfileService = { + get: async () => { + const user = await fetchApi<User>('/users/1'); + user.birthDate = new Date(user.birthDate).toISOString(); + return user; + }, + + update: async (profile: User) => { + profile.birthDate = new Date(profile.birthDate).toISOString(); + profile.isFemale = profile.isFemale === 'true'; + profile.weight = +profile.weight; + profile.height = +profile.height; + + return await fetchApi<User>('/users/1', { + method: 'PUT', + body: JSON.stringify(profile), + })}, +};
A ui/src/types/models.ts

@@ -0,0 +1,246 @@

+// Models for Go-Lift app that match the backend models + +// Equipment model +export interface Equipment { + id?: number; + name: string; + description: string; + createdAt?: string; + updatedAt?: string; + deletedAt?: string | null; + exercises?: Exercise[]; // Many-to-many relationship +} + +// Muscle Group model +export interface MuscleGroup { + id?: number; + name: string; + createdAt?: string; + updatedAt?: string; + deletedAt?: string | null; + exercises?: Exercise[]; // Many-to-many relationship +} + +// ExerciseMuscleGroup join table +export interface ExerciseMuscleGroup { + id?: number; + exerciseId: number; + muscleGroupId: number; + exercise?: Exercise; + muscleGroup?: MuscleGroup; +} + +// Set definition for an exercise +export interface Set { + id?: number; + exerciseId: number; + reps: number; + weight: number; + duration: number; // In seconds, for timed exercises + orderIndex: number; + createdAt?: string; + updatedAt?: string; + deletedAt?: string | null; + exercise?: Exercise; // Excluded in JSON via json:"-" but useful for frontend +} + +// Exercise definition +export interface Exercise { + id?: number; + name: string; + description: string; + createdAt?: string; + updatedAt?: string; + deletedAt?: string | null; + equipment: Equipment[]; + muscleGroups: MuscleGroup[]; + sets?: Set[]; +} + +// SuperSet to handle two exercises with single rest time +export interface SuperSet { + id?: number; + name: string; + primaryExerciseId: number; + secondaryExerciseId: number; + restTime: number; // In seconds + createdAt?: string; + updatedAt?: string; + deletedAt?: string | null; + primaryExercise: Exercise; + secondaryExercise: Exercise; +} + +// RoutineItem represents either an Exercise or a SuperSet in a Routine +export interface RoutineItem { + id?: number; + routineId: number; + exerciseId?: number | null; + superSetId?: number | null; + restTime: number; // In seconds + orderIndex: number; + createdAt?: string; + updatedAt?: string; + deletedAt?: string | null; + superSet?: SuperSet | null; + exercise?: Exercise | null; +} + +// A collection of exercises and/or supersets that make up a workout routine +export interface Routine { + id?: number; + name: string; + description: string; + createdAt?: string; + updatedAt?: string; + deletedAt?: string | null; + routineItems: RoutineItem[]; +} + +// RecordRoutine represents a completed workout session +export interface RecordRoutine { + id?: number; + routineId: number; + startedAt: string; + endedAt?: string | null; + createdAt?: string; + updatedAt?: string; + deletedAt?: string | null; + routine: Routine; + recordRoutineItems: RecordRoutineItem[]; +} + +// RecordRoutineItem represents either a RecordExercise or a RecordSuperSet in a completed routine +export interface RecordRoutineItem { + id?: number; + recordRoutineId: number; + recordExerciseId?: number | null; + recordSuperSetId?: number | null; + actualRestTime: number; // In seconds + orderIndex: number; + createdAt?: string; + updatedAt?: string; + deletedAt?: string | null; + recordSuperSet?: RecordSuperSet | null; + recordExercise?: RecordExercise | null; +} + +// RecordSuperSet records a completed superset +export interface RecordSuperSet { + id?: number; + recordRoutineId: number; + superSetId: number; + startedAt: string; + endedAt: string; + actualRestTime: number; // In seconds + orderIndex: number; + createdAt?: string; + updatedAt?: string; + deletedAt?: string | null; + superSet: SuperSet; +} + +// RecordExercise tracks a completed exercise +export interface RecordExercise { + id?: number; + recordRoutineId: number; + exerciseId: number; + startedAt: string; + endedAt: string; + actualRestTime: number; // In seconds + orderIndex: number; + recordSuperSetId?: number | null; + createdAt?: string; + updatedAt?: string; + deletedAt?: string | null; + exercise: Exercise; + recordSets: RecordSet[]; +} + +// RecordSet tracks an individual completed set +export interface RecordSet { + id?: number; + recordExerciseId: number; + setId: number; + actualReps: number; + actualWeight: number; + actualDuration: number; // In seconds + completedAt: string; + orderIndex: number; + createdAt?: string; + updatedAt?: string; + deletedAt?: string | null; + set: Set; +} + +// Additional models for localization +export interface Localization { + id?: number; + languageId: number; + keyword: string; + text: string; + createdAt?: string; + updatedAt?: string; + deletedAt?: string | null; +} + +export interface Language { + id?: number; + name: string; + code: string; + flag: string; + createdAt?: string; + updatedAt?: string; + deletedAt?: string | null; +} + +export interface User { + id?: number; + name: string; + isFemale: string | boolean; + weight: string | number; // in kg + height: string | number; // in cm + birthDate: string; // ISO format date string + createdAt?: string; + updatedAt?: string; +} + +// Stats for the home page +export interface WorkoutStats { + totalWorkouts: number; + totalMinutes: number; + totalExercises: number; + mostFrequentExercise?: { + name: string; + count: number; + }; + mostFrequentRoutine?: { + name: string; + count: number; + }; + recentWorkouts: RecordRoutine[]; +} + +// Some simpler interfaces for UI use when full objects are too complex +export interface ExerciseSimple { + id?: number; + name: string; + muscleGroups: string[]; + equipment: string[]; +} + +export interface SetForUI { + id?: number; + reps: number; + weight: number; + duration?: number; + completed: boolean; +} + +export interface ExerciseForWorkout { + id?: number; + exerciseId: number; + exercise?: Exercise; + sets: SetForUI[]; + notes?: string; +}
A ui/src/vite-env.d.ts

@@ -0,0 +1,1 @@

+/// <reference types="vite/client" />
A ui/tsconfig.app.json

@@ -0,0 +1,27 @@

+{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +}
A ui/tsconfig.json

@@ -0,0 +1,7 @@

+{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +}
A ui/tsconfig.node.json

@@ -0,0 +1,25 @@

+{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +}
A ui/vite.config.ts

@@ -0,0 +1,16 @@

+import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + '/api': { + target: 'http://localhost:3000', + changeOrigin: true, + secure: false, + }, + }, + }, +})