From 88166a2c7d8ecd3c8fc41ee33863221ccca7b391 Mon Sep 17 00:00:00 2001 From: Snowykami Date: Sun, 14 Sep 2025 23:52:18 +0800 Subject: [PATCH] feat: add sidebar component with context and mobile support - Implemented Sidebar component with collapsible functionality. - Added SidebarProvider for managing open state and keyboard shortcuts. - Created SidebarTrigger, SidebarRail, and various sidebar elements (Header, Footer, Content, etc.). - Integrated mobile responsiveness using Sheet component. - Added utility hooks for mobile detection. feat: create table component for structured data display - Developed Table component with subcomponents: TableHeader, TableBody, TableFooter, TableRow, TableCell, and TableCaption. - Enhanced styling for better readability and usability. feat: implement tabs component for navigation - Created Tabs component with TabsList, TabsTrigger, and TabsContent for tabbed navigation. - Ensured accessibility and responsive design. feat: add toggle group component for grouped toggle buttons - Developed ToggleGroup and ToggleGroupItem components for managing toggle states. - Integrated context for consistent styling and behavior. feat: create toggle component for binary state representation - Implemented Toggle component with variant and size options. - Enhanced user interaction with visual feedback. feat: add tooltip component for contextual information - Developed Tooltip, TooltipTrigger, and TooltipContent for displaying additional information on hover. - Integrated animations for a smoother user experience. feat: implement mobile detection hook - Created useIsMobile hook to determine if the user is on a mobile device. - Utilized matchMedia for responsive design adjustments. --- internal/middleware/auth.go | 45 +- internal/middleware/track.go | 15 + internal/model/like.go | 14 +- internal/model/post.go | 15 +- internal/repo/comment.go | 10 + internal/repo/like.go | 13 - internal/repo/post.go | 166 ++-- internal/router/apiv1/admin.go | 27 +- internal/router/apiv1/page.go | 3 +- internal/router/apiv1/post.go | 3 +- internal/router/router.go | 3 +- internal/service/post.go | 253 +++--- pkg/constant/constant.go | 98 +-- pkg/utils/oidc.go | 84 +- web/package.json | 17 +- web/pnpm-lock.yaml | 675 +++++++++++++++ web/src/api/post.ts | 2 +- web/src/api/user.ts | 5 + web/src/app/(main)/layout.tsx | 1 - web/src/app/(main)/p/[id]/page.tsx | 13 +- web/src/app/console/data.json | 614 +++++++++++++ web/src/app/console/page.tsx | 45 + web/src/app/layout.tsx | 6 +- web/src/components/blog-home/blog-home.tsx | 1 - web/src/components/blog-post/blog-post.tsx | 6 +- web/src/components/comment/comment-item.tsx | 4 +- web/src/components/comment/index.tsx | 27 +- web/src/components/console/app-sidebar.tsx | 183 ++++ .../console/chart-area-interactive.tsx | 291 +++++++ web/src/components/console/data-table.tsx | 807 ++++++++++++++++++ web/src/components/console/nav-documents.tsx | 92 ++ web/src/components/console/nav-main.tsx | 39 + web/src/components/console/nav-secondary.tsx | 42 + web/src/components/console/nav-user.tsx | 110 +++ web/src/components/console/section-cards.tsx | 102 +++ web/src/components/console/site-header.tsx | 30 + .../layout/avatar-with-dropdown-menu.tsx | 92 ++ web/src/components/layout/navbar-or-side.tsx | 6 +- web/src/components/ui/avatar.tsx | 53 ++ web/src/components/ui/badge.tsx | 29 +- web/src/components/ui/breadcrumb.tsx | 109 +++ web/src/components/ui/card.tsx | 46 +- web/src/components/ui/chart.tsx | 353 ++++++++ web/src/components/ui/drawer.tsx | 135 +++ web/src/components/ui/dropdown-menu.tsx | 257 ++++++ web/src/components/ui/input.tsx | 14 +- web/src/components/ui/label.tsx | 12 +- web/src/components/ui/sheet.tsx | 56 +- web/src/components/ui/sidebar.tsx | 726 ++++++++++++++++ web/src/components/ui/table.tsx | 116 +++ web/src/components/ui/tabs.tsx | 66 ++ web/src/components/ui/toggle-group.tsx | 73 ++ web/src/components/ui/toggle.tsx | 47 + web/src/components/ui/tooltip.tsx | 61 ++ web/src/components/user/user-header.tsx | 2 +- web/src/hooks/use-mobile.ts | 19 + web/src/hooks/use-route.ts | 1 + web/src/hooks/use-to-login.ts | 0 web/src/locales/zh-CN.json | 3 + web/src/utils/client/device.ts | 3 +- 60 files changed, 5680 insertions(+), 460 deletions(-) create mode 100644 web/src/app/console/data.json create mode 100644 web/src/app/console/page.tsx create mode 100644 web/src/components/console/app-sidebar.tsx create mode 100644 web/src/components/console/chart-area-interactive.tsx create mode 100644 web/src/components/console/data-table.tsx create mode 100644 web/src/components/console/nav-documents.tsx create mode 100644 web/src/components/console/nav-main.tsx create mode 100644 web/src/components/console/nav-secondary.tsx create mode 100644 web/src/components/console/nav-user.tsx create mode 100644 web/src/components/console/section-cards.tsx create mode 100644 web/src/components/console/site-header.tsx create mode 100644 web/src/components/layout/avatar-with-dropdown-menu.tsx create mode 100644 web/src/components/ui/avatar.tsx create mode 100644 web/src/components/ui/breadcrumb.tsx create mode 100644 web/src/components/ui/chart.tsx create mode 100644 web/src/components/ui/drawer.tsx create mode 100644 web/src/components/ui/dropdown-menu.tsx create mode 100644 web/src/components/ui/sidebar.tsx create mode 100644 web/src/components/ui/table.tsx create mode 100644 web/src/components/ui/tabs.tsx create mode 100644 web/src/components/ui/toggle-group.tsx create mode 100644 web/src/components/ui/toggle.tsx create mode 100644 web/src/components/ui/tooltip.tsx create mode 100644 web/src/hooks/use-mobile.ts delete mode 100644 web/src/hooks/use-to-login.ts diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index a6db2e6..ebfb7f1 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -2,6 +2,9 @@ package middleware import ( "context" + "strings" + "time" + "github.com/cloudwego/hertz/pkg/app" "github.com/sirupsen/logrus" "github.com/snowykami/neo-blog/internal/ctxutils" @@ -9,8 +12,6 @@ import ( "github.com/snowykami/neo-blog/pkg/constant" "github.com/snowykami/neo-blog/pkg/resps" "github.com/snowykami/neo-blog/pkg/utils" - "strings" - "time" ) func UseAuth(block bool) app.HandlerFunc { @@ -79,6 +80,46 @@ func UseAuth(block bool) app.HandlerFunc { } } +// UseRole 检查用户角色是否符合要求,必须在 UseAuth 之后使用 +// requiredRole 可以是 "admin", "editor", "user" 等 +// admin包含editor, editor包含user +func UseRole(requiredRole string) app.HandlerFunc { + return func(ctx context.Context, c *app.RequestContext) { + currentUserID := ctx.Value(constant.ContextKeyUserID) + if currentUserID == nil { + resps.Unauthorized(c, resps.ErrUnauthorized) + c.Abort() + return + } + userID := currentUserID.(uint) + user, err := repo.User.GetUserByID(userID) + if err != nil { + resps.InternalServerError(c, resps.ErrInternalServerError) + c.Abort() + return + } + if user == nil { + resps.Unauthorized(c, resps.ErrUnauthorized) + c.Abort() + return + } + + roleHierarchy := map[string]int{ + constant.RoleUser: 1, + constant.RoleEditor: 2, + constant.RoleAdmin: 3, + } + userRoleLevel := roleHierarchy[user.Role] + requiredRoleLevel := roleHierarchy[requiredRole] + if userRoleLevel < requiredRoleLevel { + resps.Forbidden(c, resps.ErrForbidden) + c.Abort() + return + } + c.Next(ctx) + } +} + func isStatefulJwtValid(claims *utils.Claims) (bool, error) { if !claims.Stateful { return true, nil diff --git a/internal/middleware/track.go b/internal/middleware/track.go index c870d7c..01ea1b8 100644 --- a/internal/middleware/track.go +++ b/internal/middleware/track.go @@ -1 +1,16 @@ package middleware + +import ( + "context" + + "github.com/cloudwego/hertz/pkg/app" + "github.com/snowykami/neo-blog/pkg/constant" +) + +func UseTrack() app.HandlerFunc { + return func(ctx context.Context, c *app.RequestContext) { + ctx = context.WithValue(ctx, constant.ContextKeyRemoteAddr, c.ClientIP()) + ctx = context.WithValue(ctx, constant.ContextKeyUserAgent, c.UserAgent()) + c.Next(ctx) + } +} diff --git a/internal/model/like.go b/internal/model/like.go index 6947dae..3aa61cc 100644 --- a/internal/model/like.go +++ b/internal/model/like.go @@ -18,8 +18,18 @@ type Like struct { func (l *Like) AfterCreate(tx *gorm.DB) (err error) { switch l.TargetType { case constant.TargetTypePost: - return tx.Model(&Post{}).Where("id = ?", l.TargetID). - UpdateColumn("like_count", gorm.Expr("like_count + ?", 1)).Error + // 点赞数+1 + if err := tx.Model(&Post{}).Where("id = ?", l.TargetID). + UpdateColumn("like_count", gorm.Expr("like_count + ?", 1)).Error; err != nil { + return err + } + // 查询最新 Post + var post Post + if err := tx.First(&post, l.TargetID).Error; err != nil { + return err + } + // 更新热度 + return tx.Model(&post).UpdateColumn("heat", post.CalculateHeat()).Error case constant.TargetTypeComment: return tx.Model(&Comment{}).Where("id = ?", l.TargetID). UpdateColumn("like_count", gorm.Expr("like_count + ?", 1)).Error diff --git a/internal/model/post.go b/internal/model/post.go index 311b6c8..c8b9a8a 100644 --- a/internal/model/post.go +++ b/internal/model/post.go @@ -22,7 +22,7 @@ type Post struct { LikeCount uint64 CommentCount uint64 ViewCount uint64 - Heat uint64 `gorm:"default:0"` + Heat uint64 } // CalculateHeat 热度计算 @@ -34,17 +34,6 @@ func (p *Post) CalculateHeat() float64 { ) } -// AfterUpdate 热度指标更新后更新热度 -func (p *Post) AfterUpdate(tx *gorm.DB) (err error) { - if tx.Statement.Changed("LikeCount") || tx.Statement.Changed("CommentCount") || tx.Statement.Changed("ViewCount") { - p.Heat = uint64(p.CalculateHeat()) - if err := tx.Model(p).Update("heat", p.Heat).Error; err != nil { - return err - } - } - return nil -} - func (p *Post) ToDto() *dto.PostDto { return &dto.PostDto{ ID: p.ID, @@ -65,7 +54,7 @@ func (p *Post) ToDto() *dto.PostDto { LikeCount: p.LikeCount, CommentCount: p.CommentCount, ViewCount: p.ViewCount, - Heat: p.Heat, + Heat: uint64(p.CalculateHeat()), CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, User: p.User.ToDto(), diff --git a/internal/repo/comment.go b/internal/repo/comment.go index 688c2e3..e4b84d5 100644 --- a/internal/repo/comment.go +++ b/internal/repo/comment.go @@ -102,6 +102,7 @@ func (cr *CommentRepo) CreateComment(comment *model.Comment) (uint, error) { return err } commentID = comment.ID // 记录主键 + // 更新目标的评论数量 switch comment.TargetType { case constant.TargetTypePost: var count int64 @@ -114,6 +115,15 @@ func (cr *CommentRepo) CreateComment(comment *model.Comment) (uint, error) { UpdateColumn("comment_count", count).Error; err != nil { return err } + // 查询最新 Post + var post model.Post + if err := tx.Where("id = ?", comment.TargetID).First(&post).Error; err != nil { + return err + } + // 更新热度 + if err := tx.Model(&post).UpdateColumn("heat", post.CalculateHeat()).Error; err != nil { + return err + } default: return errs.New(http.StatusBadRequest, "unsupported target type: "+comment.TargetType, nil) } diff --git a/internal/repo/like.go b/internal/repo/like.go index f9c53ae..a4dedf8 100644 --- a/internal/repo/like.go +++ b/internal/repo/like.go @@ -50,19 +50,6 @@ func (l *likeRepo) ToggleLike(userID, targetID uint, targetType string) (bool, e if err := tx.Model(&model.Like{}).Where("target_type = ? AND target_id = ?", targetType, targetID).Count(&count).Error; err != nil { return err } - // 更新目标的点赞数量 - //switch targetType { - //case constant.TargetTypePost: - // if err := tx.Model(&model.Post{}).Where("id = ?", targetID).UpdateColumn("like_count", count).Error; err != nil { - // return err - // } - //case constant.TargetTypeComment: - // if err := tx.Model(&model.Comment{}).Where("id = ?", targetID).UpdateColumn("like_count", count).Error; err != nil { - // return err - // } - //default: - // return errors.New("invalid target type") - //} return nil }) return finalStatus, err diff --git a/internal/repo/post.go b/internal/repo/post.go index 6904cf1..f795b79 100644 --- a/internal/repo/post.go +++ b/internal/repo/post.go @@ -1,15 +1,15 @@ package repo import ( - "errors" - "net/http" - "slices" + "errors" + "net/http" + "slices" - "github.com/snowykami/neo-blog/internal/dto" - "github.com/snowykami/neo-blog/internal/model" - "github.com/snowykami/neo-blog/pkg/constant" - "github.com/snowykami/neo-blog/pkg/errs" - "gorm.io/gorm" + "github.com/snowykami/neo-blog/internal/dto" + "github.com/snowykami/neo-blog/internal/model" + "github.com/snowykami/neo-blog/pkg/constant" + "github.com/snowykami/neo-blog/pkg/errs" + "gorm.io/gorm" ) type postRepo struct{} @@ -17,96 +17,100 @@ type postRepo struct{} var Post = &postRepo{} func (p *postRepo) CreatePost(post *model.Post) error { - if err := GetDB().Create(post).Error; err != nil { - return err - } - return nil + if err := GetDB().Create(post).Error; err != nil { + return err + } + return nil } func (p *postRepo) DeletePost(id string) error { - if id == "" { - return errs.New(http.StatusBadRequest, "invalid post ID", nil) - } - if err := GetDB().Where("id = ?", id).Delete(&model.Post{}).Error; err != nil { - return err - } - return nil + if id == "" { + return errs.New(http.StatusBadRequest, "invalid post ID", nil) + } + if err := GetDB().Where("id = ?", id).Delete(&model.Post{}).Error; err != nil { + return err + } + return nil } func (p *postRepo) GetPostByID(id string) (*model.Post, error) { - var post model.Post - if err := GetDB().Where("id = ?", id).Preload("User").First(&post).Error; err != nil { - return nil, err - } - return &post, nil + var post model.Post + if err := GetDB().Where("id = ?", id).Preload("User").First(&post).Error; err != nil { + return nil, err + } + GetDB().Model(&post).UpdateColumn("view_count", gorm.Expr("view_count + ?", 1)) + GetDB().First(&post, post.ID) + GetDB().Model(&post).UpdateColumn("heat", post.CalculateHeat()) + // TODO: 对用户进行追踪,实现更真实的访问次数计算,目前粗略地每次访问都+1 + return &post, nil } func (p *postRepo) UpdatePost(post *model.Post) error { - if post.ID == 0 { - return errs.New(http.StatusBadRequest, "invalid post ID", nil) - } - if err := GetDB().Save(post).Error; err != nil { - return err - } - return nil + if post.ID == 0 { + return errs.New(http.StatusBadRequest, "invalid post ID", nil) + } + if err := GetDB().Save(post).Error; err != nil { + return err + } + return nil } func (p *postRepo) ListPosts(currentUserID uint, keywords []string, labels []dto.LabelDto, labelRule string, page, size uint64, orderBy string, desc bool) ([]model.Post, int64, error) { - if !slices.Contains(constant.OrderByEnumPost, orderBy) { - return nil, 0, errs.New(http.StatusBadRequest, "invalid order_by parameter", nil) - } - query := GetDB().Model(&model.Post{}).Preload("User") - if currentUserID > 0 { - query = query.Where("is_private = ? OR (is_private = ? AND user_id = ?)", false, true, currentUserID) - } else { - query = query.Where("is_private = ?", false) - } + if !slices.Contains(constant.OrderByEnumPost, orderBy) { + return nil, 0, errs.New(http.StatusBadRequest, "invalid order_by parameter", nil) + } + query := GetDB().Model(&model.Post{}).Preload("User") + if currentUserID > 0 { + query = query.Where("is_private = ? OR (is_private = ? AND user_id = ?)", false, true, currentUserID) + } else { + query = query.Where("is_private = ?", false) + } - if len(labels) > 0 { - var labelIds []uint - for _, labelDto := range labels { - label, _ := Label.GetLabelByKeyAndValue(labelDto.Key, labelDto.Value) - labelIds = append(labelIds, label.ID) - } - if labelRule == "intersection" { - query = query.Joins("JOIN post_labels ON post_labels.post_id = posts.id"). - Where("post_labels.label_id IN ?", labelIds). - Group("posts.id"). - Having("COUNT(DISTINCT post_labels.label_id) = ?", len(labelIds)) - } else { - query = query.Joins("JOIN post_labels ON post_labels.post_id = posts.id"). - Where("post_labels.label_id IN ?", labelIds) - } - } + if len(labels) > 0 { + var labelIds []uint + for _, labelDto := range labels { + label, _ := Label.GetLabelByKeyAndValue(labelDto.Key, labelDto.Value) + labelIds = append(labelIds, label.ID) + } + if labelRule == "intersection" { + query = query.Joins("JOIN post_labels ON post_labels.post_id = posts.id"). + Where("post_labels.label_id IN ?", labelIds). + Group("posts.id"). + Having("COUNT(DISTINCT post_labels.label_id) = ?", len(labelIds)) + } else { + query = query.Joins("JOIN post_labels ON post_labels.post_id = posts.id"). + Where("post_labels.label_id IN ?", labelIds) + } + } - if len(keywords) > 0 { - for _, keyword := range keywords { - if keyword != "" { - query = query.Where("title LIKE ? OR content LIKE ?", - "%"+keyword+"%", "%"+keyword+"%") - } - } - } + if len(keywords) > 0 { + for _, keyword := range keywords { + if keyword != "" { + query = query.Where("title LIKE ? OR content LIKE ?", + "%"+keyword+"%", "%"+keyword+"%") + } + } + } - var total int64 - if err := query.Count(&total).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, 0, err - } + var total int64 + if err := query.Count(&total).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, 0, err + } - items, _, err := PaginateQuery[model.Post](query, page, size, orderBy, desc) - if err != nil { - return nil, 0, err - } - return items, total, nil + items, _, err := PaginateQuery[model.Post](query, page, size, orderBy, desc) + if err != nil { + return nil, 0, err + } + return items, total, nil } func (p *postRepo) ToggleLikePost(postID uint, userID uint) (bool, error) { - if postID == 0 || userID == 0 { - return false, errs.New(http.StatusBadRequest, "invalid post ID or user ID", nil) - } - liked, err := Like.ToggleLike(userID, postID, constant.TargetTypePost) - if err != nil { - return false, err - } - return liked, nil + if postID == 0 || userID == 0 { + return false, errs.New(http.StatusBadRequest, "invalid post ID or user ID", nil) + } + liked, err := Like.ToggleLike(userID, postID, constant.TargetTypePost) + if err != nil { + return false, err + } + return liked, nil } diff --git a/internal/router/apiv1/admin.go b/internal/router/apiv1/admin.go index 4dccb27..17b2053 100644 --- a/internal/router/apiv1/admin.go +++ b/internal/router/apiv1/admin.go @@ -1,20 +1,21 @@ package apiv1 import ( - "github.com/cloudwego/hertz/pkg/route" - v1 "github.com/snowykami/neo-blog/internal/controller/v1" - "github.com/snowykami/neo-blog/internal/middleware" + "github.com/cloudwego/hertz/pkg/route" + v1 "github.com/snowykami/neo-blog/internal/controller/v1" + "github.com/snowykami/neo-blog/internal/middleware" + "github.com/snowykami/neo-blog/pkg/constant" ) func registerAdminRoutes(group *route.RouterGroup) { - // Need Admin Middleware - adminController := v1.NewAdminController() - consoleGroup := group.Group("/admin").Use(middleware.UseAuth(true)) - { - consoleGroup.POST("/oidc/o", adminController.CreateOidc) - consoleGroup.DELETE("/oidc/o/:id", adminController.DeleteOidc) - consoleGroup.GET("/oidc/o/:id", adminController.GetOidcByID) - consoleGroup.GET("/oidc/list", adminController.ListOidc) - consoleGroup.PUT("/oidc/o/:id", adminController.UpdateOidc) - } + // Need Admin Middleware + adminController := v1.NewAdminController() + consoleGroup := group.Group("/admin").Use(middleware.UseAuth(true)).Use(middleware.UseRole(constant.RoleAdmin)) + { + consoleGroup.POST("/oidc/o", adminController.CreateOidc) + consoleGroup.DELETE("/oidc/o/:id", adminController.DeleteOidc) + consoleGroup.GET("/oidc/o/:id", adminController.GetOidcByID) + consoleGroup.GET("/oidc/list", adminController.ListOidc) + consoleGroup.PUT("/oidc/o/:id", adminController.UpdateOidc) + } } diff --git a/internal/router/apiv1/page.go b/internal/router/apiv1/page.go index fa700b5..ef0ef13 100644 --- a/internal/router/apiv1/page.go +++ b/internal/router/apiv1/page.go @@ -4,12 +4,13 @@ import ( "github.com/cloudwego/hertz/pkg/route" v1 "github.com/snowykami/neo-blog/internal/controller/v1" "github.com/snowykami/neo-blog/internal/middleware" + "github.com/snowykami/neo-blog/pkg/constant" ) // page 页面API路由 func registerPageRoutes(group *route.RouterGroup) { - postGroup := group.Group("/page").Use(middleware.UseAuth(true)) + postGroup := group.Group("/page").Use(middleware.UseAuth(true)).Use(middleware.UseRole(constant.RoleEditor)) postGroupWithoutAuth := group.Group("/page").Use(middleware.UseAuth(false)) { postGroupWithoutAuth.GET("/p/:id", v1.Page.Get) diff --git a/internal/router/apiv1/post.go b/internal/router/apiv1/post.go index b1010a3..2cf04bc 100644 --- a/internal/router/apiv1/post.go +++ b/internal/router/apiv1/post.go @@ -4,13 +4,14 @@ import ( "github.com/cloudwego/hertz/pkg/route" v1 "github.com/snowykami/neo-blog/internal/controller/v1" "github.com/snowykami/neo-blog/internal/middleware" + "github.com/snowykami/neo-blog/pkg/constant" ) // post 文章API路由 func registerPostRoutes(group *route.RouterGroup) { postController := v1.NewPostController() - postGroup := group.Group("/post").Use(middleware.UseAuth(true)) + postGroup := group.Group("/post").Use(middleware.UseAuth(true)).Use(middleware.UseRole(constant.RoleEditor)) postGroupWithoutAuth := group.Group("/post").Use(middleware.UseAuth(false)) { postGroupWithoutAuth.GET("/p/:id", postController.Get) diff --git a/internal/router/router.go b/internal/router/router.go index 1e274af..c27fee0 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -4,6 +4,7 @@ import ( "github.com/cloudwego/hertz/pkg/app/middlewares/server/recovery" "github.com/cloudwego/hertz/pkg/app/server" "github.com/sirupsen/logrus" + "github.com/snowykami/neo-blog/internal/middleware" "github.com/snowykami/neo-blog/internal/router/apiv1" "github.com/snowykami/neo-blog/pkg/utils" ) @@ -26,6 +27,6 @@ func init() { server.WithHostPorts(":"+utils.Env.Get("PORT", "8888")), server.WithMaxRequestBodySize(utils.Env.GetAsInt("MAX_REQUEST_BODY_SIZE", 1048576000)), // 1000MiB ) - h.Use(recovery.Recovery()) + h.Use(recovery.Recovery(), middleware.UseTrack()) apiv1.RegisterRoutes(h) } diff --git a/internal/service/post.go b/internal/service/post.go index 3708f75..beea8ac 100644 --- a/internal/service/post.go +++ b/internal/service/post.go @@ -1,154 +1,155 @@ package service import ( - "context" - "strconv" + "context" + "strconv" - "github.com/snowykami/neo-blog/internal/ctxutils" - "github.com/snowykami/neo-blog/internal/dto" - "github.com/snowykami/neo-blog/internal/model" - "github.com/snowykami/neo-blog/internal/repo" - "github.com/snowykami/neo-blog/pkg/errs" + "github.com/snowykami/neo-blog/internal/ctxutils" + "github.com/snowykami/neo-blog/internal/dto" + "github.com/snowykami/neo-blog/internal/model" + "github.com/snowykami/neo-blog/internal/repo" + "github.com/snowykami/neo-blog/pkg/errs" ) type PostService struct{} func NewPostService() *PostService { - return &PostService{} + return &PostService{} } func (p *PostService) CreatePost(ctx context.Context, req *dto.CreateOrUpdatePostReq) (uint, error) { - currentUser, ok := ctxutils.GetCurrentUser(ctx) - if !ok { - return 0, errs.ErrUnauthorized - } - post := &model.Post{ - Title: req.Title, - Content: req.Content, - UserID: currentUser.ID, - Labels: func() []model.Label { - labelModels := make([]model.Label, 0) - for _, labelID := range req.Labels { - labelModel, err := repo.Label.GetLabelByID(strconv.Itoa(int(labelID))) - if err == nil { - labelModels = append(labelModels, *labelModel) - } - } - return labelModels - }(), - IsPrivate: req.IsPrivate, - } - if err := repo.Post.CreatePost(post); err != nil { - return 0, err - } - return post.ID, nil + currentUser, ok := ctxutils.GetCurrentUser(ctx) + if !ok { + return 0, errs.ErrUnauthorized + } + post := &model.Post{ + Title: req.Title, + Content: req.Content, + UserID: currentUser.ID, + Labels: func() []model.Label { + labelModels := make([]model.Label, 0) + for _, labelID := range req.Labels { + labelModel, err := repo.Label.GetLabelByID(strconv.Itoa(int(labelID))) + if err == nil { + labelModels = append(labelModels, *labelModel) + } + } + return labelModels + }(), + IsPrivate: req.IsPrivate, + } + if err := repo.Post.CreatePost(post); err != nil { + return 0, err + } + return post.ID, nil } func (p *PostService) DeletePost(ctx context.Context, id string) error { - currentUser, ok := ctxutils.GetCurrentUser(ctx) - if !ok { - return errs.ErrUnauthorized - } - if id == "" { - return errs.ErrBadRequest - } - post, err := repo.Post.GetPostByID(id) - if err != nil { - return errs.New(errs.ErrNotFound.Code, "post not found", err) - } - if post.UserID != currentUser.ID { - return errs.ErrForbidden - } - if err := repo.Post.DeletePost(id); err != nil { - return errs.ErrInternalServer - } - return nil + currentUser, ok := ctxutils.GetCurrentUser(ctx) + if !ok { + return errs.ErrUnauthorized + } + if id == "" { + return errs.ErrBadRequest + } + post, err := repo.Post.GetPostByID(id) + if err != nil { + return errs.New(errs.ErrNotFound.Code, "post not found", err) + } + if post.UserID != currentUser.ID { + return errs.ErrForbidden + } + if err := repo.Post.DeletePost(id); err != nil { + return errs.ErrInternalServer + } + return nil } func (p *PostService) GetPost(ctx context.Context, id string) (*dto.PostDto, error) { - if id == "" { - return nil, errs.ErrBadRequest - } - post, err := repo.Post.GetPostByID(id) - if err != nil { - return nil, errs.New(errs.ErrNotFound.Code, "post not found", err) - } - currentUser, ok := ctxutils.GetCurrentUser(ctx) - if post.IsPrivate && (!ok || post.UserID != currentUser.ID) { - return nil, errs.ErrForbidden - } - return post.ToDto(), nil + if id == "" { + return nil, errs.ErrBadRequest + } + post, err := repo.Post.GetPostByID(id) + if err != nil { + return nil, errs.New(errs.ErrNotFound.Code, "post not found", err) + } + currentUser, ok := ctxutils.GetCurrentUser(ctx) + if post.IsPrivate && (!ok || post.UserID != currentUser.ID) { + return nil, errs.ErrForbidden + } + + return post.ToDto(), nil } func (p *PostService) UpdatePost(ctx context.Context, id string, req *dto.CreateOrUpdatePostReq) (uint, error) { - currentUser, ok := ctxutils.GetCurrentUser(ctx) - if !ok { - return 0, errs.ErrUnauthorized - } - if id == "" { - return 0, errs.ErrBadRequest - } - post, err := repo.Post.GetPostByID(id) - if err != nil { - return 0, errs.New(errs.ErrNotFound.Code, "post not found", err) - } - if post.UserID != currentUser.ID { - return 0, errs.ErrForbidden - } - post.Title = req.Title - post.Content = req.Content - post.IsPrivate = req.IsPrivate - post.Labels = func() []model.Label { - labelModels := make([]model.Label, len(req.Labels)) - for _, labelID := range req.Labels { - labelModel, err := repo.Label.GetLabelByID(strconv.Itoa(int(labelID))) - if err == nil { - labelModels = append(labelModels, *labelModel) - } - } - return labelModels - }() - if err := repo.Post.UpdatePost(post); err != nil { - return 0, errs.ErrInternalServer - } - return post.ID, nil + currentUser, ok := ctxutils.GetCurrentUser(ctx) + if !ok { + return 0, errs.ErrUnauthorized + } + if id == "" { + return 0, errs.ErrBadRequest + } + post, err := repo.Post.GetPostByID(id) + if err != nil { + return 0, errs.New(errs.ErrNotFound.Code, "post not found", err) + } + if post.UserID != currentUser.ID { + return 0, errs.ErrForbidden + } + post.Title = req.Title + post.Content = req.Content + post.IsPrivate = req.IsPrivate + post.Labels = func() []model.Label { + labelModels := make([]model.Label, len(req.Labels)) + for _, labelID := range req.Labels { + labelModel, err := repo.Label.GetLabelByID(strconv.Itoa(int(labelID))) + if err == nil { + labelModels = append(labelModels, *labelModel) + } + } + return labelModels + }() + if err := repo.Post.UpdatePost(post); err != nil { + return 0, errs.ErrInternalServer + } + return post.ID, nil } func (p *PostService) ListPosts(ctx context.Context, req *dto.ListPostReq) ([]*dto.PostDto, int64, error) { - postDtos := make([]*dto.PostDto, 0) - currentUserID, _ := ctxutils.GetCurrentUserID(ctx) - posts, total, err := repo.Post.ListPosts(currentUserID, req.Keywords, req.Labels, req.LabelRule, req.Page, req.Size, req.OrderBy, req.Desc) - if err != nil { - return nil, total, errs.New(errs.ErrInternalServer.Code, "failed to list posts", err) - } - for _, post := range posts { - postDtos = append(postDtos, post.ToDtoWithShortContent(100)) - } - return postDtos, total, nil + postDtos := make([]*dto.PostDto, 0) + currentUserID, _ := ctxutils.GetCurrentUserID(ctx) + posts, total, err := repo.Post.ListPosts(currentUserID, req.Keywords, req.Labels, req.LabelRule, req.Page, req.Size, req.OrderBy, req.Desc) + if err != nil { + return nil, total, errs.New(errs.ErrInternalServer.Code, "failed to list posts", err) + } + for _, post := range posts { + postDtos = append(postDtos, post.ToDtoWithShortContent(100)) + } + return postDtos, total, nil } func (p *PostService) ToggleLikePost(ctx context.Context, id string) (bool, error) { - currentUser, ok := ctxutils.GetCurrentUser(ctx) - if !ok { - return false, errs.ErrUnauthorized - } - if id == "" { - return false, errs.ErrBadRequest - } - post, err := repo.Post.GetPostByID(id) - if err != nil { - return false, errs.New(errs.ErrNotFound.Code, "post not found", err) - } - if post.UserID == currentUser.ID { - return false, errs.ErrForbidden - } - idInt, err := strconv.ParseUint(id, 10, 64) - if err != nil { - return false, errs.New(errs.ErrBadRequest.Code, "invalid post ID", err) - } - liked, err := repo.Post.ToggleLikePost(uint(idInt), currentUser.ID) - if err != nil { - return false, errs.ErrInternalServer - } - return liked, nil + currentUser, ok := ctxutils.GetCurrentUser(ctx) + if !ok { + return false, errs.ErrUnauthorized + } + if id == "" { + return false, errs.ErrBadRequest + } + post, err := repo.Post.GetPostByID(id) + if err != nil { + return false, errs.New(errs.ErrNotFound.Code, "post not found", err) + } + if post.UserID == currentUser.ID { + return false, errs.ErrForbidden + } + idInt, err := strconv.ParseUint(id, 10, 64) + if err != nil { + return false, errs.New(errs.ErrBadRequest.Code, "invalid post ID", err) + } + liked, err := repo.Post.ToggleLikePost(uint(idInt), currentUser.ID) + if err != nil { + return false, errs.ErrInternalServer + } + return liked, nil } diff --git a/pkg/constant/constant.go b/pkg/constant/constant.go index b97bf14..82dabf4 100644 --- a/pkg/constant/constant.go +++ b/pkg/constant/constant.go @@ -1,55 +1,57 @@ package constant const ( - CaptchaTypeDisable = "disable" // 禁用验证码 - CaptchaTypeHCaptcha = "hcaptcha" // HCaptcha验证码 - CaptchaTypeTurnstile = "turnstile" // Turnstile验证码 - CaptchaTypeReCaptcha = "recaptcha" // ReCaptcha验证码 - ContextKeyUserID = "user_id" // 上下文键:用户ID - ModeDev = "dev" - ModeProd = "prod" - RoleUser = "user" // 普通用户 仅有阅读和评论权限 - RoleEditor = "editor" // 能够发布和管理自己内容的用户 - RoleAdmin = "admin" - EnvKeyBaseUrl = "BASE_URL" // 环境变量:基础URL - EnvKeyCaptchaProvider = "CAPTCHA_PROVIDER" // captcha提供者 - EnvKeyCaptchaSecreteKey = "CAPTCHA_SECRET_KEY" // captcha站点密钥 - EnvKeyCaptchaUrl = "CAPTCHA_URL" // 某些自托管的captcha的url - EnvKeyCaptchaSiteKey = "CAPTCHA_SITE_KEY" // captcha密钥key - EnvKeyLocationFormat = "LOCATION_FORMAT" // 环境变量:时区格式 - EnvKeyLogLevel = "LOG_LEVEL" // 环境变量:日志级别 - EnvKeyMode = "MODE" // 环境变量:运行模式 - EnvKeyJwtSecrete = "JWT_SECRET" // 环境变量:JWT密钥 - EnvKeyPasswordSalt = "PASSWORD_SALT" // 环境变量:密码盐 - EnvKeyTokenDuration = "TOKEN_DURATION" // 环境变量:令牌有效期 - EnvKeyMaxReplyDepth = "MAX_REPLY_DEPTH" // 环境变量:最大回复深度 - EnvKeyTokenDurationDefault = 300 // Token有效时长 - EnvKeyRefreshTokenDurationDefault = 604800 // refresh token有效时长 - EnvKeyRefreshTokenDuration = "REFRESH_TOKEN_DURATION" // 环境变量:刷新令牌有效期 - EnvKeyRefreshTokenDurationWithRemember = "REFRESH_TOKEN_DURATION_WITH_REMEMBER" // 环境变量:记住我刷新令牌有效期 - KVKeyEmailVerificationCode = "email_verification_code:" // KV存储:邮箱验证码 - KVKeyOidcState = "oidc_state:" // KV存储:OIDC状态 - ApiSuffix = "/api/v1" // API版本前缀 - OidcUri = "/user/oidc/login" // OIDC登录URI - OidcProviderTypeMisskey = "misskey" // OIDC提供者类型:Misskey - OidcProviderTypeOauth2 = "oauth2" // OIDC提供者类型:GitHub - DefaultBaseUrl = "http://localhost:3000" // 默认BaseUrl - TargetTypePost = "post" - TargetTypeComment = "comment" - OrderByCreatedAt = "created_at" // 按创建时间排序 - OrderByUpdatedAt = "updated_at" // 按更新时间排序 - OrderByLikeCount = "like_count" // 按点赞数排序 - OrderByCommentCount = "comment_count" // 按评论数排序 - OrderByViewCount = "view_count" // 按浏览量排序 - OrderByHeat = "heat" - MaxReplyDepthDefault = 3 // 默认最大回复深度 - HeatFactorViewWeight = 1 // 热度因子:浏览量权重 - HeatFactorLikeWeight = 5 // 热度因子:点赞权重 - HeatFactorCommentWeight = 10 // 热度因子:评论权重 - PageLimitDefault = 20 // 默认分页大小 + CaptchaTypeDisable = "disable" // 禁用验证码 + CaptchaTypeHCaptcha = "hcaptcha" // HCaptcha验证码 + CaptchaTypeTurnstile = "turnstile" // Turnstile验证码 + CaptchaTypeReCaptcha = "recaptcha" // ReCaptcha验证码 + ContextKeyUserID = "user_id" // 上下文键:用户ID + ContextKeyRemoteAddr = "remote_addr" // 上下文键:远程地址 + ContextKeyUserAgent = "user_agent" // 上下文键:用户代理 + ModeDev = "dev" + ModeProd = "prod" + RoleUser = "user" // 普通用户 仅有阅读和评论权限 + RoleEditor = "editor" // 能够发布和管理自己内容的用户 + RoleAdmin = "admin" + EnvKeyBaseUrl = "BASE_URL" // 环境变量:基础URL + EnvKeyCaptchaProvider = "CAPTCHA_PROVIDER" // captcha提供者 + EnvKeyCaptchaSecreteKey = "CAPTCHA_SECRET_KEY" // captcha站点密钥 + EnvKeyCaptchaUrl = "CAPTCHA_URL" // 某些自托管的captcha的url + EnvKeyCaptchaSiteKey = "CAPTCHA_SITE_KEY" // captcha密钥key + EnvKeyLocationFormat = "LOCATION_FORMAT" // 环境变量:时区格式 + EnvKeyLogLevel = "LOG_LEVEL" // 环境变量:日志级别 + EnvKeyMode = "MODE" // 环境变量:运行模式 + EnvKeyJwtSecrete = "JWT_SECRET" // 环境变量:JWT密钥 + EnvKeyPasswordSalt = "PASSWORD_SALT" // 环境变量:密码盐 + EnvKeyTokenDuration = "TOKEN_DURATION" // 环境变量:令牌有效期 + EnvKeyMaxReplyDepth = "MAX_REPLY_DEPTH" // 环境变量:最大回复深度 + EnvKeyTokenDurationDefault = 300 // Token有效时长 + EnvKeyRefreshTokenDurationDefault = 604800 // refresh token有效时长 + EnvKeyRefreshTokenDuration = "REFRESH_TOKEN_DURATION" // 环境变量:刷新令牌有效期 + EnvKeyRefreshTokenDurationWithRemember = "REFRESH_TOKEN_DURATION_WITH_REMEMBER" // 环境变量:记住我刷新令牌有效期 + KVKeyEmailVerificationCode = "email_verification_code:" // KV存储:邮箱验证码 + KVKeyOidcState = "oidc_state:" // KV存储:OIDC状态 + ApiSuffix = "/api/v1" // API版本前缀 + OidcUri = "/user/oidc/login" // OIDC登录URI + OidcProviderTypeMisskey = "misskey" // OIDC提供者类型:Misskey + OidcProviderTypeOauth2 = "oauth2" // OIDC提供者类型:GitHub + DefaultBaseUrl = "http://localhost:3000" // 默认BaseUrl + TargetTypePost = "post" + TargetTypeComment = "comment" + OrderByCreatedAt = "created_at" // 按创建时间排序 + OrderByUpdatedAt = "updated_at" // 按更新时间排序 + OrderByLikeCount = "like_count" // 按点赞数排序 + OrderByCommentCount = "comment_count" // 按评论数排序 + OrderByViewCount = "view_count" // 按浏览量排序 + OrderByHeat = "heat" + MaxReplyDepthDefault = 3 // 默认最大回复深度 + HeatFactorViewWeight = 1 // 热度因子:浏览量权重 + HeatFactorLikeWeight = 5 // 热度因子:点赞权重 + HeatFactorCommentWeight = 10 // 热度因子:评论权重 + PageLimitDefault = 20 // 默认分页大小 ) var ( - OrderByEnumPost = []string{OrderByCreatedAt, OrderByUpdatedAt, OrderByLikeCount, OrderByCommentCount, OrderByViewCount, OrderByHeat} // 帖子可用的排序方式 - OrderByEnumComment = []string{OrderByCreatedAt, OrderByUpdatedAt, OrderByCommentCount} // 评论可用的排序方式 + OrderByEnumPost = []string{OrderByCreatedAt, OrderByUpdatedAt, OrderByLikeCount, OrderByCommentCount, OrderByViewCount, OrderByHeat} // 帖子可用的排序方式 + OrderByEnumComment = []string{OrderByCreatedAt, OrderByUpdatedAt, OrderByCommentCount} // 评论可用的排序方式 ) diff --git a/pkg/utils/oidc.go b/pkg/utils/oidc.go index 63af7ad..df9487f 100644 --- a/pkg/utils/oidc.go +++ b/pkg/utils/oidc.go @@ -1,7 +1,7 @@ package utils import ( - "fmt" + "fmt" ) type oidcUtils struct{} @@ -10,60 +10,60 @@ var Oidc = oidcUtils{} // RequestToken 请求访问令牌 func (u *oidcUtils) RequestToken(tokenEndpoint, clientID, clientSecret, code, redirectURI string) (*TokenResponse, error) { - tokenResp, err := client.R(). - SetFormData(map[string]string{ - "grant_type": "authorization_code", - "client_id": clientID, - "client_secret": clientSecret, - "code": code, - "redirect_uri": redirectURI, - }). - SetHeader("Accept", "application/json"). - SetResult(&TokenResponse{}). - Post(tokenEndpoint) + tokenResp, err := client.R(). + SetFormData(map[string]string{ + "grant_type": "authorization_code", + "client_id": clientID, + "client_secret": clientSecret, + "code": code, + "redirect_uri": redirectURI, + }). + SetHeader("Accept", "application/json"). + SetResult(&TokenResponse{}). + Post(tokenEndpoint) - if err != nil { - return nil, err - } + if err != nil { + return nil, err + } - if tokenResp.StatusCode() != 200 { - return nil, fmt.Errorf("状态码: %d,响应: %s", tokenResp.StatusCode(), tokenResp.String()) - } - return tokenResp.Result().(*TokenResponse), nil + if tokenResp.StatusCode() != 200 { + return nil, fmt.Errorf("状态码: %d,响应: %s", tokenResp.StatusCode(), tokenResp.String()) + } + return tokenResp.Result().(*TokenResponse), nil } // RequestUserInfo 请求用户信息 func (u *oidcUtils) RequestUserInfo(userInfoEndpoint, accessToken string) (*UserInfo, error) { - userInfoResp, err := client.R(). - SetHeader("Authorization", "Bearer "+accessToken). - SetHeader("Accept", "application/json"). - SetResult(&UserInfo{}). - Get(userInfoEndpoint) - if err != nil { - return nil, err - } + userInfoResp, err := client.R(). + SetHeader("Authorization", "Bearer "+accessToken). + SetHeader("Accept", "application/json"). + SetResult(&UserInfo{}). + Get(userInfoEndpoint) + if err != nil { + return nil, err + } - if userInfoResp.StatusCode() != 200 { - return nil, fmt.Errorf("状态码: %d,响应: %s", userInfoResp.StatusCode(), userInfoResp.String()) - } + if userInfoResp.StatusCode() != 200 { + return nil, fmt.Errorf("状态码: %d,响应: %s", userInfoResp.StatusCode(), userInfoResp.String()) + } - return userInfoResp.Result().(*UserInfo), nil + return userInfoResp.Result().(*UserInfo), nil } type TokenResponse struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` - IDToken string `json:"id_token,omitempty"` - RefreshToken string `json:"refresh_token,omitempty"` + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + IDToken string `json:"id_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` } // UserInfo 定义用户信息结构 type UserInfo struct { - Sub string `json:"sub"` - Name string `json:"name"` - Email string `json:"email"` - Picture string `json:"picture,omitempty"` - PreferredUsername string `json:"preferred_username"` - Groups []string `json:"groups,omitempty"` // 可选字段,OIDC提供的用户组信息 + Sub string `json:"sub"` + Name string `json:"name"` + Email string `json:"email"` + Picture string `json:"picture,omitempty"` + PreferredUsername string `json:"preferred_username"` + Groups []string `json:"groups,omitempty"` // 可选字段,OIDC提供的用户组信息 } diff --git a/web/package.json b/web/package.json index e5309e1..ef146ae 100644 --- a/web/package.json +++ b/web/package.json @@ -9,16 +9,28 @@ "lint": "next lint" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@hcaptcha/react-hcaptcha": "^1.12.1", "@marsidev/react-turnstile": "^1.3.0", + "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-navigation-menu": "^1.2.13", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.5", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-toggle-group": "^1.1.11", + "@radix-ui/react-tooltip": "^1.2.8", + "@tabler/icons-react": "^3.34.1", + "@tanstack/react-table": "^8.21.3", "axios": "^1.11.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -35,9 +47,12 @@ "react-dom": "19.1.0", "react-google-recaptcha-v3": "^1.11.0", "react-icons": "^5.5.0", + "recharts": "2.15.4", "rehype-highlight": "^7.0.2", "sonner": "^2.0.6", - "tailwind-merge": "^3.3.1" + "tailwind-merge": "^3.3.1", + "vaul": "^1.1.2", + "zod": "^4.1.8" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 7de495a..4af77bd 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -8,18 +8,36 @@ importers: .: dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@dnd-kit/modifiers': + specifier: ^9.0.0 + version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.1.0) '@hcaptcha/react-hcaptcha': specifier: ^1.12.1 version: 1.12.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@marsidev/react-turnstile': specifier: ^1.3.0 version: 1.3.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-avatar': + specifier: ^1.1.10 + version: 1.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-checkbox': specifier: ^1.3.3 version: 1.3.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-dialog': specifier: ^1.1.14 version: 1.1.14(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.16 + version: 2.1.16(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-label': specifier: ^2.1.7 version: 2.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -38,6 +56,24 @@ importers: '@radix-ui/react-switch': specifier: ^1.2.5 version: 1.2.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-tabs': + specifier: ^1.1.13 + version: 1.1.13(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-toggle': + specifier: ^1.1.10 + version: 1.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-toggle-group': + specifier: ^1.1.11 + version: 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-tooltip': + specifier: ^1.2.8 + version: 1.2.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@tabler/icons-react': + specifier: ^3.34.1 + version: 3.34.1(react@19.1.0) + '@tanstack/react-table': + specifier: ^8.21.3 + version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) axios: specifier: ^1.11.0 version: 1.11.0 @@ -86,6 +122,9 @@ importers: react-icons: specifier: ^5.5.0 version: 5.5.0(react@19.1.0) + recharts: + specifier: 2.15.4 + version: 2.15.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0) rehype-highlight: specifier: ^7.0.2 version: 7.0.2 @@ -95,6 +134,12 @@ importers: tailwind-merge: specifier: ^3.3.1 version: 3.3.1 + vaul: + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + zod: + specifier: ^4.1.8 + version: 4.1.8 devDependencies: '@tailwindcss/postcss': specifier: ^4 @@ -146,6 +191,34 @@ packages: resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/modifiers@9.0.0': + resolution: {integrity: sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@emnapi/core@1.4.4': resolution: {integrity: sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g==} @@ -504,6 +577,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-avatar@1.1.10': + resolution: {integrity: sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-checkbox@1.3.3': resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} peerDependencies: @@ -596,6 +682,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dropdown-menu@2.1.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-focus-guards@1.1.2': resolution: {integrity: sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==} peerDependencies: @@ -649,6 +748,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-navigation-menu@1.2.13': resolution: {integrity: sha512-WG8wWfDiJlSF5hELjwfjSGOXcBR/ZMhBFCGYe8vERpC39CQYZeq1PQ2kaYHdye3V95d06H89KGMsVCIE4LWo3g==} peerDependencies: @@ -727,6 +839,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-select@2.2.6': resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} peerDependencies: @@ -775,6 +900,58 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-tabs@1.1.13': + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle-group@1.1.11': + resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle@1.1.10': + resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tooltip@1.2.8': + resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-use-callback-ref@1.1.1': resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} peerDependencies: @@ -811,6 +988,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-is-hydrated@0.1.0': + resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-layout-effect@1.1.1': resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} peerDependencies: @@ -875,6 +1061,14 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@tabler/icons-react@3.34.1': + resolution: {integrity: sha512-Ld6g0NqOO05kyyHsfU8h787PdHBm7cFmOycQSIrGp45XcXYDuOK2Bs0VC4T2FWSKZ6bx5g04imfzazf/nqtk1A==} + peerDependencies: + react: '>= 16' + + '@tabler/icons@3.34.1': + resolution: {integrity: sha512-9gTnUvd7Fd/DmQgr3MKY+oJLa1RfNsQo8c/ir3TJAWghOuZXodbtbVp0QBY2DxWuuvrSZFys0HEbv1CoiI5y6A==} + '@tailwindcss/node@4.1.11': resolution: {integrity: sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==} @@ -963,9 +1157,47 @@ packages: '@tailwindcss/postcss@4.1.11': resolution: {integrity: sha512-q/EAIIpF6WpLhKEuQSEVMZNMIY8KhWoAemZ9eylNAih9jxMGAYPPWBn3I9QL/2jZ+e7OEz/tZkX5HwbBR4HohA==} + '@tanstack/react-table@8.21.3': + resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + '@tybys/wasm-util@0.10.0': resolution: {integrity: sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.7': + resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -1361,6 +1593,50 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.0: + resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -1393,6 +1669,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -1436,6 +1715,9 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1626,12 +1908,19 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-equals@5.2.2: + resolution: {integrity: sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==} + engines: {node: '>=6.0.0'} + fast-glob@3.3.1: resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} engines: {node: '>=8.6.0'} @@ -1837,6 +2126,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + intl-messageformat@10.7.16: resolution: {integrity: sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug==} @@ -2085,6 +2378,9 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -2473,6 +2769,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -2493,6 +2792,12 @@ packages: '@types/react': optional: true + react-smooth@4.0.4: + resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -2503,10 +2808,26 @@ packages: '@types/react': optional: true + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + react@19.1.0: resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} engines: {node: '>=0.10.0'} + recharts-scale@0.4.5: + resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} + + recharts@2.15.4: + resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==} + engines: {node: '>=14'} + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + recma-build-jsx@1.0.0: resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==} @@ -2740,6 +3061,9 @@ packages: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinyglobby@0.2.14: resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} engines: {node: '>=12.0.0'} @@ -2866,6 +3190,17 @@ packages: '@types/react': optional: true + use-sync-external-store@1.5.0: + resolution: {integrity: sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + vfile-matter@5.0.1: resolution: {integrity: sha512-o6roP82AiX0XfkyTHyRCMXgHfltUNlXSEqCIS80f+mbAyiQBE2fxtDVMtseyytGx75sihiJFo/zR6r/4LTs2Cw==} @@ -2875,6 +3210,9 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + victory-vendor@36.9.2: + resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -2913,6 +3251,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@4.1.8: + resolution: {integrity: sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -2935,6 +3276,38 @@ snapshots: '@babel/runtime@7.28.4': {} + '@dnd-kit/accessibility@3.1.1(react@19.1.0)': + dependencies: + react: 19.1.0 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.1.0) + '@dnd-kit/utilities': 3.2.2(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + tslib: 2.8.1 + + '@dnd-kit/modifiers@9.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@dnd-kit/utilities': 3.2.2(react@19.1.0) + react: 19.1.0 + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@dnd-kit/utilities': 3.2.2(react@19.1.0) + react: 19.1.0 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.1.0)': + dependencies: + react: 19.1.0 + tslib: 2.8.1 + '@emnapi/core@1.4.4': dependencies: '@emnapi/wasi-threads': 1.0.3 @@ -3275,6 +3648,19 @@ snapshots: '@types/react': 19.1.8 '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -3369,6 +3755,21 @@ snapshots: '@types/react': 19.1.8 '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-focus-guards@1.1.2(@types/react@19.1.8)(react@19.1.0)': dependencies: react: 19.1.0 @@ -3408,6 +3809,32 @@ snapshots: '@types/react': 19.1.8 '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0) + aria-hidden: 1.2.6 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-remove-scroll: 2.7.1(@types/react@19.1.8)(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-navigation-menu@1.2.13(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.2 @@ -3487,6 +3914,23 @@ snapshots: '@types/react': 19.1.8 '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-select@2.2.6(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/number': 1.1.1 @@ -3547,6 +3991,68 @@ snapshots: '@types/react': 19.1.8 '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + + '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + + '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.1.8)(react@19.1.0)': dependencies: react: 19.1.0 @@ -3575,6 +4081,13 @@ snapshots: optionalDependencies: '@types/react': 19.1.8 + '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.1.8)(react@19.1.0)': + dependencies: + react: 19.1.0 + use-sync-external-store: 1.5.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.1.8)(react@19.1.0)': dependencies: react: 19.1.0 @@ -3622,6 +4135,13 @@ snapshots: dependencies: tslib: 2.8.1 + '@tabler/icons-react@3.34.1(react@19.1.0)': + dependencies: + '@tabler/icons': 3.34.1 + react: 19.1.0 + + '@tabler/icons@3.34.1': {} + '@tailwindcss/node@4.1.11': dependencies: '@ampproject/remapping': 2.3.0 @@ -3694,11 +4214,43 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.11 + '@tanstack/react-table@8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@tanstack/table-core': 8.21.3 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + '@tanstack/table-core@8.21.3': {} + '@tybys/wasm-util@0.10.0': dependencies: tslib: 2.8.1 optional: true + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.7': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -4114,6 +4666,44 @@ snapshots: csstype@3.1.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.0: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.0 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + damerau-levenshtein@1.0.8: {} data-view-buffer@1.0.2: @@ -4142,6 +4732,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} decode-named-character-reference@1.2.0: @@ -4180,6 +4772,11 @@ snapshots: dependencies: esutils: 2.0.3 + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.28.4 + csstype: 3.1.3 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4540,10 +5137,14 @@ snapshots: esutils@2.0.3: {} + eventemitter3@4.0.7: {} + extend@3.0.2: {} fast-deep-equal@3.1.3: {} + fast-equals@5.2.2: {} + fast-glob@3.3.1: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -4786,6 +5387,8 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + internmap@2.0.3: {} + intl-messageformat@10.7.16: dependencies: '@formatjs/ecma402-abstract': 2.3.4 @@ -5024,6 +5627,8 @@ snapshots: lodash.merge@4.6.2: {} + lodash@4.17.21: {} + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -5601,6 +6206,8 @@ snapshots: react-is@16.13.1: {} + react-is@18.3.1: {} + react-remove-scroll-bar@2.3.8(@types/react@19.1.8)(react@19.1.0): dependencies: react: 19.1.0 @@ -5620,6 +6227,14 @@ snapshots: optionalDependencies: '@types/react': 19.1.8 + react-smooth@4.0.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + fast-equals: 5.2.2 + prop-types: 15.8.1 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-transition-group: 4.4.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react-style-singleton@2.2.3(@types/react@19.1.8)(react@19.1.0): dependencies: get-nonce: 1.0.1 @@ -5628,8 +6243,34 @@ snapshots: optionalDependencies: '@types/react': 19.1.8 + react-transition-group@4.4.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + '@babel/runtime': 7.28.4 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react@19.1.0: {} + recharts-scale@0.4.5: + dependencies: + decimal.js-light: 2.5.1 + + recharts@2.15.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + clsx: 2.1.1 + eventemitter3: 4.0.7 + lodash: 4.17.21 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-is: 18.3.1 + react-smooth: 4.0.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + recharts-scale: 0.4.5 + tiny-invariant: 1.3.3 + victory-vendor: 36.9.2 + recma-build-jsx@1.0.0: dependencies: '@types/estree': 1.0.8 @@ -5982,6 +6623,8 @@ snapshots: mkdirp: 3.0.1 yallist: 5.0.0 + tiny-invariant@1.3.3: {} + tinyglobby@0.2.14: dependencies: fdir: 6.4.6(picomatch@4.0.3) @@ -6160,6 +6803,19 @@ snapshots: optionalDependencies: '@types/react': 19.1.8 + use-sync-external-store@1.5.0(react@19.1.0): + dependencies: + react: 19.1.0 + + vaul@1.1.2(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + '@radix-ui/react-dialog': 1.1.14(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + vfile-matter@5.0.1: dependencies: vfile: 6.0.3 @@ -6175,6 +6831,23 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + victory-vendor@36.9.2: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.7 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -6228,4 +6901,6 @@ snapshots: yocto-queue@0.1.0: {} + zod@4.1.8: {} + zwitch@2.0.4: {} diff --git a/web/src/api/post.ts b/web/src/api/post.ts index 9d43fe0..2e29f0a 100644 --- a/web/src/api/post.ts +++ b/web/src/api/post.ts @@ -4,7 +4,7 @@ import axiosClient from './client' import { OrderBy, PaginationParams } from '@/models/common' -export async function getPostById(id: string, token: string=""): Promise { +export async function getPostById({id, token = ""}: {id: string, token: string}): Promise { try { const res = await axiosClient.get>(`/post/p/${id}`, { headers: { diff --git a/web/src/api/user.ts b/web/src/api/user.ts index bde82d2..5ecb3de 100644 --- a/web/src/api/user.ts +++ b/web/src/api/user.ts @@ -25,6 +25,11 @@ export async function userLogin( return res.data } +export async function userLogout(): Promise> { + const res = await axiosClient.post>('/user/logout') + return res.data +} + export async function userRegister( data: RegisterRequest, ): Promise> { diff --git a/web/src/app/(main)/layout.tsx b/web/src/app/(main)/layout.tsx index b712b4d..7c62a6e 100644 --- a/web/src/app/(main)/layout.tsx +++ b/web/src/app/(main)/layout.tsx @@ -1,7 +1,6 @@ 'use client' import { motion } from 'motion/react' -import { usePathname } from 'next/navigation' import { Navbar } from '@/components/layout/navbar-or-side' import { BackgroundProvider } from '@/contexts/background-context' import Footer from '@/components/layout/footer' diff --git a/web/src/app/(main)/p/[id]/page.tsx b/web/src/app/(main)/p/[id]/page.tsx index c744132..745201e 100644 --- a/web/src/app/(main)/p/[id]/page.tsx +++ b/web/src/app/(main)/p/[id]/page.tsx @@ -2,19 +2,16 @@ import { getPostById } from '@/api/post' import { cookies } from 'next/headers' import BlogPost from '@/components/blog-post/blog-post' -interface Props { - params: Promise<{ id: string }> -} - -export default async function PostPage({ params }: Props) { +// 这个是approuter固定的传入格式,无法更改 +export default async function PostPage({ params }: { params: { id: string } }) { const cookieStore = await cookies(); - const { id } = await params - const post = await getPostById(id, cookieStore.get('token')?.value || ''); + const { id } = params; + const post = await getPostById({id, token: cookieStore.get('token')?.value || ''}); if (!post) return
文章不存在
return (
- +
) } diff --git a/web/src/app/console/data.json b/web/src/app/console/data.json new file mode 100644 index 0000000..ec08736 --- /dev/null +++ b/web/src/app/console/data.json @@ -0,0 +1,614 @@ +[ + { + "id": 1, + "header": "Cover page", + "type": "Cover page", + "status": "In Process", + "target": "18", + "limit": "5", + "reviewer": "Eddie Lake" + }, + { + "id": 2, + "header": "Table of contents", + "type": "Table of contents", + "status": "Done", + "target": "29", + "limit": "24", + "reviewer": "Eddie Lake" + }, + { + "id": 3, + "header": "Executive summary", + "type": "Narrative", + "status": "Done", + "target": "10", + "limit": "13", + "reviewer": "Eddie Lake" + }, + { + "id": 4, + "header": "Technical approach", + "type": "Narrative", + "status": "Done", + "target": "27", + "limit": "23", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 5, + "header": "Design", + "type": "Narrative", + "status": "In Process", + "target": "2", + "limit": "16", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 6, + "header": "Capabilities", + "type": "Narrative", + "status": "In Process", + "target": "20", + "limit": "8", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 7, + "header": "Integration with existing systems", + "type": "Narrative", + "status": "In Process", + "target": "19", + "limit": "21", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 8, + "header": "Innovation and Advantages", + "type": "Narrative", + "status": "Done", + "target": "25", + "limit": "26", + "reviewer": "Assign reviewer" + }, + { + "id": 9, + "header": "Overview of EMR's Innovative Solutions", + "type": "Technical content", + "status": "Done", + "target": "7", + "limit": "23", + "reviewer": "Assign reviewer" + }, + { + "id": 10, + "header": "Advanced Algorithms and Machine Learning", + "type": "Narrative", + "status": "Done", + "target": "30", + "limit": "28", + "reviewer": "Assign reviewer" + }, + { + "id": 11, + "header": "Adaptive Communication Protocols", + "type": "Narrative", + "status": "Done", + "target": "9", + "limit": "31", + "reviewer": "Assign reviewer" + }, + { + "id": 12, + "header": "Advantages Over Current Technologies", + "type": "Narrative", + "status": "Done", + "target": "12", + "limit": "0", + "reviewer": "Assign reviewer" + }, + { + "id": 13, + "header": "Past Performance", + "type": "Narrative", + "status": "Done", + "target": "22", + "limit": "33", + "reviewer": "Assign reviewer" + }, + { + "id": 14, + "header": "Customer Feedback and Satisfaction Levels", + "type": "Narrative", + "status": "Done", + "target": "15", + "limit": "34", + "reviewer": "Assign reviewer" + }, + { + "id": 15, + "header": "Implementation Challenges and Solutions", + "type": "Narrative", + "status": "Done", + "target": "3", + "limit": "35", + "reviewer": "Assign reviewer" + }, + { + "id": 16, + "header": "Security Measures and Data Protection Policies", + "type": "Narrative", + "status": "In Process", + "target": "6", + "limit": "36", + "reviewer": "Assign reviewer" + }, + { + "id": 17, + "header": "Scalability and Future Proofing", + "type": "Narrative", + "status": "Done", + "target": "4", + "limit": "37", + "reviewer": "Assign reviewer" + }, + { + "id": 18, + "header": "Cost-Benefit Analysis", + "type": "Plain language", + "status": "Done", + "target": "14", + "limit": "38", + "reviewer": "Assign reviewer" + }, + { + "id": 19, + "header": "User Training and Onboarding Experience", + "type": "Narrative", + "status": "Done", + "target": "17", + "limit": "39", + "reviewer": "Assign reviewer" + }, + { + "id": 20, + "header": "Future Development Roadmap", + "type": "Narrative", + "status": "Done", + "target": "11", + "limit": "40", + "reviewer": "Assign reviewer" + }, + { + "id": 21, + "header": "System Architecture Overview", + "type": "Technical content", + "status": "In Process", + "target": "24", + "limit": "18", + "reviewer": "Maya Johnson" + }, + { + "id": 22, + "header": "Risk Management Plan", + "type": "Narrative", + "status": "Done", + "target": "15", + "limit": "22", + "reviewer": "Carlos Rodriguez" + }, + { + "id": 23, + "header": "Compliance Documentation", + "type": "Legal", + "status": "In Process", + "target": "31", + "limit": "27", + "reviewer": "Sarah Chen" + }, + { + "id": 24, + "header": "API Documentation", + "type": "Technical content", + "status": "Done", + "target": "8", + "limit": "12", + "reviewer": "Raj Patel" + }, + { + "id": 25, + "header": "User Interface Mockups", + "type": "Visual", + "status": "In Process", + "target": "19", + "limit": "25", + "reviewer": "Leila Ahmadi" + }, + { + "id": 26, + "header": "Database Schema", + "type": "Technical content", + "status": "Done", + "target": "22", + "limit": "20", + "reviewer": "Thomas Wilson" + }, + { + "id": 27, + "header": "Testing Methodology", + "type": "Technical content", + "status": "In Process", + "target": "17", + "limit": "14", + "reviewer": "Assign reviewer" + }, + { + "id": 28, + "header": "Deployment Strategy", + "type": "Narrative", + "status": "Done", + "target": "26", + "limit": "30", + "reviewer": "Eddie Lake" + }, + { + "id": 29, + "header": "Budget Breakdown", + "type": "Financial", + "status": "In Process", + "target": "13", + "limit": "16", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 30, + "header": "Market Analysis", + "type": "Research", + "status": "Done", + "target": "29", + "limit": "32", + "reviewer": "Sophia Martinez" + }, + { + "id": 31, + "header": "Competitor Comparison", + "type": "Research", + "status": "In Process", + "target": "21", + "limit": "19", + "reviewer": "Assign reviewer" + }, + { + "id": 32, + "header": "Maintenance Plan", + "type": "Technical content", + "status": "Done", + "target": "16", + "limit": "23", + "reviewer": "Alex Thompson" + }, + { + "id": 33, + "header": "User Personas", + "type": "Research", + "status": "In Process", + "target": "27", + "limit": "24", + "reviewer": "Nina Patel" + }, + { + "id": 34, + "header": "Accessibility Compliance", + "type": "Legal", + "status": "Done", + "target": "18", + "limit": "21", + "reviewer": "Assign reviewer" + }, + { + "id": 35, + "header": "Performance Metrics", + "type": "Technical content", + "status": "In Process", + "target": "23", + "limit": "26", + "reviewer": "David Kim" + }, + { + "id": 36, + "header": "Disaster Recovery Plan", + "type": "Technical content", + "status": "Done", + "target": "14", + "limit": "17", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 37, + "header": "Third-party Integrations", + "type": "Technical content", + "status": "In Process", + "target": "25", + "limit": "28", + "reviewer": "Eddie Lake" + }, + { + "id": 38, + "header": "User Feedback Summary", + "type": "Research", + "status": "Done", + "target": "20", + "limit": "15", + "reviewer": "Assign reviewer" + }, + { + "id": 39, + "header": "Localization Strategy", + "type": "Narrative", + "status": "In Process", + "target": "12", + "limit": "19", + "reviewer": "Maria Garcia" + }, + { + "id": 40, + "header": "Mobile Compatibility", + "type": "Technical content", + "status": "Done", + "target": "28", + "limit": "31", + "reviewer": "James Wilson" + }, + { + "id": 41, + "header": "Data Migration Plan", + "type": "Technical content", + "status": "In Process", + "target": "19", + "limit": "22", + "reviewer": "Assign reviewer" + }, + { + "id": 42, + "header": "Quality Assurance Protocols", + "type": "Technical content", + "status": "Done", + "target": "30", + "limit": "33", + "reviewer": "Priya Singh" + }, + { + "id": 43, + "header": "Stakeholder Analysis", + "type": "Research", + "status": "In Process", + "target": "11", + "limit": "14", + "reviewer": "Eddie Lake" + }, + { + "id": 44, + "header": "Environmental Impact Assessment", + "type": "Research", + "status": "Done", + "target": "24", + "limit": "27", + "reviewer": "Assign reviewer" + }, + { + "id": 45, + "header": "Intellectual Property Rights", + "type": "Legal", + "status": "In Process", + "target": "17", + "limit": "20", + "reviewer": "Sarah Johnson" + }, + { + "id": 46, + "header": "Customer Support Framework", + "type": "Narrative", + "status": "Done", + "target": "22", + "limit": "25", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 47, + "header": "Version Control Strategy", + "type": "Technical content", + "status": "In Process", + "target": "15", + "limit": "18", + "reviewer": "Assign reviewer" + }, + { + "id": 48, + "header": "Continuous Integration Pipeline", + "type": "Technical content", + "status": "Done", + "target": "26", + "limit": "29", + "reviewer": "Michael Chen" + }, + { + "id": 49, + "header": "Regulatory Compliance", + "type": "Legal", + "status": "In Process", + "target": "13", + "limit": "16", + "reviewer": "Assign reviewer" + }, + { + "id": 50, + "header": "User Authentication System", + "type": "Technical content", + "status": "Done", + "target": "28", + "limit": "31", + "reviewer": "Eddie Lake" + }, + { + "id": 51, + "header": "Data Analytics Framework", + "type": "Technical content", + "status": "In Process", + "target": "21", + "limit": "24", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 52, + "header": "Cloud Infrastructure", + "type": "Technical content", + "status": "Done", + "target": "16", + "limit": "19", + "reviewer": "Assign reviewer" + }, + { + "id": 53, + "header": "Network Security Measures", + "type": "Technical content", + "status": "In Process", + "target": "29", + "limit": "32", + "reviewer": "Lisa Wong" + }, + { + "id": 54, + "header": "Project Timeline", + "type": "Planning", + "status": "Done", + "target": "14", + "limit": "17", + "reviewer": "Eddie Lake" + }, + { + "id": 55, + "header": "Resource Allocation", + "type": "Planning", + "status": "In Process", + "target": "27", + "limit": "30", + "reviewer": "Assign reviewer" + }, + { + "id": 56, + "header": "Team Structure and Roles", + "type": "Planning", + "status": "Done", + "target": "20", + "limit": "23", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 57, + "header": "Communication Protocols", + "type": "Planning", + "status": "In Process", + "target": "15", + "limit": "18", + "reviewer": "Assign reviewer" + }, + { + "id": 58, + "header": "Success Metrics", + "type": "Planning", + "status": "Done", + "target": "30", + "limit": "33", + "reviewer": "Eddie Lake" + }, + { + "id": 59, + "header": "Internationalization Support", + "type": "Technical content", + "status": "In Process", + "target": "23", + "limit": "26", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 60, + "header": "Backup and Recovery Procedures", + "type": "Technical content", + "status": "Done", + "target": "18", + "limit": "21", + "reviewer": "Assign reviewer" + }, + { + "id": 61, + "header": "Monitoring and Alerting System", + "type": "Technical content", + "status": "In Process", + "target": "25", + "limit": "28", + "reviewer": "Daniel Park" + }, + { + "id": 62, + "header": "Code Review Guidelines", + "type": "Technical content", + "status": "Done", + "target": "12", + "limit": "15", + "reviewer": "Eddie Lake" + }, + { + "id": 63, + "header": "Documentation Standards", + "type": "Technical content", + "status": "In Process", + "target": "27", + "limit": "30", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 64, + "header": "Release Management Process", + "type": "Planning", + "status": "Done", + "target": "22", + "limit": "25", + "reviewer": "Assign reviewer" + }, + { + "id": 65, + "header": "Feature Prioritization Matrix", + "type": "Planning", + "status": "In Process", + "target": "19", + "limit": "22", + "reviewer": "Emma Davis" + }, + { + "id": 66, + "header": "Technical Debt Assessment", + "type": "Technical content", + "status": "Done", + "target": "24", + "limit": "27", + "reviewer": "Eddie Lake" + }, + { + "id": 67, + "header": "Capacity Planning", + "type": "Planning", + "status": "In Process", + "target": "21", + "limit": "24", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 68, + "header": "Service Level Agreements", + "type": "Legal", + "status": "Done", + "target": "26", + "limit": "29", + "reviewer": "Assign reviewer" + } +] diff --git a/web/src/app/console/page.tsx b/web/src/app/console/page.tsx new file mode 100644 index 0000000..e59e1ba --- /dev/null +++ b/web/src/app/console/page.tsx @@ -0,0 +1,45 @@ +"use client" +import { AppSidebar } from "@/components/console/app-sidebar" +import { SiteHeader } from "@/components/console/site-header" +import { + SidebarInset, + SidebarProvider, +} from "@/components/ui/sidebar" + +import { useToLogin } from "@/hooks/use-route" +import { useEffect, useState } from "react" +import { User } from "@/models/user" +import { getLoginUser } from "@/api/user" + +export default function Page() { + const [user, setUser] = useState(null); + const toLogin = useToLogin(); + + useEffect(() => { + getLoginUser().then(res => { + setUser(res.data); + }).catch(() => { + setUser(null); + toLogin(); + }); + }, []); + if (user === null) { + return null; + } + + return ( + + + + + + + ) +} diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 761ae31..7cb8e74 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -4,7 +4,7 @@ import "./globals.css"; import { DeviceProvider } from "@/contexts/device-context"; import { NextIntlClientProvider } from 'next-intl'; import config from "@/config"; -import { getUserLocales, getFirstLocale } from '@/i18n/request'; +import { getFirstLocale } from '@/i18n/request'; import { Toaster } from "@/components/ui/sonner" const geistSans = Geist({ @@ -34,7 +34,9 @@ export default async function RootLayout({ > - {children} + + {children} + diff --git a/web/src/components/blog-home/blog-home.tsx b/web/src/components/blog-home/blog-home.tsx index bced2e6..f568bcc 100644 --- a/web/src/components/blog-home/blog-home.tsx +++ b/web/src/components/blog-home/blog-home.tsx @@ -29,7 +29,6 @@ export default function BlogHome() { // 从路由查询参数中获取页码和标签们 const searchParams = useSearchParams(); const t = useTranslations("BlogHome"); - const [labels, setLabels] = useState([]); const [keywords, setKeywords] = useState([]); const [currentPage, setCurrentPage] = useState(1); diff --git a/web/src/components/blog-post/blog-post.tsx b/web/src/components/blog-post/blog-post.tsx index 0995294..6ab9d42 100644 --- a/web/src/components/blog-post/blog-post.tsx +++ b/web/src/components/blog-post/blog-post.tsx @@ -15,7 +15,7 @@ function PostMeta({ post }: { post: Post }) { {/* 作者 */} - {post.user.nickname || "未知作者"} + {post.user.nickname || post.user.username || "未知作者"} {/* 字数 */} @@ -140,10 +140,10 @@ async function PostContent({ post }: { post: Post }) { } -async function BlogPost({ post }: { post: Post }) { +async function BlogPost({ post }: { post: Post}) { return (
+ > {/* */} {t("reply")} : + <>{t("reply")} : } {commentState.content}

@@ -277,7 +277,7 @@ export function CommentItem( user={user} onCommentSubmitted={onReply} initIsPrivate={commentState.isPrivate} - placeholder={`${t("reply")} ${commentState.user.nickname} :`} + placeholder={`${t("reply")} ${commentState.user.nickname || commentState.user.username} :`} />} {activeInput && activeInput.type === 'edit' && activeInput.id === commentState.id && (null); + const [user, setUser] = useState(null); const [comments, setComments] = useState([]); const [activeInput, setActiveInput] = useState<{ id: number; type: 'reply' | 'edit' } | null>(null); const [page, setPage] = useState(1); // 当前页码 const [totalCommentCount, setTotalCommentCount] = useState(totalCount); // 评论总数 const [needLoadMore, setNeedLoadMore] = useState(true); // 是否需要加载更多,当最后一次获取的评论数小于分页大小时设为false - // 获取当前登录用户 + // 获取登录用户信息 useEffect(() => { - getLoginUser() - .then(response => { - setCurrentUser(response.data); - }) - }, []) - + getLoginUser().then(res => { + setUser(res.data); + }).catch(() => { + setUser(null); + }); + }, []); // 加载0/顶层评论 useEffect(() => { listComments({ @@ -118,7 +117,7 @@ export function CommentSection(
{t("comment")} ({totalCommentCount})
@@ -127,7 +126,7 @@ export function CommentSection(
) { + return ( + + + + + + + + {config.metadata.name} + + + + + + + + + + + + + + + ) +} diff --git a/web/src/components/console/chart-area-interactive.tsx b/web/src/components/console/chart-area-interactive.tsx new file mode 100644 index 0000000..5b475ea --- /dev/null +++ b/web/src/components/console/chart-area-interactive.tsx @@ -0,0 +1,291 @@ +"use client" + +import * as React from "react" +import { Area, AreaChart, CartesianGrid, XAxis } from "recharts" + +import { useIsMobile } from "@/hooks/use-mobile" +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + ToggleGroup, + ToggleGroupItem, +} from "@/components/ui/toggle-group" + +export const description = "An interactive area chart" + +const chartData = [ + { date: "2024-04-01", desktop: 222, mobile: 150 }, + { date: "2024-04-02", desktop: 97, mobile: 180 }, + { date: "2024-04-03", desktop: 167, mobile: 120 }, + { date: "2024-04-04", desktop: 242, mobile: 260 }, + { date: "2024-04-05", desktop: 373, mobile: 290 }, + { date: "2024-04-06", desktop: 301, mobile: 340 }, + { date: "2024-04-07", desktop: 245, mobile: 180 }, + { date: "2024-04-08", desktop: 409, mobile: 320 }, + { date: "2024-04-09", desktop: 59, mobile: 110 }, + { date: "2024-04-10", desktop: 261, mobile: 190 }, + { date: "2024-04-11", desktop: 327, mobile: 350 }, + { date: "2024-04-12", desktop: 292, mobile: 210 }, + { date: "2024-04-13", desktop: 342, mobile: 380 }, + { date: "2024-04-14", desktop: 137, mobile: 220 }, + { date: "2024-04-15", desktop: 120, mobile: 170 }, + { date: "2024-04-16", desktop: 138, mobile: 190 }, + { date: "2024-04-17", desktop: 446, mobile: 360 }, + { date: "2024-04-18", desktop: 364, mobile: 410 }, + { date: "2024-04-19", desktop: 243, mobile: 180 }, + { date: "2024-04-20", desktop: 89, mobile: 150 }, + { date: "2024-04-21", desktop: 137, mobile: 200 }, + { date: "2024-04-22", desktop: 224, mobile: 170 }, + { date: "2024-04-23", desktop: 138, mobile: 230 }, + { date: "2024-04-24", desktop: 387, mobile: 290 }, + { date: "2024-04-25", desktop: 215, mobile: 250 }, + { date: "2024-04-26", desktop: 75, mobile: 130 }, + { date: "2024-04-27", desktop: 383, mobile: 420 }, + { date: "2024-04-28", desktop: 122, mobile: 180 }, + { date: "2024-04-29", desktop: 315, mobile: 240 }, + { date: "2024-04-30", desktop: 454, mobile: 380 }, + { date: "2024-05-01", desktop: 165, mobile: 220 }, + { date: "2024-05-02", desktop: 293, mobile: 310 }, + { date: "2024-05-03", desktop: 247, mobile: 190 }, + { date: "2024-05-04", desktop: 385, mobile: 420 }, + { date: "2024-05-05", desktop: 481, mobile: 390 }, + { date: "2024-05-06", desktop: 498, mobile: 520 }, + { date: "2024-05-07", desktop: 388, mobile: 300 }, + { date: "2024-05-08", desktop: 149, mobile: 210 }, + { date: "2024-05-09", desktop: 227, mobile: 180 }, + { date: "2024-05-10", desktop: 293, mobile: 330 }, + { date: "2024-05-11", desktop: 335, mobile: 270 }, + { date: "2024-05-12", desktop: 197, mobile: 240 }, + { date: "2024-05-13", desktop: 197, mobile: 160 }, + { date: "2024-05-14", desktop: 448, mobile: 490 }, + { date: "2024-05-15", desktop: 473, mobile: 380 }, + { date: "2024-05-16", desktop: 338, mobile: 400 }, + { date: "2024-05-17", desktop: 499, mobile: 420 }, + { date: "2024-05-18", desktop: 315, mobile: 350 }, + { date: "2024-05-19", desktop: 235, mobile: 180 }, + { date: "2024-05-20", desktop: 177, mobile: 230 }, + { date: "2024-05-21", desktop: 82, mobile: 140 }, + { date: "2024-05-22", desktop: 81, mobile: 120 }, + { date: "2024-05-23", desktop: 252, mobile: 290 }, + { date: "2024-05-24", desktop: 294, mobile: 220 }, + { date: "2024-05-25", desktop: 201, mobile: 250 }, + { date: "2024-05-26", desktop: 213, mobile: 170 }, + { date: "2024-05-27", desktop: 420, mobile: 460 }, + { date: "2024-05-28", desktop: 233, mobile: 190 }, + { date: "2024-05-29", desktop: 78, mobile: 130 }, + { date: "2024-05-30", desktop: 340, mobile: 280 }, + { date: "2024-05-31", desktop: 178, mobile: 230 }, + { date: "2024-06-01", desktop: 178, mobile: 200 }, + { date: "2024-06-02", desktop: 470, mobile: 410 }, + { date: "2024-06-03", desktop: 103, mobile: 160 }, + { date: "2024-06-04", desktop: 439, mobile: 380 }, + { date: "2024-06-05", desktop: 88, mobile: 140 }, + { date: "2024-06-06", desktop: 294, mobile: 250 }, + { date: "2024-06-07", desktop: 323, mobile: 370 }, + { date: "2024-06-08", desktop: 385, mobile: 320 }, + { date: "2024-06-09", desktop: 438, mobile: 480 }, + { date: "2024-06-10", desktop: 155, mobile: 200 }, + { date: "2024-06-11", desktop: 92, mobile: 150 }, + { date: "2024-06-12", desktop: 492, mobile: 420 }, + { date: "2024-06-13", desktop: 81, mobile: 130 }, + { date: "2024-06-14", desktop: 426, mobile: 380 }, + { date: "2024-06-15", desktop: 307, mobile: 350 }, + { date: "2024-06-16", desktop: 371, mobile: 310 }, + { date: "2024-06-17", desktop: 475, mobile: 520 }, + { date: "2024-06-18", desktop: 107, mobile: 170 }, + { date: "2024-06-19", desktop: 341, mobile: 290 }, + { date: "2024-06-20", desktop: 408, mobile: 450 }, + { date: "2024-06-21", desktop: 169, mobile: 210 }, + { date: "2024-06-22", desktop: 317, mobile: 270 }, + { date: "2024-06-23", desktop: 480, mobile: 530 }, + { date: "2024-06-24", desktop: 132, mobile: 180 }, + { date: "2024-06-25", desktop: 141, mobile: 190 }, + { date: "2024-06-26", desktop: 434, mobile: 380 }, + { date: "2024-06-27", desktop: 448, mobile: 490 }, + { date: "2024-06-28", desktop: 149, mobile: 200 }, + { date: "2024-06-29", desktop: 103, mobile: 160 }, + { date: "2024-06-30", desktop: 446, mobile: 400 }, +] + +const chartConfig = { + visitors: { + label: "Visitors", + }, + desktop: { + label: "Desktop", + color: "var(--primary)", + }, + mobile: { + label: "Mobile", + color: "var(--primary)", + }, +} satisfies ChartConfig + +export function ChartAreaInteractive() { + const isMobile = useIsMobile() + const [timeRange, setTimeRange] = React.useState("90d") + + React.useEffect(() => { + if (isMobile) { + setTimeRange("7d") + } + }, [isMobile]) + + const filteredData = chartData.filter((item) => { + const date = new Date(item.date) + const referenceDate = new Date("2024-06-30") + let daysToSubtract = 90 + if (timeRange === "30d") { + daysToSubtract = 30 + } else if (timeRange === "7d") { + daysToSubtract = 7 + } + const startDate = new Date(referenceDate) + startDate.setDate(startDate.getDate() - daysToSubtract) + return date >= startDate + }) + + return ( + + + Total Visitors + + + Total for the last 3 months + + Last 3 months + + + + Last 3 months + Last 30 days + Last 7 days + + + + + + + + + + + + + + + + + + + { + const date = new Date(value) + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }) + }} + /> + { + return new Date(value).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }) + }} + indicator="dot" + /> + } + /> + + + + + + + ) +} diff --git a/web/src/components/console/data-table.tsx b/web/src/components/console/data-table.tsx new file mode 100644 index 0000000..4834681 --- /dev/null +++ b/web/src/components/console/data-table.tsx @@ -0,0 +1,807 @@ +"use client" + +import * as React from "react" +import { + closestCenter, + DndContext, + KeyboardSensor, + MouseSensor, + TouchSensor, + useSensor, + useSensors, + type DragEndEvent, + type UniqueIdentifier, +} from "@dnd-kit/core" +import { restrictToVerticalAxis } from "@dnd-kit/modifiers" +import { + arrayMove, + SortableContext, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import { + IconChevronDown, + IconChevronLeft, + IconChevronRight, + IconChevronsLeft, + IconChevronsRight, + IconCircleCheckFilled, + IconDotsVertical, + IconGripVertical, + IconLayoutColumns, + IconLoader, + IconPlus, + IconTrendingUp, +} from "@tabler/icons-react" +import { + ColumnDef, + ColumnFiltersState, + flexRender, + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + Row, + SortingState, + useReactTable, + VisibilityState, +} from "@tanstack/react-table" +import { Area, AreaChart, CartesianGrid, XAxis } from "recharts" +import { toast } from "sonner" +import { z } from "zod" + +import { useIsMobile } from "@/hooks/use-mobile" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart" +import { Checkbox } from "@/components/ui/checkbox" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Separator } from "@/components/ui/separator" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs" + +export const schema = z.object({ + id: z.number(), + header: z.string(), + type: z.string(), + status: z.string(), + target: z.string(), + limit: z.string(), + reviewer: z.string(), +}) + +// Create a separate component for the drag handle +function DragHandle({ id }: { id: number }) { + const { attributes, listeners } = useSortable({ + id, + }) + + return ( + + ) +} + +const columns: ColumnDef>[] = [ + { + id: "drag", + header: () => null, + cell: ({ row }) => , + }, + { + id: "select", + header: ({ table }) => ( +
+ table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> +
+ ), + cell: ({ row }) => ( +
+ row.toggleSelected(!!value)} + aria-label="Select row" + /> +
+ ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "header", + header: "Header", + cell: ({ row }) => { + return + }, + enableHiding: false, + }, + { + accessorKey: "type", + header: "Section Type", + cell: ({ row }) => ( +
+ + {row.original.type} + +
+ ), + }, + { + accessorKey: "status", + header: "Status", + cell: ({ row }) => ( + + {row.original.status === "Done" ? ( + + ) : ( + + )} + {row.original.status} + + ), + }, + { + accessorKey: "target", + header: () =>
Target
, + cell: ({ row }) => ( +
{ + e.preventDefault() + toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), { + loading: `Saving ${row.original.header}`, + success: "Done", + error: "Error", + }) + }} + > + + +
+ ), + }, + { + accessorKey: "limit", + header: () =>
Limit
, + cell: ({ row }) => ( +
{ + e.preventDefault() + toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), { + loading: `Saving ${row.original.header}`, + success: "Done", + error: "Error", + }) + }} + > + + +
+ ), + }, + { + accessorKey: "reviewer", + header: "Reviewer", + cell: ({ row }) => { + const isAssigned = row.original.reviewer !== "Assign reviewer" + + if (isAssigned) { + return row.original.reviewer + } + + return ( + <> + + + + ) + }, + }, + { + id: "actions", + cell: () => ( + + + + + + Edit + Make a copy + Favorite + + Delete + + + ), + }, +] + +function DraggableRow({ row }: { row: Row> }) { + const { transform, transition, setNodeRef, isDragging } = useSortable({ + id: row.original.id, + }) + + return ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ) +} + +export function DataTable({ + data: initialData, +}: { + data: z.infer[] +}) { + const [data, setData] = React.useState(() => initialData) + const [rowSelection, setRowSelection] = React.useState({}) + const [columnVisibility, setColumnVisibility] = + React.useState({}) + const [columnFilters, setColumnFilters] = React.useState( + [] + ) + const [sorting, setSorting] = React.useState([]) + const [pagination, setPagination] = React.useState({ + pageIndex: 0, + pageSize: 10, + }) + const sortableId = React.useId() + const sensors = useSensors( + useSensor(MouseSensor, {}), + useSensor(TouchSensor, {}), + useSensor(KeyboardSensor, {}) + ) + + const dataIds = React.useMemo( + () => data?.map(({ id }) => id) || [], + [data] + ) + + const table = useReactTable({ + data, + columns, + state: { + sorting, + columnVisibility, + rowSelection, + columnFilters, + pagination, + }, + getRowId: (row) => row.id.toString(), + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + }) + + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event + if (active && over && active.id !== over.id) { + setData((data) => { + const oldIndex = dataIds.indexOf(active.id) + const newIndex = dataIds.indexOf(over.id) + return arrayMove(data, oldIndex, newIndex) + }) + } + } + + return ( + +
+ + + + Outline + + Past Performance 3 + + + Key Personnel 2 + + Focus Documents + +
+ + + + + + {table + .getAllColumns() + .filter( + (column) => + typeof column.accessorFn !== "undefined" && + column.getCanHide() + ) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + > + {column.id} + + ) + })} + + + +
+
+ +
+ + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + + {table.getRowModel().rows.map((row) => ( + + ))} + + ) : ( + + + No results. + + + )} + +
+
+
+
+
+ {table.getFilteredSelectedRowModel().rows.length} of{" "} + {table.getFilteredRowModel().rows.length} row(s) selected. +
+
+
+ + +
+
+ Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} +
+
+ + + + +
+
+
+
+ +
+
+ +
+
+ +
+
+
+ ) +} + +const chartData = [ + { month: "January", desktop: 186, mobile: 80 }, + { month: "February", desktop: 305, mobile: 200 }, + { month: "March", desktop: 237, mobile: 120 }, + { month: "April", desktop: 73, mobile: 190 }, + { month: "May", desktop: 209, mobile: 130 }, + { month: "June", desktop: 214, mobile: 140 }, +] + +const chartConfig = { + desktop: { + label: "Desktop", + color: "var(--primary)", + }, + mobile: { + label: "Mobile", + color: "var(--primary)", + }, +} satisfies ChartConfig + +function TableCellViewer({ item }: { item: z.infer }) { + const isMobile = useIsMobile() + + return ( + + + + + + + {item.header} + + Showing total visitors for the last 6 months + + +
+ {!isMobile && ( + <> + + + + value.slice(0, 3)} + hide + /> + } + /> + + + + + +
+
+ Trending up by 5.2% this month{" "} + +
+
+ Showing total visitors for the last 6 months. This is just + some random text to test the layout. It spans multiple lines + and should wrap around. +
+
+ + + )} +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + + + + + +
+
+ ) +} diff --git a/web/src/components/console/nav-documents.tsx b/web/src/components/console/nav-documents.tsx new file mode 100644 index 0000000..b551e71 --- /dev/null +++ b/web/src/components/console/nav-documents.tsx @@ -0,0 +1,92 @@ +"use client" + +import { + IconDots, + IconFolder, + IconShare3, + IconTrash, + type Icon, +} from "@tabler/icons-react" + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + SidebarGroup, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar" + +export function NavDocuments({ + items, +}: { + items: { + name: string + url: string + icon: Icon + }[] +}) { + const { isMobile } = useSidebar() + + return ( + + Documents + + {items.map((item) => ( + + + + + {item.name} + + + + + + + More + + + + + + Open + + + + Share + + + + + Delete + + + + + ))} + + + + More + + + + + ) +} diff --git a/web/src/components/console/nav-main.tsx b/web/src/components/console/nav-main.tsx new file mode 100644 index 0000000..4ea34d8 --- /dev/null +++ b/web/src/components/console/nav-main.tsx @@ -0,0 +1,39 @@ +"use client" + +import { IconCirclePlusFilled, IconMail, type Icon } from "@tabler/icons-react" + +import { Button } from "@/components/ui/button" +import { + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar" + +export function NavMain({ + items, +}: { + items: { + title: string + url: string + icon?: Icon + }[] +}) { + return ( + + + + {items.map((item) => ( + + + {item.icon && } + {item.title} + + + ))} + + + + ) +} diff --git a/web/src/components/console/nav-secondary.tsx b/web/src/components/console/nav-secondary.tsx new file mode 100644 index 0000000..3f3636f --- /dev/null +++ b/web/src/components/console/nav-secondary.tsx @@ -0,0 +1,42 @@ +"use client" + +import * as React from "react" +import { type Icon } from "@tabler/icons-react" + +import { + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar" + +export function NavSecondary({ + items, + ...props +}: { + items: { + title: string + url: string + icon: Icon + }[] +} & React.ComponentPropsWithoutRef) { + return ( + + + + {items.map((item) => ( + + + + + {item.title} + + + + ))} + + + + ) +} diff --git a/web/src/components/console/nav-user.tsx b/web/src/components/console/nav-user.tsx new file mode 100644 index 0000000..7c49dc7 --- /dev/null +++ b/web/src/components/console/nav-user.tsx @@ -0,0 +1,110 @@ +"use client" + +import { + IconCreditCard, + IconDotsVertical, + IconLogout, + IconNotification, + IconUserCircle, +} from "@tabler/icons-react" + +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@/components/ui/avatar" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar" + +export function NavUser({ + user, +}: { + user: { + name: string + email: string + avatar: string + } +}) { + const { isMobile } = useSidebar() + + return ( + + + + + + + + CN + +
+ {user.name} + + {user.email} + +
+ +
+
+ + +
+ + + CN + +
+ {user.name} + + {user.email} + +
+
+
+ + + + + Account + + + + Billing + + + + Notifications + + + + + + Log out + +
+
+
+
+ ) +} diff --git a/web/src/components/console/section-cards.tsx b/web/src/components/console/section-cards.tsx new file mode 100644 index 0000000..f714d25 --- /dev/null +++ b/web/src/components/console/section-cards.tsx @@ -0,0 +1,102 @@ +import { IconTrendingDown, IconTrendingUp } from "@tabler/icons-react" + +import { Badge } from "@/components/ui/badge" +import { + Card, + CardAction, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card" + +export function SectionCards() { + return ( +
+ + + Total Revenue + + $1,250.00 + + + + + +12.5% + + + + +
+ Trending up this month +
+
+ Visitors for the last 6 months +
+
+
+ + + New Customers + + 1,234 + + + + + -20% + + + + +
+ Down 20% this period +
+
+ Acquisition needs attention +
+
+
+ + + Active Accounts + + 45,678 + + + + + +12.5% + + + + +
+ Strong user retention +
+
Engagement exceed targets
+
+
+ + + Growth Rate + + 4.5% + + + + + +4.5% + + + + +
+ Steady performance increase +
+
Meets growth projections
+
+
+
+ ) +} diff --git a/web/src/components/console/site-header.tsx b/web/src/components/console/site-header.tsx new file mode 100644 index 0000000..59c4f02 --- /dev/null +++ b/web/src/components/console/site-header.tsx @@ -0,0 +1,30 @@ +import { Button } from "@/components/ui/button" +import { Separator } from "@/components/ui/separator" +import { SidebarTrigger } from "@/components/ui/sidebar" + +export function SiteHeader() { + return ( +
+
+ + +

Documents

+
+ +
+
+
+ ) +} diff --git a/web/src/components/layout/avatar-with-dropdown-menu.tsx b/web/src/components/layout/avatar-with-dropdown-menu.tsx new file mode 100644 index 0000000..0a3cfb6 --- /dev/null +++ b/web/src/components/layout/avatar-with-dropdown-menu.tsx @@ -0,0 +1,92 @@ +"use client" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import GravatarAvatar from "../common/gravatar" +import { User } from "@/models/user"; +import { useEffect, useState } from "react"; +import { getLoginUser, userLogout } from "@/api/user"; +import Link from "next/link"; +import { toast } from "sonner"; +import { useToLogin } from "@/hooks/use-route"; + +export function AvatarWithDropdownMenu() { + const [user, setUser] = useState(null); + const toLogin = useToLogin(); + useEffect(() => { + getLoginUser().then(res => { + setUser(res.data); + }).catch(() => { + setUser(null); + }); + }, []); + + const handleLogout = () => { + userLogout().then(() => { + toast.success("Logged out successfully"); + window.location.reload(); + }) + } + + return ( + + + + + + My Account + + + Profile + + + Billing + + + Console + + + + + Team + + Invite users + + + Email + Message + + More... + + + + + New Team + ⌘+T + + + + GitHub + Support + API + + + {user ? `Logout (${user.username})` : "Login"} + + + + ) +} \ No newline at end of file diff --git a/web/src/components/layout/navbar-or-side.tsx b/web/src/components/layout/navbar-or-side.tsx index d4dd1c1..c800701 100644 --- a/web/src/components/layout/navbar-or-side.tsx +++ b/web/src/components/layout/navbar-or-side.tsx @@ -17,8 +17,8 @@ import config from "@/config" import { useState } from "react" import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet" import { Menu } from "lucide-react" -import { Switch } from "../ui/switch" import { ThemeModeToggle } from "../common/theme-toggle" +import { AvatarWithDropdownMenu } from "./avatar-with-dropdown-menu" const navbarMenuComponents = [ { @@ -49,12 +49,13 @@ export function Navbar() { return (