diff --git a/.env.example b/.env.example index 35a9fbc..4900e0f 100644 --- a/.env.example +++ b/.env.example @@ -18,10 +18,16 @@ BACKEND_DOCKERFILE=Dockerfile BACKEND_CONTAINER_NAME=social-raiting-backend BACKEND_HOST_PORT=8080 +FRONTEND_BUILD_CONTEXT=./frontend/social-raiting +FRONTEND_DOCKERFILE=Dockerfile +FRONTEND_CONTAINER_NAME=social-raiting-frontend +FRONTEND_HOST_PORT=4173 +VITE_API_BASE_URL=http://social_rating.nekiiinkognito.ru:8080/api/ + SWAGGER_UI_IMAGE=swaggerapi/swagger-ui SWAGGER_UI_CONTAINER_NAME=social-raiting-swagger-ui SWAGGER_UI_HOST_PORT=8081 -SWAGGER_SPEC_PATH=./backend/docs/swagger.yaml +SWAGGER_SPEC_URL=http://backend:8080/swagger.yaml SERVER_PORT=8080 JWT_SECRET=replace-with-a-long-random-secret diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1dcef2d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +.env \ No newline at end of file diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index f591a68..2c79b4e 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -177,6 +177,155 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' + /api/users: + get: + tags: + - Social Rating + summary: List users with current social rating + operationId: listUsersWithRatings + security: + - bearerAuth: [] + parameters: + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 200 + default: 50 + responses: + '200': + description: Users with current rating values + content: + application/json: + schema: + $ref: '#/components/schemas/UsersResponse' + '401': + description: Missing or invalid token + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/users/{userId}: + get: + tags: + - Social Rating + summary: Get user with current social rating + operationId: getUserWithRating + security: + - bearerAuth: [] + parameters: + - in: path + name: userId + required: true + schema: + type: integer + format: uint64 + responses: + '200': + description: User with current rating value + content: + application/json: + schema: + $ref: '#/components/schemas/UserRatingResponse' + '400': + description: Invalid user id + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid token + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: User not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/users/{userId}/social-rating/history: + get: + tags: + - Social Rating + summary: Get social rating history for user + operationId: getUserRatingHistory + security: + - bearerAuth: [] + parameters: + - in: path + name: userId + required: true + schema: + type: integer + format: uint64 + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 200 + default: 50 + responses: + '200': + description: Rating history for user + content: + application/json: + schema: + $ref: '#/components/schemas/HistoryResponse' + '400': + description: Invalid user id + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid token + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: User not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/social-rating/operations: + get: + tags: + - Social Rating + summary: Get recent social rating operations + operationId: getRecentSocialRatingOperations + security: + - bearerAuth: [] + parameters: + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 200 + default: 50 + responses: + '200': + description: Recent rating operations across all users + content: + application/json: + schema: + $ref: '#/components/schemas/HistoryResponse' + '401': + description: Missing or invalid token + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/social-rating/decrease: post: tags: @@ -321,6 +470,34 @@ components: required: - user + UsersResponse: + type: object + properties: + users: + type: array + items: + $ref: '#/components/schemas/UserWithRating' + required: + - users + + UserRatingResponse: + type: object + properties: + user: + $ref: '#/components/schemas/UserWithRating' + required: + - user + + HistoryResponse: + type: object + properties: + operations: + type: array + items: + $ref: '#/components/schemas/SocialRatingOperation' + required: + - operations + User: type: object properties: @@ -350,6 +527,42 @@ components: - createdAt - updatedAt + UserWithRating: + type: object + properties: + id: + type: integer + format: uint64 + example: 2 + email: + type: string + format: email + example: user@example.com + isAdmin: + type: boolean + example: false + score: + type: integer + example: -4 + lastOperationId: + type: integer + format: uint64 + nullable: true + example: 15 + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + required: + - id + - email + - isAdmin + - score + - createdAt + - updatedAt + UserSocialRating: type: object properties: diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index c9061e8..1a7c2e9 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -2,6 +2,7 @@ package server import ( "net/http" + "strings" "time" "github.com/gin-contrib/cors" @@ -16,10 +17,21 @@ import ( func NewRouter(db *gorm.DB, cfg config.Config) *gin.Engine { router := gin.Default() router.Use(cors.New(cors.Config{ - AllowOrigins: []string{"http://localhost:5173", "http://127.0.0.1:5173", "http://localhost:8081", "http://127.0.0.1:8081", "https://social-rating.nekiiinkognito.ru/"}, - AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, - AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"}, - ExposeHeaders: []string{"Content-Length"}, + AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"}, + ExposeHeaders: []string{"Content-Length"}, + AllowOriginFunc: func(origin string) bool { + switch { + case strings.HasPrefix(origin, "http://localhost:"): + return true + case strings.HasPrefix(origin, "http://127.0.0.1:"): + return true + case origin == "https://social-rating.nekiiinkognito.ru": + return true + default: + return false + } + }, AllowCredentials: true, MaxAge: 12 * time.Hour, })) @@ -38,6 +50,10 @@ func NewRouter(db *gorm.DB, cfg config.Config) *gin.Engine { protected := api.Group("/") protected.Use(auth.Middleware(cfg.JWTSecret)) protected.GET("/auth/me", authHandler.Me) + protected.GET("/users", socialRatingHandler.ListUsers) + protected.GET("/users/:userId", socialRatingHandler.GetUser) + protected.GET("/users/:userId/social-rating/history", socialRatingHandler.GetUserHistory) + protected.GET("/social-rating/operations", socialRatingHandler.GetRecentOperations) protected.POST("/social-rating/increase", socialRatingHandler.Increase) protected.POST("/social-rating/decrease", socialRatingHandler.Decrease) diff --git a/backend/internal/socialrating/handler.go b/backend/internal/socialrating/handler.go index 0af3b2f..81433ef 100644 --- a/backend/internal/socialrating/handler.go +++ b/backend/internal/socialrating/handler.go @@ -3,6 +3,7 @@ package socialrating import ( "errors" "net/http" + "strconv" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -26,10 +27,79 @@ type ChangeResponse struct { CurrentRating models.UserSocialRating `json:"currentRating"` } +type UsersResponse struct { + Users []UserWithRating `json:"users"` +} + +type UserRatingResponse struct { + User UserWithRating `json:"user"` +} + +type HistoryResponse struct { + Operations []models.SocialRatingOperation `json:"operations"` +} + func NewHandler(service Service) Handler { return Handler{Service: service} } +func (h Handler) ListUsers(ctx *gin.Context) { + limit := parsePositiveLimit(ctx.Query("limit"), 50) + + users, err := h.Service.ListUsersWithRatings(limit) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load users"}) + return + } + + ctx.JSON(http.StatusOK, UsersResponse{Users: users}) +} + +func (h Handler) GetUser(ctx *gin.Context) { + userID, err := parseUintParam(ctx.Param("userId")) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + user, err := h.Service.GetUserRating(userID) + if err != nil { + handleApplyChangeError(ctx, err) + return + } + + ctx.JSON(http.StatusOK, UserRatingResponse{User: user}) +} + +func (h Handler) GetUserHistory(ctx *gin.Context) { + userID, err := parseUintParam(ctx.Param("userId")) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + limit := parsePositiveLimit(ctx.Query("limit"), 50) + operations, err := h.Service.GetUserHistory(userID, limit) + if err != nil { + handleApplyChangeError(ctx, err) + return + } + + ctx.JSON(http.StatusOK, HistoryResponse{Operations: operations}) +} + +func (h Handler) GetRecentOperations(ctx *gin.Context) { + limit := parsePositiveLimit(ctx.Query("limit"), 50) + + operations, err := h.Service.GetRecentOperations(limit) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load operations"}) + return + } + + ctx.JSON(http.StatusOK, HistoryResponse{Operations: operations}) +} + func (h Handler) Increase(ctx *gin.Context) { h.applySignedChange(ctx, "increase", 1) } @@ -97,3 +167,12 @@ func handleApplyChangeError(ctx *gin.Context, err error) { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) } } + +func parseUintParam(raw string) (uint, error) { + value, err := strconv.ParseUint(raw, 10, 64) + if err != nil || value == 0 { + return 0, errors.New("invalid user id") + } + + return uint(value), nil +} diff --git a/backend/internal/socialrating/service.go b/backend/internal/socialrating/service.go index f20de4d..0508327 100644 --- a/backend/internal/socialrating/service.go +++ b/backend/internal/socialrating/service.go @@ -2,7 +2,9 @@ package socialrating import ( "errors" + "strconv" "strings" + "time" "gorm.io/gorm" @@ -22,6 +24,16 @@ type ChangeInput struct { Source string } +type UserWithRating struct { + ID uint `json:"id"` + Email string `json:"email"` + IsAdmin bool `json:"isAdmin"` + Score int `json:"score"` + LastOperationID *uint `json:"lastOperationId,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + func NewService(db *gorm.DB) Service { return Service{DB: db} } @@ -95,3 +107,131 @@ func ensureUserExists(tx *gorm.DB, userID uint) error { var user models.User return tx.First(&user, "id = ?", userID).Error } + +func (s Service) ListUsersWithRatings(limit int) ([]UserWithRating, error) { + if limit <= 0 { + limit = 50 + } + + var rows []struct { + ID uint + Email string + IsAdmin bool + Score int + LastOperationID *uint + CreatedAt time.Time + UpdatedAt time.Time + } + + err := s.DB. + Table("users"). + Select( + "users.id, users.email, users.is_admin, COALESCE(user_social_ratings.score, 0) AS score, user_social_ratings.last_operation_id, users.created_at, users.updated_at", + ). + Joins("LEFT JOIN user_social_ratings ON user_social_ratings.user_id = users.id"). + Order("score DESC, users.id ASC"). + Limit(limit). + Scan(&rows).Error + if err != nil { + return nil, err + } + + result := make([]UserWithRating, 0, len(rows)) + for _, row := range rows { + result = append(result, UserWithRating{ + ID: row.ID, + Email: row.Email, + IsAdmin: row.IsAdmin, + Score: row.Score, + LastOperationID: row.LastOperationID, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + }) + } + + return result, nil +} + +func (s Service) GetUserRating(userID uint) (UserWithRating, error) { + var row struct { + ID uint + Email string + IsAdmin bool + Score int + LastOperationID *uint + CreatedAt time.Time + UpdatedAt time.Time + } + + err := s.DB. + Table("users"). + Select( + "users.id, users.email, users.is_admin, COALESCE(user_social_ratings.score, 0) AS score, user_social_ratings.last_operation_id, users.created_at, users.updated_at", + ). + Joins("LEFT JOIN user_social_ratings ON user_social_ratings.user_id = users.id"). + Where("users.id = ?", userID). + Take(&row).Error + if err != nil { + return UserWithRating{}, err + } + + return UserWithRating{ + ID: row.ID, + Email: row.Email, + IsAdmin: row.IsAdmin, + Score: row.Score, + LastOperationID: row.LastOperationID, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + }, nil +} + +func (s Service) GetUserHistory(userID uint, limit int) ([]models.SocialRatingOperation, error) { + if limit <= 0 { + limit = 50 + } + + if err := ensureUserExists(s.DB, userID); err != nil { + return nil, err + } + + var operations []models.SocialRatingOperation + err := s.DB. + Where("target_user_id = ?", userID). + Order("created_at DESC, id DESC"). + Limit(limit). + Find(&operations).Error + + return operations, err +} + +func (s Service) GetRecentOperations(limit int) ([]models.SocialRatingOperation, error) { + if limit <= 0 { + limit = 50 + } + + var operations []models.SocialRatingOperation + err := s.DB. + Order("created_at DESC, id DESC"). + Limit(limit). + Find(&operations).Error + + return operations, err +} + +func parsePositiveLimit(raw string, fallback int) int { + if raw == "" { + return fallback + } + + value, err := strconv.Atoi(raw) + if err != nil || value <= 0 { + return fallback + } + + if value > 200 { + return 200 + } + + return value +} diff --git a/docker-compose.yml b/docker-compose.yml index fc5e3e3..e090d7e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: ports: - "${MYSQL_HOST_PORT}:3306" volumes: - - volume:/var/lib/mysql + - ${MYSQL_VOLUME_NAME}:/var/lib/mysql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-p${MYSQL_ROOT_PASSWORD}"] interval: ${MYSQL_HEALTHCHECK_INTERVAL} @@ -29,5 +29,29 @@ services: ports: - "${BACKEND_HOST_PORT}:${SERVER_PORT}" + frontend: + build: + context: ${FRONTEND_BUILD_CONTEXT} + dockerfile: ${FRONTEND_DOCKERFILE} + args: + VITE_API_BASE_URL: ${VITE_API_BASE_URL} + container_name: ${FRONTEND_CONTAINER_NAME} + restart: ${COMPOSE_RESTART_POLICY} + depends_on: + - backend + ports: + - "${FRONTEND_HOST_PORT}:80" + + swagger-ui: + image: ${SWAGGER_UI_IMAGE} + container_name: ${SWAGGER_UI_CONTAINER_NAME} + restart: ${COMPOSE_RESTART_POLICY} + depends_on: + - backend + environment: + URL: ${SWAGGER_SPEC_URL} + ports: + - "${SWAGGER_UI_HOST_PORT}:8080" + volumes: - volume: + ${MYSQL_VOLUME_NAME}: diff --git a/frontend/social-raiting/.dockerignore b/frontend/social-raiting/.dockerignore new file mode 100644 index 0000000..3b24e4a --- /dev/null +++ b/frontend/social-raiting/.dockerignore @@ -0,0 +1,4 @@ +node_modules +dist +.env +*.log diff --git a/frontend/social-raiting/Dockerfile b/frontend/social-raiting/Dockerfile new file mode 100644 index 0000000..51f0246 --- /dev/null +++ b/frontend/social-raiting/Dockerfile @@ -0,0 +1,21 @@ +FROM node:22-alpine AS builder + +WORKDIR /app + +ARG VITE_API_BASE_URL=http://localhost:8080/api/ +ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +FROM nginx:1.29-alpine + +COPY nginx/default.conf /etc/nginx/conf.d/default.conf +COPY --from=builder /app/dist /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/social-raiting/nginx/default.conf b/frontend/social-raiting/nginx/default.conf new file mode 100644 index 0000000..3aa17e6 --- /dev/null +++ b/frontend/social-raiting/nginx/default.conf @@ -0,0 +1,11 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/social-raiting/package-lock.json b/frontend/social-raiting/package-lock.json index 329cc78..6e37cbe 100644 --- a/frontend/social-raiting/package-lock.json +++ b/frontend/social-raiting/package-lock.json @@ -8,9 +8,15 @@ "name": "social-raiting", "version": "0.0.0", "dependencies": { + "@radix-ui/react-form": "^0.1.8", "@radix-ui/themes": "^3.3.0", + "@tanstack/react-query": "^5.99.0", + "axios": "^1.15.0", + "formik": "^2.4.9", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "react-router": "^7.14.1", + "yup": "^1.7.1" }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -2372,6 +2378,29 @@ "node": ">=14.0.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.0.0-rc.15", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", @@ -2413,6 +2442,32 @@ "dev": true, "license": "MIT" }, + "node_modules/@tanstack/query-core": { + "version": "5.99.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.99.0.tgz", + "integrity": "sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.99.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.99.0.tgz", + "integrity": "sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.99.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -2431,6 +2486,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz", + "integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==", + "license": "MIT", + "dependencies": { + "hoist-non-react-statics": "^3.3.0" + }, + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2453,7 +2520,6 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -2869,6 +2935,23 @@ "node": ">=10" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2935,6 +3018,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3009,6 +3105,18 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3023,6 +3131,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3042,7 +3163,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -3070,6 +3190,24 @@ "dev": true, "license": "MIT" }, + "node_modules/deepmerge": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz", + "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3086,6 +3224,20 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.340", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz", @@ -3093,6 +3245,51 @@ "dev": true, "license": "ISC" }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3391,6 +3588,67 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formik": { + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/formik/-/formik-2.4.9.tgz", + "integrity": "sha512-5nI94BMnlFDdQRBY4Sz39WkhxajZJ57Fzs8wVbtsQlm5ScKIR1QLYqv/ultBnobObtlUyxpxoLodpixrsf36Og==", + "funding": [ + { + "type": "individual", + "url": "https://opencollective.com/formik" + } + ], + "license": "Apache-2.0", + "dependencies": { + "@types/hoist-non-react-statics": "^3.3.1", + "deepmerge": "^2.1.1", + "hoist-non-react-statics": "^3.3.0", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "react-fast-compare": "^2.0.1", + "tiny-warning": "^1.0.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3406,6 +3664,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -3416,6 +3683,30 @@ "node": ">=6.9.0" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -3425,6 +3716,19 @@ "node": ">=6" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3451,6 +3755,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3461,6 +3777,45 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -3478,6 +3833,15 @@ "hermes-estree": "0.25.1" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3913,6 +4277,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -3930,6 +4306,36 @@ "yallist": "^3.0.2" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -4126,6 +4532,21 @@ "node": ">= 0.8.0" } }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4236,6 +4657,18 @@ "react": "^19.2.5" } }, + "node_modules/react-fast-compare": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", + "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==", + "license": "MIT" + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -4283,6 +4716,28 @@ } } }, + "node_modules/react-router": { + "version": "7.14.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.1.tgz", + "integrity": "sha512-5BCvFskyAAVumqhEKh/iPhLOIkfxcEUz8WqFIARCkMg8hZZzDYX9CtwxXA0e+qT8zAxmMC0x3Ckb9iMONwc5jg==", + "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-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", @@ -4372,6 +4827,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4431,6 +4892,18 @@ "node": ">=8" } }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", + "license": "MIT" + }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -4448,6 +4921,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -4480,6 +4959,18 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -4744,6 +5235,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yup": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz", + "integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==", + "license": "MIT", + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", diff --git a/frontend/social-raiting/package.json b/frontend/social-raiting/package.json index df41734..03d5f1d 100644 --- a/frontend/social-raiting/package.json +++ b/frontend/social-raiting/package.json @@ -10,9 +10,15 @@ "preview": "vite preview" }, "dependencies": { + "@radix-ui/react-form": "^0.1.8", "@radix-ui/themes": "^3.3.0", + "@tanstack/react-query": "^5.99.0", + "axios": "^1.15.0", + "formik": "^2.4.9", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "react-router": "^7.14.1", + "yup": "^1.7.1" }, "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/frontend/social-raiting/src/App.tsx b/frontend/social-raiting/src/App.tsx index 1642cc3..cf439aa 100644 --- a/frontend/social-raiting/src/App.tsx +++ b/frontend/social-raiting/src/App.tsx @@ -1,14 +1,40 @@ -import { Button, Container, Heading } from "@radix-ui/themes" +import type { ReactNode } from "react" +import { Navigate, Route, Routes } from "react-router" +import { isAuthenticated } from "./consts/auth" +import { LoginPage } from "./pages/LoginPage/LoginPage" +import { MainPage } from "./pages/MainPage/MainPage" +import { UserHistoryPage } from "./pages/UserHistoryPage/UserHistoryPage" + +const ProtectedRoute = ({ children }: { children: ReactNode }) => { + if (!isAuthenticated()) { + return + } + + return <>{children} +} function App() { - return ( - <> - - This is an app - - - + + } /> + + + + } + /> + + + + } + /> + } /> + ) } diff --git a/frontend/social-raiting/src/consts/auth.ts b/frontend/social-raiting/src/consts/auth.ts new file mode 100644 index 0000000..88cccd9 --- /dev/null +++ b/frontend/social-raiting/src/consts/auth.ts @@ -0,0 +1,63 @@ +const TOKEN_STORAGE_KEY = "social_raiting_token" + +type JwtPayload = { + exp?: number +} + +function parseJwtPayload(token: string): JwtPayload | null { + try { + const [, payload] = token.split(".") + if (!payload) { + return null + } + + const normalized = payload + .replace(/-/g, "+") + .replace(/_/g, "/") + .padEnd(Math.ceil(payload.length / 4) * 4, "=") + + const decoded = window.atob(normalized) + return JSON.parse(decoded) as JwtPayload + } catch { + return null + } +} + +export function getStoredToken() { + return window.localStorage.getItem(TOKEN_STORAGE_KEY) +} + +export function setStoredToken(token: string) { + window.localStorage.setItem(TOKEN_STORAGE_KEY, token) +} + +export function clearStoredToken() { + window.localStorage.removeItem(TOKEN_STORAGE_KEY) +} + +export function isTokenExpired(token: string) { + const payload = parseJwtPayload(token) + if (!payload?.exp) { + return true + } + + return payload.exp * 1000 <= Date.now() +} + +export function getValidToken() { + const token = getStoredToken() + if (!token) { + return null + } + + if (isTokenExpired(token)) { + clearStoredToken() + return null + } + + return token +} + +export function isAuthenticated() { + return !!getValidToken() +} diff --git a/frontend/social-raiting/src/consts/axios.ts b/frontend/social-raiting/src/consts/axios.ts new file mode 100644 index 0000000..fc49c3d --- /dev/null +++ b/frontend/social-raiting/src/consts/axios.ts @@ -0,0 +1,11 @@ +import axios from "axios"; +import { getValidToken } from "./auth"; + +const baseApiUrl = import.meta.env.VITE_API_BASE_URL || "http://localhost:8080/api/" + +export const apiInstance = axios.create({ baseURL: baseApiUrl, transformResponse: (r) => JSON.parse(r) }); + +const token = getValidToken() +if (token) { + apiInstance.defaults.headers.common.Authorization = `Bearer ${token}` +} diff --git a/frontend/social-raiting/src/index.css b/frontend/social-raiting/src/index.css index e69de29..d5b645c 100644 --- a/frontend/social-raiting/src/index.css +++ b/frontend/social-raiting/src/index.css @@ -0,0 +1,12 @@ +html, +body, +#root { + min-height: 100%; +} + +body { + margin: 0; + background: + radial-gradient(circle at top, var(--accent-a3), transparent 35%), + linear-gradient(180deg, var(--gray-1), var(--gray-2)); +} diff --git a/frontend/social-raiting/src/main.tsx b/frontend/social-raiting/src/main.tsx index 4d087cf..012e6af 100644 --- a/frontend/social-raiting/src/main.tsx +++ b/frontend/social-raiting/src/main.tsx @@ -3,9 +3,17 @@ import App from './App.tsx' import './index.css' import "@radix-ui/themes/styles.css"; import { Theme } from '@radix-ui/themes' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { BrowserRouter } from 'react-router'; + +const queryClient = new QueryClient() createRoot(document.getElementById('root')!).render( - - - + + + + + + + ) diff --git a/frontend/social-raiting/src/pages/LoginPage/LoginPage.css b/frontend/social-raiting/src/pages/LoginPage/LoginPage.css new file mode 100644 index 0000000..22e7643 --- /dev/null +++ b/frontend/social-raiting/src/pages/LoginPage/LoginPage.css @@ -0,0 +1,67 @@ +.login-page { + position: relative; + display: flex; + min-height: 100vh; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 32px 20px; +} + +.login-card { + position: relative; + z-index: 1; + width: min(100%, 480px); + border: 1px solid var(--gray-a6); + background: + linear-gradient(180deg, var(--gray-a2), var(--gray-a3)), + radial-gradient(circle at top, var(--accent-a4), transparent 55%); + box-shadow: + 0 30px 80px rgba(0, 0, 0, 0.35), + inset 0 1px 0 var(--gray-a4); + backdrop-filter: blur(18px); +} + +.login-field { + width: 100%; +} + +.login-submit { + width: 100%; +} + +.login-page__glow { + position: absolute; + border-radius: 999px; + filter: blur(90px); + opacity: 0.32; + pointer-events: none; +} + +.login-page__glow--primary { + top: 10%; + left: -5%; + width: 260px; + height: 260px; + background: var(--accent-8); +} + +.login-page__glow--secondary { + right: -8%; + bottom: 4%; + width: 320px; + height: 320px; + background: var(--gray-8); +} + +@media (max-width: 640px) { + .login-page { + padding: 20px 16px; + align-items: stretch; + } + + .login-card { + width: 100%; + margin: auto 0; + } +} diff --git a/frontend/social-raiting/src/pages/LoginPage/LoginPage.tsx b/frontend/social-raiting/src/pages/LoginPage/LoginPage.tsx new file mode 100644 index 0000000..6663571 --- /dev/null +++ b/frontend/social-raiting/src/pages/LoginPage/LoginPage.tsx @@ -0,0 +1,156 @@ +import * as Form from "@radix-ui/react-form" +import { Button, Callout, Card, Container, Flex, Heading, Text, TextField } from "@radix-ui/themes" +import { useMutation } from "@tanstack/react-query" +import { Navigate, useNavigate } from "react-router" +import { useFormik } from "formik" +import * as yup from "yup" +import { apiInstance } from "../../consts/axios" +import { isAuthenticated, setStoredToken } from "../../consts/auth" +import "./LoginPage.css" + +const valSch = yup.object({ + email: yup.string().required().email(), + password: yup.string().required() +}) + +export const LoginPage = () => { + const navigate = useNavigate() + + const f = useFormik({ + validationSchema: valSch, + initialValues: { + email: "", + password: "" + }, + onSubmit: () => { + loginMutation.mutate() + } + }) + + const loginMutation = useMutation({ + mutationKey: ["login"], + mutationFn: async () => { + console.log("email", f.values.email) + const response = await apiInstance.post("auth/login", JSON.stringify({ + email: f.values.email, + password: f.values.password + })) + + return response.data + }, + onSuccess(data) { + setStoredToken(data.token) + apiInstance.defaults.headers.common.Authorization = `Bearer ${data.token}` + navigate("/", { replace: true }) + }, + }) + + const emailError = f.touched.email ? f.errors.email : undefined + const passwordError = f.touched.password ? f.errors.password : undefined + + if (isAuthenticated()) { + return + } + + return ( + + + + + Check Your Social Rating + + + + + + + + + Email + + + {emailError ? ( + !!emailError}> + + {emailError} + + + ) : null} + + + + + + + + + + + + Password + + + {passwordError ? ( + !!passwordError}> + + {passwordError} + + + ) : null} + + + + + + + + {loginMutation.isError ? ( + + + Login failed. Check your credentials and try again. + + + ) : null} + + + + + + + + + + ) +} diff --git a/frontend/social-raiting/src/pages/MainPage/MainPage.tsx b/frontend/social-raiting/src/pages/MainPage/MainPage.tsx new file mode 100644 index 0000000..b3c1f15 --- /dev/null +++ b/frontend/social-raiting/src/pages/MainPage/MainPage.tsx @@ -0,0 +1,173 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { Badge, Button, Card, Container, Flex, Heading, Link, Spinner, Table, Text } from "@radix-ui/themes" +import { Link as RouterLink, useNavigate } from "react-router" +import { clearStoredToken } from "../../consts/auth" +import { apiInstance } from "../../consts/axios" + +type UserRow = { + id: number + email: string + isAdmin: boolean + score: number + lastOperationId?: number + createdAt: string + updatedAt: string +} + +type UsersResponse = { + users: UserRow[] +} + +export const MainPage = () => { + const navigate = useNavigate() + const queryClient = useQueryClient() + + const usersQuery = useQuery({ + queryKey: ["users"], + queryFn: async () => { + const response = await apiInstance.get("users") + return response.data.users + }, + }) + + const changeRatingMutation = useMutation({ + mutationFn: async ({ userId, direction }: { userId: number, direction: "increase" | "decrease" }) => { + await apiInstance.post(`social-rating/${direction}`, { + targetUserId: userId, + amount: 1, + source: "ui", + }) + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["users"] }) + }, + }) + + const handleLogout = () => { + clearStoredToken() + delete apiInstance.defaults.headers.common.Authorization + navigate("/login", { replace: true }) + } + + return ( + + + + + + + Social Raiting + + Users rating table + + Review all users, sorted by current social rating, and adjust their score directly from the table. + + + + + + + + + + + All users + {changeRatingMutation.isPending ? ( + + + + Updating rating... + + + ) : null} + + + {usersQuery.isLoading ? ( + + + + ) : null} + + {usersQuery.isError ? ( + + Failed to load users. + + ) : null} + + {usersQuery.data ? ( + + + + Email + Role + Social rating + History + Actions + + + + + {usersQuery.data.map((user) => ( + + + + {user.email} + + ID: {user.id} + + + + + + {user.isAdmin ? "Admin" : "User"} + + + + 0 ? "green" : user.score < 0 ? "ruby" : "gray"}> + {user.score > 0 ? `+${user.score}` : user.score} + + + + + + Open history + + + + + + + + + + + ))} + + + ) : null} + + + + + ) +} diff --git a/frontend/social-raiting/src/pages/UserHistoryPage/UserHistoryPage.tsx b/frontend/social-raiting/src/pages/UserHistoryPage/UserHistoryPage.tsx new file mode 100644 index 0000000..2972364 --- /dev/null +++ b/frontend/social-raiting/src/pages/UserHistoryPage/UserHistoryPage.tsx @@ -0,0 +1,173 @@ +import { useQuery } from "@tanstack/react-query" +import { Badge, Button, Card, Container, Flex, Heading, Link, Spinner, Table, Text } from "@radix-ui/themes" +import { Link as RouterLink, useNavigate, useParams } from "react-router" +import { apiInstance } from "../../consts/axios" + +type Operation = { + id: number + targetUserId: number + actorUserId?: number + delta: number + operationType: string + reason?: string + source?: string + balanceAfter: number + createdAt: string +} + +type HistoryResponse = { + operations: Operation[] +} + +type UserResponse = { + user: { + id: number + email: string + isAdmin: boolean + score: number + lastOperationId?: number + createdAt: string + updatedAt: string + } +} + +export const UserHistoryPage = () => { + const navigate = useNavigate() + const params = useParams() + const userId = params.userId + + const userQuery = useQuery({ + queryKey: ["user", userId], + enabled: !!userId, + queryFn: async () => { + const response = await apiInstance.get(`users/${userId}`) + return response.data.user + }, + }) + + const historyQuery = useQuery({ + queryKey: ["user-history", userId], + enabled: !!userId, + queryFn: async () => { + const response = await apiInstance.get(`users/${userId}/social-rating/history`) + return response.data.operations + }, + }) + + return ( + + + + + + + Back to users + + User rating history + + See every increase and decrease applied to this user and how each operation changed the running balance. + + + + + + + + + {userQuery.isLoading ? ( + + + + ) : null} + + {userQuery.data ? ( + + + {userQuery.data.email} + + + {userQuery.data.isAdmin ? "Admin" : "User"} + + 0 ? "green" : userQuery.data.score < 0 ? "ruby" : "gray"} + variant="soft" + radius="full" + > + Score: {userQuery.data.score > 0 ? `+${userQuery.data.score}` : userQuery.data.score} + + + + + User ID: {userQuery.data.id} + + + ) : null} + + + + + Operations + + {historyQuery.isLoading ? ( + + + + ) : null} + + {historyQuery.isError ? ( + + Failed to load user history. + + ) : null} + + {historyQuery.data ? ( + + + + ID + Type + Delta + Balance after + Actor + Reason + Source + Created + + + + + {historyQuery.data.map((operation) => ( + + {operation.id} + + 0 ? "green" : "ruby"} + variant="soft" + radius="full" + > + {operation.operationType} + + + + 0 ? "green" : "ruby"} weight="medium"> + {operation.delta > 0 ? `+${operation.delta}` : operation.delta} + + + {operation.balanceAfter} + {operation.actorUserId ?? "System"} + {operation.reason || "—"} + {operation.source || "—"} + {new Date(operation.createdAt).toLocaleString()} + + ))} + + + ) : null} + + + + + ) +}