asd
This commit is contained in:
@@ -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
|
||||
|
||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
.env
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}:
|
||||
|
||||
4
frontend/social-raiting/.dockerignore
Normal file
4
frontend/social-raiting/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
*.log
|
||||
21
frontend/social-raiting/Dockerfile
Normal file
21
frontend/social-raiting/Dockerfile
Normal file
@@ -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;"]
|
||||
11
frontend/social-raiting/nginx/default.conf
Normal file
11
frontend/social-raiting/nginx/default.conf
Normal file
@@ -0,0 +1,11 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
509
frontend/social-raiting/package-lock.json
generated
509
frontend/social-raiting/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function App() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container size={"2"}>
|
||||
<Heading>This is an app</Heading>
|
||||
<Button>Go inside</Button>
|
||||
</Container>
|
||||
</>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<MainPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users/:userId/history"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<UserHistoryPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
63
frontend/social-raiting/src/consts/auth.ts
Normal file
63
frontend/social-raiting/src/consts/auth.ts
Normal file
@@ -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()
|
||||
}
|
||||
11
frontend/social-raiting/src/consts/axios.ts
Normal file
11
frontend/social-raiting/src/consts/axios.ts
Normal file
@@ -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}`
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
<Theme accentColor="pink" grayColor="gray" appearance="dark">
|
||||
<App />
|
||||
</Theme>
|
||||
<BrowserRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Theme accentColor="pink" grayColor="gray" appearance="dark">
|
||||
<App />
|
||||
</Theme>
|
||||
</QueryClientProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
67
frontend/social-raiting/src/pages/LoginPage/LoginPage.css
Normal file
67
frontend/social-raiting/src/pages/LoginPage/LoginPage.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
156
frontend/social-raiting/src/pages/LoginPage/LoginPage.tsx
Normal file
156
frontend/social-raiting/src/pages/LoginPage/LoginPage.tsx
Normal file
@@ -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 <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="login-page">
|
||||
<Card className="login-card" size="4" style={{ margin: "auto" }}>
|
||||
<Flex direction="column" gap="5">
|
||||
<Heading weight="medium" color="pink">
|
||||
Check Your Social Rating
|
||||
</Heading>
|
||||
|
||||
<Form.Root onSubmit={f.handleSubmit}>
|
||||
<Flex direction="column" gap="4">
|
||||
<Form.Field className="login-field" name="email">
|
||||
<Flex justify="between" align="center" mb="2">
|
||||
<Form.Label asChild>
|
||||
<Text size="2" weight="medium">
|
||||
Email
|
||||
</Text>
|
||||
</Form.Label>
|
||||
{emailError ? (
|
||||
<Form.Message asChild match={() => !!emailError}>
|
||||
<Text size="1" color="ruby">
|
||||
{emailError}
|
||||
</Text>
|
||||
</Form.Message>
|
||||
) : null}
|
||||
</Flex>
|
||||
|
||||
<Form.Control asChild>
|
||||
<TextField.Root
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="admin@example.com"
|
||||
value={f.values.email}
|
||||
onChange={f.handleChange}
|
||||
onBlur={f.handleBlur}
|
||||
autoComplete="email"
|
||||
size="3"
|
||||
variant="soft"
|
||||
color={emailError ? "ruby" : undefined}
|
||||
radius="large"
|
||||
/>
|
||||
</Form.Control>
|
||||
</Form.Field>
|
||||
|
||||
<Form.Field className="login-field" name="password">
|
||||
<Flex justify="between" align="center" mb="2">
|
||||
<Form.Label asChild>
|
||||
<Text size="2" weight="medium">
|
||||
Password
|
||||
</Text>
|
||||
</Form.Label>
|
||||
{passwordError ? (
|
||||
<Form.Message asChild match={() => !!passwordError}>
|
||||
<Text size="1" color="ruby">
|
||||
{passwordError}
|
||||
</Text>
|
||||
</Form.Message>
|
||||
) : null}
|
||||
</Flex>
|
||||
|
||||
<Form.Control asChild>
|
||||
<TextField.Root
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
value={f.values.password}
|
||||
onChange={f.handleChange}
|
||||
onBlur={f.handleBlur}
|
||||
autoComplete="current-password"
|
||||
size="3"
|
||||
variant="soft"
|
||||
color={passwordError ? "ruby" : undefined}
|
||||
radius="large"
|
||||
/>
|
||||
</Form.Control>
|
||||
</Form.Field>
|
||||
|
||||
{loginMutation.isError ? (
|
||||
<Callout.Root color="ruby" variant="soft" size="2">
|
||||
<Callout.Text>
|
||||
Login failed. Check your credentials and try again.
|
||||
</Callout.Text>
|
||||
</Callout.Root>
|
||||
) : null}
|
||||
|
||||
<Form.Submit asChild>
|
||||
<Button
|
||||
size="3"
|
||||
radius="large"
|
||||
loading={loginMutation.isPending}
|
||||
disabled={!f.isValid || loginMutation.isPending}
|
||||
className="login-submit"
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</Form.Submit>
|
||||
</Flex>
|
||||
</Form.Root>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
173
frontend/social-raiting/src/pages/MainPage/MainPage.tsx
Normal file
173
frontend/social-raiting/src/pages/MainPage/MainPage.tsx
Normal file
@@ -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<UsersResponse>("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 (
|
||||
<Container size="4" py="8">
|
||||
<Flex direction="column" gap="6">
|
||||
<Card size="4">
|
||||
<Flex justify="between" align="center" gap="4" wrap="wrap">
|
||||
<Flex direction="column" gap="2">
|
||||
<Text size="2" weight="medium" color="pink">
|
||||
Social Raiting
|
||||
</Text>
|
||||
<Heading size="7">Users rating table</Heading>
|
||||
<Text size="3" color="gray">
|
||||
Review all users, sorted by current social rating, and adjust their score directly from the table.
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Button variant="soft" color="gray" radius="large" onClick={handleLogout}>
|
||||
Logout
|
||||
</Button>
|
||||
</Flex>
|
||||
</Card>
|
||||
|
||||
<Card size="4">
|
||||
<Flex direction="column" gap="4">
|
||||
<Flex justify="between" align="center" gap="4" wrap="wrap">
|
||||
<Heading size="5">All users</Heading>
|
||||
{changeRatingMutation.isPending ? (
|
||||
<Flex align="center" gap="2">
|
||||
<Spinner size="2" />
|
||||
<Text size="2" color="gray">
|
||||
Updating rating...
|
||||
</Text>
|
||||
</Flex>
|
||||
) : null}
|
||||
</Flex>
|
||||
|
||||
{usersQuery.isLoading ? (
|
||||
<Flex justify="center" py="8">
|
||||
<Spinner size="3" />
|
||||
</Flex>
|
||||
) : null}
|
||||
|
||||
{usersQuery.isError ? (
|
||||
<Text size="2" color="ruby">
|
||||
Failed to load users.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{usersQuery.data ? (
|
||||
<Table.Root variant="surface">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeaderCell>Email</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>Role</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell justify="end">Social rating</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>History</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell justify="end">Actions</Table.ColumnHeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
{usersQuery.data.map((user) => (
|
||||
<Table.Row key={user.id}>
|
||||
<Table.RowHeaderCell>
|
||||
<Flex direction="column" gap="1">
|
||||
<Text weight="medium">{user.email}</Text>
|
||||
<Text size="1" color="gray">
|
||||
ID: {user.id}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Table.RowHeaderCell>
|
||||
<Table.Cell>
|
||||
<Badge color={user.isAdmin ? "pink" : "gray"} variant="soft" radius="full">
|
||||
{user.isAdmin ? "Admin" : "User"}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell justify="end">
|
||||
<Text weight="bold" color={user.score > 0 ? "green" : user.score < 0 ? "ruby" : "gray"}>
|
||||
{user.score > 0 ? `+${user.score}` : user.score}
|
||||
</Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Link asChild color="pink" underline="hover">
|
||||
<RouterLink to={`/users/${user.id}/history`}>
|
||||
Open history
|
||||
</RouterLink>
|
||||
</Link>
|
||||
</Table.Cell>
|
||||
<Table.Cell justify="end">
|
||||
<Flex justify="end" gap="2">
|
||||
<Button
|
||||
size="1"
|
||||
variant="soft"
|
||||
color="green"
|
||||
radius="large"
|
||||
disabled={changeRatingMutation.isPending}
|
||||
onClick={() => changeRatingMutation.mutate({ userId: user.id, direction: "increase" })}
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
<Button
|
||||
size="1"
|
||||
variant="soft"
|
||||
color="ruby"
|
||||
radius="large"
|
||||
disabled={changeRatingMutation.isPending}
|
||||
onClick={() => changeRatingMutation.mutate({ userId: user.id, direction: "decrease" })}
|
||||
>
|
||||
-
|
||||
</Button>
|
||||
</Flex>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
) : null}
|
||||
</Flex>
|
||||
</Card>
|
||||
</Flex>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -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<UserResponse>(`users/${userId}`)
|
||||
return response.data.user
|
||||
},
|
||||
})
|
||||
|
||||
const historyQuery = useQuery({
|
||||
queryKey: ["user-history", userId],
|
||||
enabled: !!userId,
|
||||
queryFn: async () => {
|
||||
const response = await apiInstance.get<HistoryResponse>(`users/${userId}/social-rating/history`)
|
||||
return response.data.operations
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Container size="4" py="8">
|
||||
<Flex direction="column" gap="6">
|
||||
<Card size="4">
|
||||
<Flex justify="between" align="center" gap="4" wrap="wrap">
|
||||
<Flex direction="column" gap="2">
|
||||
<Link asChild color="gray" underline="hover">
|
||||
<RouterLink to="/">Back to users</RouterLink>
|
||||
</Link>
|
||||
<Heading size="7">User rating history</Heading>
|
||||
<Text size="3" color="gray">
|
||||
See every increase and decrease applied to this user and how each operation changed the running balance.
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Button variant="soft" color="gray" radius="large" onClick={() => navigate(-1)}>
|
||||
Go back
|
||||
</Button>
|
||||
</Flex>
|
||||
</Card>
|
||||
|
||||
<Card size="4">
|
||||
{userQuery.isLoading ? (
|
||||
<Flex justify="center" py="6">
|
||||
<Spinner size="3" />
|
||||
</Flex>
|
||||
) : null}
|
||||
|
||||
{userQuery.data ? (
|
||||
<Flex justify="between" align="center" gap="4" wrap="wrap">
|
||||
<Flex direction="column" gap="2">
|
||||
<Heading size="5">{userQuery.data.email}</Heading>
|
||||
<Flex gap="2" wrap="wrap">
|
||||
<Badge color={userQuery.data.isAdmin ? "pink" : "gray"} variant="soft" radius="full">
|
||||
{userQuery.data.isAdmin ? "Admin" : "User"}
|
||||
</Badge>
|
||||
<Badge
|
||||
color={userQuery.data.score > 0 ? "green" : userQuery.data.score < 0 ? "ruby" : "gray"}
|
||||
variant="soft"
|
||||
radius="full"
|
||||
>
|
||||
Score: {userQuery.data.score > 0 ? `+${userQuery.data.score}` : userQuery.data.score}
|
||||
</Badge>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Text size="2" color="gray">
|
||||
User ID: {userQuery.data.id}
|
||||
</Text>
|
||||
</Flex>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<Card size="4">
|
||||
<Flex direction="column" gap="4">
|
||||
<Heading size="5">Operations</Heading>
|
||||
|
||||
{historyQuery.isLoading ? (
|
||||
<Flex justify="center" py="6">
|
||||
<Spinner size="3" />
|
||||
</Flex>
|
||||
) : null}
|
||||
|
||||
{historyQuery.isError ? (
|
||||
<Text size="2" color="ruby">
|
||||
Failed to load user history.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{historyQuery.data ? (
|
||||
<Table.Root variant="surface">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeaderCell>ID</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>Type</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>Delta</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>Balance after</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>Actor</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>Reason</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>Source</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>Created</Table.ColumnHeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
{historyQuery.data.map((operation) => (
|
||||
<Table.Row key={operation.id}>
|
||||
<Table.RowHeaderCell>{operation.id}</Table.RowHeaderCell>
|
||||
<Table.Cell>
|
||||
<Badge
|
||||
color={operation.delta > 0 ? "green" : "ruby"}
|
||||
variant="soft"
|
||||
radius="full"
|
||||
>
|
||||
{operation.operationType}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Text color={operation.delta > 0 ? "green" : "ruby"} weight="medium">
|
||||
{operation.delta > 0 ? `+${operation.delta}` : operation.delta}
|
||||
</Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{operation.balanceAfter}</Table.Cell>
|
||||
<Table.Cell>{operation.actorUserId ?? "System"}</Table.Cell>
|
||||
<Table.Cell>{operation.reason || "—"}</Table.Cell>
|
||||
<Table.Cell>{operation.source || "—"}</Table.Cell>
|
||||
<Table.Cell>{new Date(operation.createdAt).toLocaleString()}</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
) : null}
|
||||
</Flex>
|
||||
</Card>
|
||||
</Flex>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user