From abe109971161aa7f7b217a9f093b807bbf5c9e96 Mon Sep 17 00:00:00 2001 From: Snowykami Date: Thu, 24 Jul 2025 13:12:59 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20feat:=20update=20global=20?= =?UTF-8?q?styles=20and=20color=20variables=20for=20improved=20theming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor: change import paths for DeviceContext and GravatarAvatar components fix: adjust login form API call and update UI text for clarity feat: add post API for listing posts with pagination and filtering options feat: implement BlogCard component for displaying blog posts with enhanced UI feat: create Badge component for consistent styling of labels and indicators refactor: reintroduce DeviceContext with improved functionality for theme and language management feat: define Label and Post models for better type safety and structure --- internal/controller/v1/label.go | 105 ++++++- internal/controller/v1/post.go | 18 +- internal/controller/v1/user.go | 17 +- internal/ctxutils/user.go | 32 +- internal/dto/label.go | 1 + internal/dto/post.go | 8 + internal/middleware/auth.go | 5 +- internal/model/label.go | 2 +- internal/model/post.go | 6 + internal/repo/label.go | 12 +- internal/router/apiv1/label.go | 11 +- internal/service/label.go | 84 +++++ internal/service/post.go | 54 ++-- internal/service/user.go | 6 +- pkg/constant/constant.go | 1 + web/src/api/client.ts | 8 +- web/src/api/post.ts | 30 ++ web/src/api/user.ts | 84 +++-- web/src/app/(main)/layout.tsx | 2 +- web/src/app/(main)/page.tsx | 281 +++++++++++------ web/src/app/globals.css | 74 ++--- web/src/app/layout.tsx | 3 +- web/src/components/Navbar.tsx | 4 +- web/src/components/blog-card.tsx | 289 ++++++++++++++++++ web/src/components/login-form.tsx | 9 +- web/src/components/ui/badge.tsx | 46 +++ web/src/config.ts | 7 + .../{DeviceContext.tsx => device-context.tsx} | 0 web/src/models/label.ts | 7 + web/src/models/post.ts | 17 ++ 30 files changed, 935 insertions(+), 288 deletions(-) create mode 100644 web/src/api/post.ts create mode 100644 web/src/components/blog-card.tsx create mode 100644 web/src/components/ui/badge.tsx rename web/src/contexts/{DeviceContext.tsx => device-context.tsx} (100%) create mode 100644 web/src/models/label.ts create mode 100644 web/src/models/post.ts diff --git a/internal/controller/v1/label.go b/internal/controller/v1/label.go index 757b9da..db97d7f 100644 --- a/internal/controller/v1/label.go +++ b/internal/controller/v1/label.go @@ -3,28 +3,105 @@ package v1 import ( "context" "github.com/cloudwego/hertz/pkg/app" + "github.com/cloudwego/hertz/pkg/common/utils" + "github.com/snowykami/neo-blog/internal/dto" + "github.com/snowykami/neo-blog/internal/service" + "github.com/snowykami/neo-blog/pkg/errs" + "github.com/snowykami/neo-blog/pkg/resps" + "strconv" ) -type labelType struct{} - -var Label = new(labelType) - -func (l *labelType) Create(ctx context.Context, c *app.RequestContext) { - // TODO: Impl +type LabelController struct { + service *service.LabelService } -func (l *labelType) Delete(ctx context.Context, c *app.RequestContext) { - // TODO: Impl +func NewLabelController() *LabelController { + return &LabelController{ + service: service.NewLabelService(), + } } -func (l *labelType) Get(ctx context.Context, c *app.RequestContext) { - // TODO: Impl +func (l *LabelController) Create(ctx context.Context, c *app.RequestContext) { + var req dto.LabelDto + if err := c.BindAndValidate(&req); err != nil { + resps.BadRequest(c, resps.ErrParamInvalid) + return + } + labelID, err := l.service.CreateLabel(&req) + if err != nil { + serviceErr := errs.AsServiceError(err) + resps.Custom(c, serviceErr.Code, serviceErr.Message, nil) + return + } + resps.Ok(c, resps.Success, utils.H{"id": labelID}) } -func (l *labelType) Update(ctx context.Context, c *app.RequestContext) { - // TODO: Impl +func (l *LabelController) Delete(ctx context.Context, c *app.RequestContext) { + id := c.Param("id") + if id == "" { + resps.BadRequest(c, resps.ErrParamInvalid) + return + } + err := l.service.DeleteLabel(id) + if err != nil { + serviceErr := errs.AsServiceError(err) + resps.Custom(c, serviceErr.Code, serviceErr.Message, nil) + return + } + resps.Ok(c, resps.Success, nil) } -func (l *labelType) List(ctx context.Context, c *app.RequestContext) { - // TODO: Impl +func (l *LabelController) Get(ctx context.Context, c *app.RequestContext) { + id := c.Param("id") + if id == "" { + resps.BadRequest(c, resps.ErrParamInvalid) + return + } + label, err := l.service.GetLabelByID(id) + if err != nil { + serviceErr := errs.AsServiceError(err) + resps.Custom(c, serviceErr.Code, serviceErr.Message, nil) + return + } + if label == nil { + resps.NotFound(c, resps.ErrNotFound) + return + } + resps.Ok(c, resps.Success, label) +} + +func (l *LabelController) Update(ctx context.Context, c *app.RequestContext) { + var req dto.LabelDto + if err := c.BindAndValidate(&req); err != nil { + resps.BadRequest(c, resps.ErrParamInvalid) + return + } + id := c.Param("id") + if id == "" { + resps.BadRequest(c, resps.ErrParamInvalid) + return + } + idInt, err := strconv.Atoi(id) + if err != nil { + resps.BadRequest(c, "Invalid label ID") + return + } + req.ID = uint(idInt) + labelID, err := l.service.UpdateLabel(&req) + if err != nil { + serviceErr := errs.AsServiceError(err) + resps.Custom(c, serviceErr.Code, serviceErr.Message, nil) + return + } + resps.Ok(c, resps.Success, utils.H{"id": labelID}) +} + +func (l *LabelController) List(ctx context.Context, c *app.RequestContext) { + labels, err := l.service.ListLabels() + if err != nil { + serviceErr := errs.AsServiceError(err) + resps.Custom(c, serviceErr.Code, serviceErr.Message, nil) + return + } + resps.Ok(c, resps.Success, labels) } diff --git a/internal/controller/v1/post.go b/internal/controller/v1/post.go index 4cfa156..32b764a 100644 --- a/internal/controller/v1/post.go +++ b/internal/controller/v1/post.go @@ -3,6 +3,7 @@ package v1 import ( "context" "github.com/cloudwego/hertz/pkg/app" + "github.com/cloudwego/hertz/pkg/common/utils" "github.com/snowykami/neo-blog/internal/ctxutils" "github.com/snowykami/neo-blog/internal/dto" "github.com/snowykami/neo-blog/internal/service" @@ -28,12 +29,13 @@ func (p *PostController) Create(ctx context.Context, c *app.RequestContext) { if err := c.BindAndValidate(&req); err != nil { resps.BadRequest(c, resps.ErrParamInvalid) } - if err := p.service.CreatePost(ctx, &req); err != nil { + postID, err := p.service.CreatePost(ctx, &req) + if err != nil { serviceErr := errs.AsServiceError(err) resps.Custom(c, serviceErr.Code, serviceErr.Message, nil) return } - resps.Ok(c, resps.Success, nil) + resps.Ok(c, resps.Success, utils.H{"id": postID}) } func (p *PostController) Delete(ctx context.Context, c *app.RequestContext) { @@ -80,16 +82,20 @@ func (p *PostController) Update(ctx context.Context, c *app.RequestContext) { resps.BadRequest(c, resps.ErrParamInvalid) return } - if err := p.service.UpdatePost(ctx, id, &req); err != nil { + postID, err := p.service.UpdatePost(ctx, id, &req) + if err != nil { serviceErr := errs.AsServiceError(err) resps.Custom(c, serviceErr.Code, serviceErr.Message, nil) return } - resps.Ok(c, resps.Success, nil) + resps.Ok(c, resps.Success, utils.H{"id": postID}) } func (p *PostController) List(ctx context.Context, c *app.RequestContext) { pagination := ctxutils.GetPaginationParams(c) + if pagination.OrderedBy == "" { + pagination.OrderedBy = constant.OrderedByUpdatedAt + } if pagination.OrderedBy != "" && !slices.Contains(constant.OrderedByEnumPost, pagination.OrderedBy) { resps.BadRequest(c, "无效的排序字段") return @@ -103,11 +109,11 @@ func (p *PostController) List(ctx context.Context, c *app.RequestContext) { OrderedBy: pagination.OrderedBy, Reverse: pagination.Reverse, } - resp, err := p.service.ListPosts(ctx, req) + posts, err := p.service.ListPosts(ctx, req) if err != nil { serviceErr := errs.AsServiceError(err) resps.Custom(c, serviceErr.Code, serviceErr.Message, nil) return } - resps.Ok(c, resps.Success, resp) + resps.Ok(c, resps.Success, posts) } diff --git a/internal/controller/v1/user.go b/internal/controller/v1/user.go index 2f01196..1443be5 100644 --- a/internal/controller/v1/user.go +++ b/internal/controller/v1/user.go @@ -70,15 +70,13 @@ func (u *UserController) Logout(ctx context.Context, c *app.RequestContext) { } func (u *UserController) OidcList(ctx context.Context, c *app.RequestContext) { - resp, err := u.service.ListOidcConfigs() + oidcConfigs, err := u.service.ListOidcConfigs() if err != nil { serviceErr := errs.AsServiceError(err) resps.Custom(c, serviceErr.Code, serviceErr.Message, nil) return } - resps.Ok(c, resps.Success, map[string]any{ - "oidc_configs": resp.OidcConfigs, - }) + resps.Ok(c, resps.Success, oidcConfigs) } func (u *UserController) OidcLogin(ctx context.Context, c *app.RequestContext) { @@ -109,7 +107,12 @@ func (u *UserController) GetUser(ctx context.Context, c *app.RequestContext) { userID := c.Param("id") userIDInt, err := strconv.Atoi(userID) if err != nil || userIDInt <= 0 { - userIDInt = int(ctxutils.GetCurrentUserID(ctx)) + currentUserID, ok := ctxutils.GetCurrentUserID(ctx) + if !ok { + resps.Unauthorized(c, resps.ErrUnauthorized) + return + } + userIDInt = int(currentUserID) } resp, err := u.service.GetUser(&dto.GetUserReq{UserID: uint(userIDInt)}) @@ -138,8 +141,8 @@ func (u *UserController) UpdateUser(ctx context.Context, c *app.RequestContext) return } updateUserReq.ID = uint(userIDInt) - currentUser := ctxutils.GetCurrentUser(ctx) - if currentUser == nil { + currentUser, ok := ctxutils.GetCurrentUser(ctx) + if !ok { resps.Unauthorized(c, resps.ErrUnauthorized) return } diff --git a/internal/ctxutils/user.go b/internal/ctxutils/user.go index 1c5ed10..4236f58 100644 --- a/internal/ctxutils/user.go +++ b/internal/ctxutils/user.go @@ -4,25 +4,29 @@ import ( "context" "github.com/snowykami/neo-blog/internal/model" "github.com/snowykami/neo-blog/internal/repo" + "github.com/snowykami/neo-blog/pkg/constant" ) -func GetCurrentUser(ctx context.Context) *model.User { - userIDValue := ctx.Value("user_id").(uint) - if userIDValue <= 0 { - return nil +// GetCurrentUser 从上下文中获取当前用户 +func GetCurrentUser(ctx context.Context) (*model.User, bool) { + val := ctx.Value(constant.ContextKeyUserID) + if val == nil { + return nil, false } - user, err := repo.User.GetUserByID(userIDValue) - if err != nil || user == nil || user.ID == 0 { - return nil + user, err := repo.User.GetUserByID(val.(uint)) + if err != nil { + return nil, false } - return user + + return user, true } -// GetCurrentUserID 获取当前用户ID,如果未认证则返回0 -func GetCurrentUserID(ctx context.Context) uint { - user := GetCurrentUser(ctx) - if user == nil { - return 0 +// GetCurrentUserID 从上下文中获取当前用户ID +func GetCurrentUserID(ctx context.Context) (uint, bool) { + user, ok := GetCurrentUser(ctx) + if !ok || user == nil { + return 0, false } - return user.ID + + return user.ID, true } diff --git a/internal/dto/label.go b/internal/dto/label.go index 8679657..af291be 100644 --- a/internal/dto/label.go +++ b/internal/dto/label.go @@ -1,6 +1,7 @@ package dto type LabelDto struct { + ID uint `json:"id"` // 标签ID Key string `json:"key"` Value string `json:"value"` Color string `json:"color"` diff --git a/internal/dto/post.go b/internal/dto/post.go index 1d486ae..6fba66b 100644 --- a/internal/dto/post.go +++ b/internal/dto/post.go @@ -1,22 +1,30 @@ package dto +import "time" + type PostDto struct { ID uint `json:"id"` // 帖子ID UserID uint `json:"user_id"` // 发布者的用户ID Title string `json:"title"` // 帖子标题 Content string `json:"content"` + Cover string `json:"cover"` // 帖子封面图 + Type string `json:"type"` // 帖子类型 markdown / html / text Labels []LabelDto `json:"labels"` // 关联的标签 IsPrivate bool `json:"is_private"` // 是否为私密帖子 LikeCount uint64 `json:"like_count"` // 点赞数 CommentCount uint64 `json:"comment_count"` // 评论数 ViewCount uint64 `json:"view_count"` // 浏览数 Heat uint64 `json:"heat"` // 热度 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` // 更新时间 } type CreateOrUpdatePostReq struct { Title string `json:"title"` Content string `json:"content"` + Cover string `json:"cover"` IsPrivate bool `json:"is_private"` + Type string `json:"type"` Labels []uint `json:"labels"` // 标签ID列表 } diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index ddfd32e..e10bc2c 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -20,7 +20,7 @@ func UseAuth(block bool) app.HandlerFunc { // 尝试用普通 token 认证 tokenClaims, err := utils.Jwt.ParseJsonWebTokenWithoutState(token) if err == nil && tokenClaims != nil { - ctx = context.WithValue(ctx, "user_id", tokenClaims.UserID) + ctx = context.WithValue(ctx, constant.ContextKeyUserID, tokenClaims.UserID) c.Next(ctx) return } @@ -30,8 +30,7 @@ func UseAuth(block bool) app.HandlerFunc { if err == nil && refreshTokenClaims != nil { ok, err := isStatefulJwtValid(refreshTokenClaims) if err == nil && ok { - ctx = context.WithValue(ctx, "user_id", refreshTokenClaims.UserID) - + ctx = context.WithValue(ctx, constant.ContextKeyUserID, refreshTokenClaims.UserID) // 生成新 token newTokenClaims := utils.Jwt.NewClaims( refreshTokenClaims.UserID, diff --git a/internal/model/label.go b/internal/model/label.go index 15c0c2d..5ba36f0 100644 --- a/internal/model/label.go +++ b/internal/model/label.go @@ -7,7 +7,7 @@ import ( type Label struct { gorm.Model - Key string `gorm:"uniqueIndex"` // 标签键,唯一标识 + Key string `gorm:"index"` // 标签键,唯一标识 Value string `gorm:"type:text"` // 标签值,描述标签的内容 Color string `gorm:"type:text"` // 前端可用颜色代码 TailwindClassName string `gorm:"type:text"` // Tailwind CSS 的类名,用于前端样式 diff --git a/internal/model/post.go b/internal/model/post.go index 0f5bc8c..bfa9c52 100644 --- a/internal/model/post.go +++ b/internal/model/post.go @@ -11,7 +11,9 @@ type Post struct { UserID uint `gorm:"index"` // 发布者的用户ID User User `gorm:"foreignKey:UserID;references:ID"` // 关联的用户 Title string `gorm:"type:text;not null"` // 帖子标题 + Cover string `gorm:"type:text"` // 帖子封面图 Content string `gorm:"type:text;not null"` // 帖子内容 + Type string `gorm:"type:text;default:markdown"` // markdown类型,支持markdown或html CategoryID uint `gorm:"index"` // 帖子分类ID Category Category `gorm:"foreignKey:CategoryID;references:ID"` // 关联的分类 Labels []Label `gorm:"many2many:post_labels;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` // 关联的标签 @@ -48,10 +50,14 @@ func (p *Post) ToDto() dto.PostDto { UserID: p.UserID, Title: p.Title, Content: p.Content, + Cover: p.Cover, + Type: p.Type, IsPrivate: p.IsPrivate, LikeCount: p.LikeCount, CommentCount: p.CommentCount, ViewCount: p.ViewCount, Heat: p.Heat, + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, } } diff --git a/internal/repo/label.go b/internal/repo/label.go index 9c57dbc..5a6aaae 100644 --- a/internal/repo/label.go +++ b/internal/repo/label.go @@ -6,13 +6,7 @@ type labelRepo struct{} var Label = &labelRepo{} -func (l *labelRepo) CreateLabel(key, value, color, tailwindClassName string) error { - label := &model.Label{ - Key: key, - Value: value, - Color: color, - TailwindClassName: tailwindClassName, - } +func (l *labelRepo) CreateLabel(label *model.Label) error { return GetDB().Create(label).Error } @@ -24,7 +18,7 @@ func (l *labelRepo) GetLabelByKey(key string) (*model.Label, error) { return &label, nil } -func (l *labelRepo) GetLabelByID(id uint) (*model.Label, error) { +func (l *labelRepo) GetLabelByID(id string) (*model.Label, error) { var label model.Label if err := GetDB().Where("id = ?", id).First(&label).Error; err != nil { return nil, err @@ -47,7 +41,7 @@ func (l *labelRepo) UpdateLabel(label *model.Label) error { return nil } -func (l *labelRepo) DeleteLabel(id uint) error { +func (l *labelRepo) DeleteLabel(id string) error { if err := GetDB().Where("id = ?", id).Delete(&model.Label{}).Error; err != nil { return err } diff --git a/internal/router/apiv1/label.go b/internal/router/apiv1/label.go index 18138e3..2082007 100644 --- a/internal/router/apiv1/label.go +++ b/internal/router/apiv1/label.go @@ -7,14 +7,15 @@ import ( ) func registerLabelRoutes(group *route.RouterGroup) { + labelController := v1.NewLabelController() labelGroup := group.Group("/label").Use(middleware.UseAuth(true)) labelGroupWithoutAuth := group.Group("/label").Use(middleware.UseAuth(false)) { - labelGroupWithoutAuth.GET("/l/:id", v1.Label.Get) - labelGroupWithoutAuth.GET("/list", v1.Label.List) + labelGroupWithoutAuth.GET("/l/:id", labelController.Get) + labelGroupWithoutAuth.GET("/list", labelController.List) - labelGroup.POST("/l", v1.Label.Create) - labelGroup.DELETE("/l/:id", v1.Label.Delete) - labelGroup.PUT("/l/:id", v1.Label.Update) + labelGroup.POST("/l", labelController.Create) + labelGroup.DELETE("/l/:id", labelController.Delete) + labelGroup.PUT("/l/:id", labelController.Update) } } diff --git a/internal/service/label.go b/internal/service/label.go index 6d43c33..3821efa 100644 --- a/internal/service/label.go +++ b/internal/service/label.go @@ -1 +1,85 @@ package service + +import ( + "github.com/snowykami/neo-blog/internal/dto" + "github.com/snowykami/neo-blog/internal/model" + "github.com/snowykami/neo-blog/internal/repo" + "gorm.io/gorm" +) + +type LabelService struct{} + +func NewLabelService() *LabelService { + return &LabelService{} +} + +func (l *LabelService) CreateLabel(req *dto.LabelDto) (uint, error) { + label := &model.Label{ + Key: req.Key, + Value: req.Value, + Color: req.Color, + TailwindClassName: req.TailwindClassName, + } + return label.ID, repo.Label.CreateLabel(label) +} + +func (l *LabelService) UpdateLabel(req *dto.LabelDto) (uint, error) { + label := &model.Label{ + Model: gorm.Model{ID: req.ID}, + Key: req.Key, + Value: req.Value, + Color: req.Color, + TailwindClassName: req.TailwindClassName, + } + return label.ID, repo.Label.UpdateLabel(label) +} + +func (l *LabelService) DeleteLabel(id string) error { + return repo.Label.DeleteLabel(id) +} + +func (l *LabelService) GetLabelByKey(key string) (*dto.LabelDto, error) { + label, err := repo.Label.GetLabelByKey(key) + if err != nil { + return nil, err + } + return &dto.LabelDto{ + ID: label.ID, + Key: label.Key, + Value: label.Value, + Color: label.Color, + TailwindClassName: label.TailwindClassName, + }, nil +} + +func (l *LabelService) GetLabelByID(id string) (*dto.LabelDto, error) { + label, err := repo.Label.GetLabelByID(id) + if err != nil { + return nil, err + } + return &dto.LabelDto{ + ID: label.ID, + Key: label.Key, + Value: label.Value, + Color: label.Color, + TailwindClassName: label.TailwindClassName, + }, nil +} + +func (l *LabelService) ListLabels() ([]dto.LabelDto, error) { + labels, err := repo.Label.ListLabels() + var labelDtos []dto.LabelDto + if err != nil { + return labelDtos, err + } + for _, label := range labels { + labelDtos = append(labelDtos, dto.LabelDto{ + ID: label.ID, + Key: label.Key, + Value: label.Value, + Color: label.Color, + TailwindClassName: label.TailwindClassName, + }) + } + return labelDtos, nil +} diff --git a/internal/service/post.go b/internal/service/post.go index 9c9fa54..8b2b176 100644 --- a/internal/service/post.go +++ b/internal/service/post.go @@ -7,6 +7,7 @@ import ( "github.com/snowykami/neo-blog/internal/model" "github.com/snowykami/neo-blog/internal/repo" "github.com/snowykami/neo-blog/pkg/errs" + "strconv" ) type PostService struct{} @@ -15,10 +16,10 @@ func NewPostService() *PostService { return &PostService{} } -func (p *PostService) CreatePost(ctx context.Context, req *dto.CreateOrUpdatePostReq) error { - currentUser := ctxutils.GetCurrentUser(ctx) - if currentUser == nil { - return errs.ErrUnauthorized +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, @@ -27,7 +28,7 @@ func (p *PostService) CreatePost(ctx context.Context, req *dto.CreateOrUpdatePos Labels: func() []model.Label { labelModels := make([]model.Label, 0) for _, labelID := range req.Labels { - labelModel, err := repo.Label.GetLabelByID(labelID) + labelModel, err := repo.Label.GetLabelByID(strconv.Itoa(int(labelID))) if err == nil { labelModels = append(labelModels, *labelModel) } @@ -37,14 +38,14 @@ func (p *PostService) CreatePost(ctx context.Context, req *dto.CreateOrUpdatePos IsPrivate: req.IsPrivate, } if err := repo.Post.CreatePost(post); err != nil { - return err + return 0, err } - return nil + return post.ID, nil } func (p *PostService) DeletePost(ctx context.Context, id string) error { - currentUser := ctxutils.GetCurrentUser(ctx) - if currentUser == nil { + currentUser, ok := ctxutils.GetCurrentUser(ctx) + if !ok { return errs.ErrUnauthorized } if id == "" { @@ -64,8 +65,8 @@ func (p *PostService) DeletePost(ctx context.Context, id string) error { } func (p *PostService) GetPost(ctx context.Context, id string) (*dto.PostDto, error) { - currentUser := ctxutils.GetCurrentUser(ctx) - if currentUser == nil { + currentUser, ok := ctxutils.GetCurrentUser(ctx) + if !ok { return nil, errs.ErrUnauthorized } if id == "" { @@ -92,20 +93,20 @@ func (p *PostService) GetPost(ctx context.Context, id string) (*dto.PostDto, err }, nil } -func (p *PostService) UpdatePost(ctx context.Context, id string, req *dto.CreateOrUpdatePostReq) error { - currentUser := ctxutils.GetCurrentUser(ctx) - if currentUser == nil { - return errs.ErrUnauthorized +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 errs.ErrBadRequest + return 0, errs.ErrBadRequest } post, err := repo.Post.GetPostByID(id) if err != nil { - return errs.New(errs.ErrNotFound.Code, "post not found", err) + return 0, errs.New(errs.ErrNotFound.Code, "post not found", err) } if post.UserID != currentUser.ID { - return errs.ErrForbidden + return 0, errs.ErrForbidden } post.Title = req.Title post.Content = req.Content @@ -113,7 +114,7 @@ func (p *PostService) UpdatePost(ctx context.Context, id string, req *dto.Create post.Labels = func() []model.Label { labelModels := make([]model.Label, len(req.Labels)) for _, labelID := range req.Labels { - labelModel, err := repo.Label.GetLabelByID(labelID) + labelModel, err := repo.Label.GetLabelByID(strconv.Itoa(int(labelID))) if err == nil { labelModels = append(labelModels, *labelModel) } @@ -121,14 +122,14 @@ func (p *PostService) UpdatePost(ctx context.Context, id string, req *dto.Create return labelModels }() if err := repo.Post.UpdatePost(post); err != nil { - return errs.ErrInternalServer + return 0, errs.ErrInternalServer } - return nil + return post.ID, nil } -func (p *PostService) ListPosts(ctx context.Context, req *dto.ListPostReq) (*dto.ListPostResp, error) { +func (p *PostService) ListPosts(ctx context.Context, req *dto.ListPostReq) ([]dto.PostDto, error) { postDtos := make([]dto.PostDto, 0) - currentUserID := ctxutils.GetCurrentUserID(ctx) + currentUserID, _ := ctxutils.GetCurrentUserID(ctx) posts, err := repo.Post.ListPosts(currentUserID, req.Keywords, req.Page, req.Size, req.OrderedBy, req.Reverse) if err != nil { return nil, errs.New(errs.ErrInternalServer.Code, "failed to list posts", err) @@ -136,10 +137,5 @@ func (p *PostService) ListPosts(ctx context.Context, req *dto.ListPostReq) (*dto for _, post := range posts { postDtos = append(postDtos, post.ToDto()) } - return &dto.ListPostResp{ - Posts: postDtos, - Total: uint64(len(posts)), - OrderedBy: req.OrderedBy, - Reverse: req.Reverse, - }, nil + return postDtos, nil } diff --git a/internal/service/user.go b/internal/service/user.go index c06262c..ebfbf3f 100644 --- a/internal/service/user.go +++ b/internal/service/user.go @@ -140,7 +140,7 @@ func (s *UserService) RequestVerifyEmail(req *dto.VerifyEmailReq) (*dto.VerifyEm return &dto.VerifyEmailResp{Success: true}, nil } -func (s *UserService) ListOidcConfigs() (*dto.ListOidcConfigResp, error) { +func (s *UserService) ListOidcConfigs() ([]dto.UserOidcConfigDto, error) { enabledOidcConfigs, err := repo.Oidc.ListOidcConfigs(true) if err != nil { return nil, errs.ErrInternalServer @@ -187,9 +187,7 @@ func (s *UserService) ListOidcConfigs() (*dto.ListOidcConfigResp, error) { LoginUrl: loginUrl, }) } - return &dto.ListOidcConfigResp{ - OidcConfigs: oidcConfigsDtos, - }, nil + return oidcConfigsDtos, nil } func (s *UserService) OidcLogin(req *dto.OidcLoginReq) (*dto.OidcLoginResp, error) { diff --git a/pkg/constant/constant.go b/pkg/constant/constant.go index 0c44645..635bd94 100644 --- a/pkg/constant/constant.go +++ b/pkg/constant/constant.go @@ -5,6 +5,7 @@ const ( CaptchaTypeHCaptcha = "hcaptcha" // HCaptcha验证码 CaptchaTypeTurnstile = "turnstile" // Turnstile验证码 CaptchaTypeReCaptcha = "recaptcha" // ReCaptcha验证码 + ContextKeyUserID = "user_id" // 上下文键:用户ID ModeDev = "dev" ModeProd = "prod" RoleUser = "user" diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 24db3ef..2dec8c7 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -3,12 +3,12 @@ import { camelToSnakeObj, snakeToCamelObj } from "field-conv"; const API_SUFFIX = "/api/v1"; -const axiosInstance = axios.create({ +const axiosClient = axios.create({ baseURL: API_SUFFIX, timeout: 10000, }); -axiosInstance.interceptors.request.use((config) => { +axiosClient.interceptors.request.use((config) => { if (config.data && typeof config.data === "object") { config.data = camelToSnakeObj(config.data); } @@ -18,7 +18,7 @@ axiosInstance.interceptors.request.use((config) => { return config; }); -axiosInstance.interceptors.response.use( +axiosClient.interceptors.response.use( (response) => { if (response.data && typeof response.data === "object") { response.data = snakeToCamelObj(response.data); @@ -28,4 +28,4 @@ axiosInstance.interceptors.response.use( (error) => Promise.reject(error), ); -export default axiosInstance; +export default axiosClient; diff --git a/web/src/api/post.ts b/web/src/api/post.ts new file mode 100644 index 0000000..794cc19 --- /dev/null +++ b/web/src/api/post.ts @@ -0,0 +1,30 @@ +import type { BaseResponse } from "@/models/resp"; +import type { Post } from "@/models/post"; +import axiosClient from "./client"; + +interface ListPostsParams { + page?: number; + size?: number; + orderedBy?: string; + reverse?: boolean; + keywords?: string; +} + +export async function listPosts({ + page = 1, + size = 10, + orderedBy = 'updated_at', + reverse = false, + keywords = '' +}: ListPostsParams = {}): Promise> { + const res = await axiosClient.get>("/post/list", { + params: { + page, + size, + orderedBy, + reverse, + keywords + } + }); + return res.data; +} \ No newline at end of file diff --git a/web/src/api/user.ts b/web/src/api/user.ts index 1f1063e..bb451a5 100644 --- a/web/src/api/user.ts +++ b/web/src/api/user.ts @@ -1,56 +1,46 @@ -import axiosInstance from "./client"; - +import axiosClient from "./client"; import type { OidcConfig } from "@/models/oidc-config"; import type { User } from "@/models/user"; import type { BaseResponse } from "@/models/resp"; -export function userLogin( - username: string, - password: string -): Promise> { - return axiosInstance - .post>( - "/user/login", - { - username, - password, - } - ) - .then(res => res.data); +export interface LoginRequest { + username: string; + password: string; + rememberMe?: boolean; // 可以轻松添加新字段 + captcha?: string; } -export function userRegister( - username: string, - password: string, - nickname: string, - email: string, - verificationCode?: string -): Promise> { - return axiosInstance - .post>( - "/user/register", - { - username, - password, - nickname, - email, - verificationCode, - } - ) - .then(res => res.data); +export interface RegisterRequest { + username: string; + password: string; + nickname: string; + email: string; + verificationCode?: string; } -export function ListOidcConfigs(): Promise> { - return axiosInstance - .get>("/user/oidc/list") - .then(res => { - const data = res.data; - if ('configs' in data) { - return { - ...data, - oidcConfigs: data.configs, - }; - } - return data; - }); +export async function userLogin( + data: LoginRequest +): Promise> { + const res = await axiosClient.post>( + "/user/login", + data + ); + return res.data; +} + +export async function userRegister( + data: RegisterRequest +): Promise> { + const res = await axiosClient.post>( + "/user/register", + data + ); + return res.data; +} + +export async function ListOidcConfigs(): Promise> { + const res = await axiosClient.get>( + "/user/oidc/list" + ); + return res.data; } \ No newline at end of file diff --git a/web/src/app/(main)/layout.tsx b/web/src/app/(main)/layout.tsx index cf28d24..ee7da7e 100644 --- a/web/src/app/(main)/layout.tsx +++ b/web/src/app/(main)/layout.tsx @@ -1,4 +1,4 @@ -import { Navbar } from "@/components/Navbar"; +import { Navbar } from "@/components/navbar"; export default function RootLayout({ children, diff --git a/web/src/app/(main)/page.tsx b/web/src/app/(main)/page.tsx index b4caef1..7c45bf7 100644 --- a/web/src/app/(main)/page.tsx +++ b/web/src/app/(main)/page.tsx @@ -1,104 +1,189 @@ -import { NavigationMenu } from "@/components/ui/navigation-menu"; -import Image from "next/image"; +"use client"; + +import { BlogCardGrid } from "@/components/blog-card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Search, TrendingUp, Clock, Heart, Eye } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import config from '../../config'; +import type { Label } from "@/models/label"; +import type { Post } from "@/models/post"; +import { listPosts } from "@/api/post"; + +import { useEffect, useState } from "react"; + export default function Home() { - return ( -
-
- Next.js logo -
    -
  1. - Get started by editing{" "} - - src/app/page.tsx - - . -
  2. -
  3. - Save and see your changes instantly. -
  4. -
+ const [labels, setLabels] = useState([]); + const [posts, setPosts] = useState([]); -
- - Vercel logomark - Deploy now - - - Read our docs - + useEffect(() => { + listPosts().then(data => { + setPosts(data.data); + console.log(posts); + }).catch(error => { + console.error("Failed to fetch posts:", error); + }); + }, []); + + + return ( +
+ {/* Hero Section */} +
+ {/* 背景装饰 */} +
+ + {/* 容器 - 关键布局 */} +
+
+

+ Snowykami's Blog +

+

+ {config.metadata.description} +

+ + {/* 搜索框 */} +
+ + +
+ + {/* 热门标签 */} +
+ {['React', 'TypeScript', 'Next.js', 'Node.js', 'AI', '前端开发'].map((tag) => ( + + {tag} + + ))} +
-
- +
+ + + {/* 主内容区域 */} +
+ {/* 容器 - 关键布局 */} +
+
+ {/* 主要内容区域 */} +
+ {/* 文章列表标题 */} +
+

最新文章

+
+ + +
+
+ + {/* 博客卡片网格 */} + + + {/* 加载更多按钮 */} +
+ +
+
+ + {/* 侧边栏 */} +
+ {/* 关于我 */} + + + + + 关于我 + + + +
+
+ S +
+

{config.owner.name}

+

{config.owner.motto}

+
+

+ {config.owner.description} +

+
+
+ + {/* 热门文章 */} + + + + + 热门文章 + + + + {posts.slice(0, 3).map((post, index) => ( +
+ + {index + 1} + +
+

+ {post.title} +

+
+ + + {post.viewCount} + + + + {post.likeCount} + +
+
+
+ ))} +
+
+ + {/* 标签云 */} + + + 标签云 + + +
+ {['React', 'TypeScript', 'Next.js', 'Node.js', 'JavaScript', 'CSS', 'HTML', 'Vue', 'Angular', 'Webpack'].map((tag) => ( + + {tag} + + ))} +
+
+
+
+
+
+
); -} +} \ No newline at end of file diff --git a/web/src/app/globals.css b/web/src/app/globals.css index dc98be7..5a79152 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -44,72 +44,72 @@ } :root { - --radius: 0.625rem; + --radius: 0.65rem; --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); + --foreground: oklch(0.141 0.005 285.823); --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); + --card-foreground: oklch(0.141 0.005 285.823); --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.623 0.214 259.815); + --primary-foreground: oklch(0.97 0.014 254.604); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.623 0.214 259.815); --chart-1: oklch(0.646 0.222 41.116); --chart-2: oklch(0.6 0.118 184.704); --chart-3: oklch(0.398 0.07 227.392); --chart-4: oklch(0.828 0.189 84.429); --chart-5: oklch(0.769 0.188 70.08); --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.623 0.214 259.815); + --sidebar-primary-foreground: oklch(0.97 0.014 254.604); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.623 0.214 259.815); } .dark { - --background: oklch(0.145 0 0); + --background: oklch(0.141 0.005 285.823); --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); + --card: oklch(0.21 0.006 285.885); --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); + --popover: oklch(0.21 0.006 285.885); --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); + --primary: oklch(0.546 0.245 262.881); + --primary-foreground: oklch(0.379 0.146 265.522); + --secondary: oklch(0.274 0.006 286.033); --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); + --ring: oklch(0.488 0.243 264.376); --chart-1: oklch(0.488 0.243 264.376); --chart-2: oklch(0.696 0.17 162.48); --chart-3: oklch(0.769 0.188 70.08); --chart-4: oklch(0.627 0.265 303.9); --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.205 0 0); + --sidebar: oklch(0.21 0.006 285.885); --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); + --sidebar-primary: oklch(0.546 0.245 262.881); + --sidebar-primary-foreground: oklch(0.379 0.146 265.522); + --sidebar-accent: oklch(0.274 0.006 286.033); --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); + --sidebar-ring: oklch(0.488 0.243 264.376); } @layer base { diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 29b62fa..a78d2e7 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,8 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; -import { Navbar } from "@/components/Navbar"; -import { DeviceProvider } from "@/contexts/DeviceContext"; +import { DeviceProvider } from "@/contexts/device-context"; const geistSans = Geist({ variable: "--font-geist-sans", diff --git a/web/src/components/Navbar.tsx b/web/src/components/Navbar.tsx index b4f5f12..08c5479 100644 --- a/web/src/components/Navbar.tsx +++ b/web/src/components/Navbar.tsx @@ -12,8 +12,8 @@ import { NavigationMenuTrigger, navigationMenuTriggerStyle, } from "@/components/ui/navigation-menu" -import GravatarAvatar from "./Gravatar" -import { useDevice } from "@/contexts/DeviceContext" +import GravatarAvatar from "@/components/gravatar" +import { useDevice } from "@/contexts/device-context" const components: { title: string; href: string }[] = [ { diff --git a/web/src/components/blog-card.tsx b/web/src/components/blog-card.tsx new file mode 100644 index 0000000..94a6649 --- /dev/null +++ b/web/src/components/blog-card.tsx @@ -0,0 +1,289 @@ +import { Post } from "@/models/post"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import Link from "next/link"; +import Image from "next/image"; +import { Calendar, Clock, Eye, Heart, MessageCircle, Lock } from "lucide-react"; +import { cn } from "@/lib/utils"; +import config from "@/config"; + +interface BlogCardProps { + post: Post; + className?: string; +} + +export function BlogCard({ post, className }: BlogCardProps) { + // 格式化日期 + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit' + }); + }; + + // 计算阅读时间(估算) + const getReadingTime = (content: string) => { + const wordsPerMinute = 200; + const wordCount = content.length; + const minutes = Math.ceil(wordCount / wordsPerMinute); + return `${minutes} 分钟阅读`; + }; + + // 根据内容类型获取图标 + const getContentTypeIcon = (type: Post['type']) => { + switch (type) { + case 'markdown': + return '📝'; + case 'html': + return '🌐'; + case 'text': + return '📄'; + default: + return '📝'; + } + }; + + return ( + + {/* 封面图片区域 */} +
+ {/* 自定义封面图片 */} + {(post.cover || config.defaultCover) ? ( + {post.title} + ) : ( + // 默认渐变背景 - 基于热度生成颜色 +
80 ? "from-red-400 via-pink-500 to-orange-500" : + post.heat > 60 ? "from-orange-400 via-yellow-500 to-red-500" : + post.heat > 40 ? "from-blue-400 via-purple-500 to-pink-500" : + post.heat > 20 ? "from-green-400 via-blue-500 to-purple-500" : + "from-gray-400 via-slate-500 to-gray-600" + )} + /> + )} + + {/* 覆盖层 */} +
+ + {/* 私有文章标识 */} + {post.isPrivate && ( + + + 私有 + + )} + + {/* 内容类型标签 */} + + {getContentTypeIcon(post.type)} {post.type.toUpperCase()} + + + {/* 热度指示器 */} + {post.heat > 50 && ( +
+ + 🔥 {post.heat} + +
+ )} +
+ + {/* Card Header - 标题区域 */} + + + {post.title} + + + {post.content.replace(/[#*`]/g, '').substring(0, 150)} + {post.content.length > 150 ? '...' : ''} + + + + {/* Card Content - 主要内容 */} + + {/* 标签列表 */} + {post.labels && post.labels.length > 0 && ( +
+ {post.labels.slice(0, 3).map((label) => ( + + {label.key} + + ))} + {post.labels.length > 3 && ( + + +{post.labels.length - 3} + + )} +
+ )} + + {/* 统计信息 */} +
+
+ {/* 点赞数 */} +
+ + {post.likeCount} +
+ + {/* 评论数 */} +
+ + {post.commentCount} +
+
+ +
+ {/* 阅读量 */} +
+ + {post.viewCount} +
+ + {/* 阅读时间 */} +
+ + {getReadingTime(post.content)} +
+
+
+
+ + {/* Card Footer - 日期和操作区域 */} + + {/* 创建日期 */} +
+ + +
+ + {/* 更新日期(如果与创建日期不同)或阅读提示 */} + {post.updatedAt !== post.createdAt ? ( +
+ 更新于 {formatDate(post.updatedAt)} +
+ ) : ( +
+ 阅读更多 → +
+ )} +
+ + ); +} + +// 骨架屏加载组件 - 使用 shadcn Card 结构 +export function BlogCardSkeleton() { + return ( + + {/* 封面图片骨架 */} +
+ + {/* Header 骨架 */} + +
+
+
+
+
+
+ + + {/* Content 骨架 */} + +
+
+
+
+
+
+
+
+
+ + + {/* Footer 骨架 */} + +
+
+ + + ); +} + +// 网格布局的博客卡片列表 +export function BlogCardGrid({ + posts, + isLoading, + showPrivate = false +}: { + posts: Post[]; + isLoading?: boolean; + showPrivate?: boolean; +}) { + const filteredPosts = showPrivate ? posts : posts.filter(post => !post.isPrivate); + + if (isLoading) { + return ( +
+ {Array.from({ length: 6 }).map((_, index) => ( + + ))} +
+ ); + } + + if (filteredPosts.length === 0) { + return ( + + +

暂无文章

+

+ {showPrivate ? '没有找到任何文章' : '没有找到公开的文章'} +

+
+
+ ); + } + + return ( +
+ {filteredPosts.map((post) => ( + + + + ))} +
+ ); +} \ No newline at end of file diff --git a/web/src/components/login-form.tsx b/web/src/components/login-form.tsx index e52774b..710698b 100644 --- a/web/src/components/login-form.tsx +++ b/web/src/components/login-form.tsx @@ -15,7 +15,7 @@ import Image from "next/image" import { useEffect, useState } from "react" import type { OidcConfig } from "@/models/oidc-config" import { ListOidcConfigs, userLogin } from "@/api/user" -import Link from "next/link" // 使用 Next.js 的 Link 而不是 lucide 的 Link +import Link from "next/link" import { useRouter, useSearchParams } from "next/navigation" export function LoginForm({ @@ -32,8 +32,7 @@ export function LoginForm({ useEffect(() => { ListOidcConfigs() .then((res) => { - setOidcConfigs(res.data.oidcConfigs || []) // 确保是数组 - console.log("OIDC configs fetched:", res.data.oidcConfigs) + setOidcConfigs(res.data || []) // 确保是数组 }) .catch((error) => { console.error("Error fetching OIDC configs:", error) @@ -44,7 +43,7 @@ export function LoginForm({ const handleLogin = async (e: React.FormEvent) => { e.preventDefault() try { - const res = await userLogin(username, password) + const res = await userLogin({username, password}) console.log("Login successful:", res) router.push(redirectBack) } catch (error) { @@ -58,7 +57,7 @@ export function LoginForm({ Welcome back - Login with Open ID Connect or your email and password. + Login with Open ID Connect. diff --git a/web/src/components/ui/badge.tsx b/web/src/components/ui/badge.tsx new file mode 100644 index 0000000..0205413 --- /dev/null +++ b/web/src/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/web/src/config.ts b/web/src/config.ts index 263acf3..4414728 100644 --- a/web/src/config.ts +++ b/web/src/config.ts @@ -2,6 +2,13 @@ const config = { metadata: { name: "Snowykami's Blog", icon: "https://cdn.liteyuki.org/snowykami/avatar.jpg", + description: "分享一些好玩的东西" + }, + defaultCover: "https://cdn.liteyuki.org/blog/background.png", + owner: { + name: "Snowykami", + description: "全栈开发工程师,喜欢分享技术心得和生活感悟。", + motto: "And now that story unfolds into a journey that, alone, I set out to" } } diff --git a/web/src/contexts/DeviceContext.tsx b/web/src/contexts/device-context.tsx similarity index 100% rename from web/src/contexts/DeviceContext.tsx rename to web/src/contexts/device-context.tsx diff --git a/web/src/models/label.ts b/web/src/models/label.ts new file mode 100644 index 0000000..7aa5fe8 --- /dev/null +++ b/web/src/models/label.ts @@ -0,0 +1,7 @@ +export interface Label { + id: number; + key: string; + value: string; + color: string; + className?: string; +} \ No newline at end of file diff --git a/web/src/models/post.ts b/web/src/models/post.ts new file mode 100644 index 0000000..7c26734 --- /dev/null +++ b/web/src/models/post.ts @@ -0,0 +1,17 @@ +import type { Label } from "@/models/label"; + +export interface Post { + id: number; + title: string; + content: string; + cover: string | null; // 封面可以为空 + type: "markdown" | "html" | "text"; + labels: Label[] | null; // 标签可以为空 + isPrivate: boolean; + likeCount: number; + commentCount: number; + viewCount: number; + heat: number; + createdAt: string; + updatedAt: string; +} \ No newline at end of file