feat: Refactor comment section to correctly handle API response structure
All checks were successful
Push to Helm Chart Repository / build (push) Successful in 9s

fix: Update Gravatar URL size and improve avatar rendering logic

style: Adjust footer margin for better layout consistency

refactor: Remove old navbar component and integrate new layout structure

feat: Enhance user profile page with user header component

chore: Remove unused user profile component

fix: Update posts per page configuration for better pagination

feat: Extend device context to support system theme mode

refactor: Remove unused device hook

fix: Improve storage state hook for better error handling

i18n: Add new translations for blog home page

feat: Implement pagination component for better navigation

feat: Create theme toggle component for improved user experience

feat: Introduce responsive navbar or side layout with theme toggle

feat: Develop custom select component for better UI consistency

feat: Create user header component to display user information

chore: Add query key constants for better code maintainability
This commit is contained in:
2025-09-12 00:26:08 +08:00
parent b3e8a5ef77
commit d1d8aa529f
36 changed files with 1443 additions and 731 deletions

View File

@ -128,13 +128,13 @@ func (cc *CommentController) GetCommentList(ctx context.Context, c *app.RequestC
TargetType: c.Query("target_type"),
CommentID: commentID,
}
resp, err := cc.service.GetCommentList(ctx, &req)
commentDtos, err := cc.service.GetCommentList(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, utils.H{"comments": commentDtos})
}
func (cc *CommentController) ReactComment(ctx context.Context, c *app.RequestContext) {

View File

@ -1,121 +1,150 @@
package v1
import (
"context"
"slices"
"strings"
"context"
"slices"
"strings"
"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"
"github.com/snowykami/neo-blog/pkg/constant"
"github.com/snowykami/neo-blog/pkg/errs"
"github.com/snowykami/neo-blog/pkg/resps"
"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"
"github.com/snowykami/neo-blog/pkg/constant"
"github.com/snowykami/neo-blog/pkg/errs"
"github.com/snowykami/neo-blog/pkg/resps"
)
type PostController struct {
service *service.PostService
service *service.PostService
}
func NewPostController() *PostController {
return &PostController{
service: service.NewPostService(),
}
return &PostController{
service: service.NewPostService(),
}
}
func (p *PostController) Create(ctx context.Context, c *app.RequestContext) {
var req dto.CreateOrUpdatePostReq
if err := c.BindAndValidate(&req); err != nil {
resps.BadRequest(c, resps.ErrParamInvalid)
return
}
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, utils.H{"id": postID})
var req dto.CreateOrUpdatePostReq
if err := c.BindAndValidate(&req); err != nil {
resps.BadRequest(c, resps.ErrParamInvalid)
return
}
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, utils.H{"id": postID})
}
func (p *PostController) Delete(ctx context.Context, c *app.RequestContext) {
id := c.Param("id")
if id == "" {
resps.BadRequest(c, resps.ErrParamInvalid)
return
}
if err := p.service.DeletePost(ctx, id); err != nil {
serviceErr := errs.AsServiceError(err)
resps.Custom(c, serviceErr.Code, serviceErr.Message, nil)
return
}
resps.Ok(c, resps.Success, nil)
id := c.Param("id")
if id == "" {
resps.BadRequest(c, resps.ErrParamInvalid)
return
}
if err := p.service.DeletePost(ctx, id); err != nil {
serviceErr := errs.AsServiceError(err)
resps.Custom(c, serviceErr.Code, serviceErr.Message, nil)
return
}
resps.Ok(c, resps.Success, nil)
}
func (p *PostController) Get(ctx context.Context, c *app.RequestContext) {
id := c.Param("id")
if id == "" {
resps.BadRequest(c, resps.ErrParamInvalid)
return
}
post, err := p.service.GetPost(ctx, id)
if err != nil {
serviceErr := errs.AsServiceError(err)
resps.Custom(c, serviceErr.Code, serviceErr.Message, nil)
return
}
if post == nil {
resps.NotFound(c, resps.ErrNotFound)
return
}
resps.Ok(c, resps.Success, post)
id := c.Param("id")
if id == "" {
resps.BadRequest(c, resps.ErrParamInvalid)
return
}
post, err := p.service.GetPost(ctx, id)
if err != nil {
serviceErr := errs.AsServiceError(err)
resps.Custom(c, serviceErr.Code, serviceErr.Message, nil)
return
}
if post == nil {
resps.NotFound(c, resps.ErrNotFound)
return
}
resps.Ok(c, resps.Success, post)
}
func (p *PostController) Update(ctx context.Context, c *app.RequestContext) {
var req dto.CreateOrUpdatePostReq
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
}
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, utils.H{"id": postID})
var req dto.CreateOrUpdatePostReq
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
}
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, utils.H{"id": postID})
}
func (p *PostController) List(ctx context.Context, c *app.RequestContext) {
pagination := ctxutils.GetPaginationParams(c)
if pagination.OrderBy == "" {
pagination.OrderBy = constant.OrderByUpdatedAt
}
if pagination.OrderBy != "" && !slices.Contains(constant.OrderByEnumPost, pagination.OrderBy) {
resps.BadRequest(c, "无效的排序字段")
return
}
keywords := c.Query("keywords")
keywordsArray := strings.Split(keywords, ",")
req := &dto.ListPostReq{
Keywords: keywordsArray,
Page: pagination.Page,
Size: pagination.Size,
OrderBy: pagination.OrderBy,
Desc: pagination.Desc,
}
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, posts)
pagination := ctxutils.GetPaginationParams(c)
if pagination.OrderBy == "" {
pagination.OrderBy = constant.OrderByUpdatedAt
}
if pagination.OrderBy != "" && !slices.Contains(constant.OrderByEnumPost, pagination.OrderBy) {
resps.BadRequest(c, "无效的排序字段")
return
}
keywords := c.Query("keywords")
keywordsArray := strings.Split(keywords, ",")
labels := c.Query("labels")
labelStringArray := strings.Split(labels, ",")
labelRule := c.Query("label_rule")
if labelRule != "intersection" {
labelRule = "union"
}
labelDtos := make([]dto.LabelDto, 0, len(labelStringArray))
for _, labelString := range labelStringArray {
// :分割key和value
if labelString == "" {
continue
}
parts := strings.SplitN(labelString, ":", 2)
if len(parts) == 2 {
labelDtos = append(labelDtos, dto.LabelDto{
Key: parts[0],
Value: parts[1],
})
} else {
labelDtos = append(labelDtos, dto.LabelDto{
Key: parts[0],
Value: "",
})
}
}
req := &dto.ListPostReq{
Keywords: keywordsArray,
Labels: labelDtos,
LabelRule: labelRule,
Page: pagination.Page,
Size: pagination.Size,
OrderBy: pagination.OrderBy,
Desc: pagination.Desc,
}
posts, total, 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, utils.H{"posts": posts, "total": total})
}

View File

@ -31,11 +31,13 @@ type CreateOrUpdatePostReq struct {
}
type ListPostReq struct {
Keywords []string `json:"keywords"` // 关键词列表
OrderBy string `json:"order_by"` // 排序方式
Page uint64 `json:"page"` // 页码
Size uint64 `json:"size"`
Desc bool `json:"desc"`
Keywords []string `json:"keywords"` // 关键词列表
OrderBy string `json:"order_by"` // 排序方式
Page uint64 `json:"page"` // 页码
Size uint64 `json:"size"`
Desc bool `json:"desc"`
Labels []LabelDto `json:"labels"`
LabelRule string `json:"label_rule"` // 标签过滤规则 union or intersection
}
type ListPostResp struct {

View File

@ -12,7 +12,7 @@ type User struct {
AvatarUrl string
Email string `gorm:"uniqueIndex"`
Gender string
Role string `gorm:"default:'user'"`
Role string `gorm:"default:'user'"` // user editor admin
Language string `gorm:"default:'en'"`
Password string // 密码,存储加密后的值
}

View File

@ -18,6 +18,26 @@ func (l *labelRepo) GetLabelByKey(key string) (*model.Label, error) {
return &label, nil
}
func (l *labelRepo) GetLabelByValue(value string) (*model.Label, error) {
var label model.Label
if err := GetDB().Where("value = ?", value).First(&label).Error; err != nil {
return nil, err
}
return &label, nil
}
func (l *labelRepo) GetLabelByKeyAndValue(key, value string) (*model.Label, error) {
var label model.Label
query := GetDB().Where("key = ?", key)
if value != "" {
query = query.Where("value = ?", value)
}
if err := GetDB().Where(query).First(&label).Error; err != nil {
return nil, err
}
return &label, nil
}
func (l *labelRepo) GetLabelByID(id string) (*model.Label, error) {
var label model.Label
if err := GetDB().Where("id = ?", id).First(&label).Error; err != nil {

View File

@ -1,12 +1,15 @@
package repo
import (
"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"
)
type postRepo struct{}
@ -48,9 +51,9 @@ func (p *postRepo) UpdatePost(post *model.Post) error {
return nil
}
func (p *postRepo) ListPosts(currentUserID uint, keywords []string, page, size uint64, orderBy string, desc bool) ([]model.Post, error) {
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, errs.New(http.StatusBadRequest, "invalid order_by parameter", nil)
return nil, 0, errs.New(http.StatusBadRequest, "invalid order_by parameter", nil)
}
query := GetDB().Model(&model.Post{}).Preload("User")
if currentUserID > 0 {
@ -58,20 +61,43 @@ func (p *postRepo) ListPosts(currentUserID uint, keywords []string, page, size u
} 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(keywords) > 0 {
for _, keyword := range keywords {
if keyword != "" {
// 使用LIKE进行模糊匹配搜索标题、内容和标签
query = query.Where("title LIKE ? OR content LIKE ?", // TODO: 支持标签搜索
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
}
items, _, err := PaginateQuery[model.Post](query, page, size, orderBy, desc)
if err != nil {
return nil, err
return nil, 0, err
}
return items, nil
return items, total, nil
}
func (p *postRepo) ToggleLikePost(postID uint, userID uint) (bool, error) {

View File

@ -114,17 +114,17 @@ func (p *PostService) UpdatePost(ctx context.Context, id string, req *dto.Create
return post.ID, nil
}
func (p *PostService) ListPosts(ctx context.Context, req *dto.ListPostReq) ([]*dto.PostDto, error) {
func (p *PostService) ListPosts(ctx context.Context, req *dto.ListPostReq) ([]*dto.PostDto, int64, error) {
postDtos := make([]*dto.PostDto, 0)
currentUserID, _ := ctxutils.GetCurrentUserID(ctx)
posts, err := repo.Post.ListPosts(currentUserID, req.Keywords, req.Page, req.Size, req.OrderBy, req.Desc)
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, errs.New(errs.ErrInternalServer.Code, "failed to list posts", err)
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, nil
return postDtos, total, nil
}
func (p *PostService) ToggleLikePost(ctx context.Context, id string) (bool, error) {

View File

@ -1,400 +1,400 @@
package service
import (
"errors"
"fmt"
"net/http"
"strings"
"time"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/sirupsen/logrus"
"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/internal/static"
"github.com/snowykami/neo-blog/pkg/constant"
"github.com/snowykami/neo-blog/pkg/errs"
"github.com/snowykami/neo-blog/pkg/utils"
"gorm.io/gorm"
"github.com/sirupsen/logrus"
"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/internal/static"
"github.com/snowykami/neo-blog/pkg/constant"
"github.com/snowykami/neo-blog/pkg/errs"
"github.com/snowykami/neo-blog/pkg/utils"
"gorm.io/gorm"
)
type UserService struct{}
func NewUserService() *UserService {
return &UserService{}
return &UserService{}
}
func (s *UserService) UserLogin(req *dto.UserLoginReq) (*dto.UserLoginResp, error) {
user, err := repo.User.GetUserByUsernameOrEmail(req.Username)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
logrus.Warnf("User not found: %s", req.Username)
return nil, errs.ErrNotFound
}
return nil, errs.ErrInternalServer
}
if user == nil {
return nil, errs.ErrNotFound
}
if utils.Password.VerifyPassword(req.Password, user.Password, utils.Env.Get(constant.EnvKeyPasswordSalt, "default_salt")) {
token, refreshToken, err := s.generate2Token(user.ID)
if err != nil {
logrus.Errorln("Failed to generate tokens:", err)
return nil, errs.ErrInternalServer
}
resp := &dto.UserLoginResp{
Token: token,
RefreshToken: refreshToken,
User: user.ToDto(),
}
return resp, nil
} else {
return nil, errs.ErrInternalServer
}
user, err := repo.User.GetUserByUsernameOrEmail(req.Username)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
logrus.Warnf("User not found: %s", req.Username)
return nil, errs.ErrNotFound
}
return nil, errs.ErrInternalServer
}
if user == nil {
return nil, errs.ErrNotFound
}
if utils.Password.VerifyPassword(req.Password, user.Password, utils.Env.Get(constant.EnvKeyPasswordSalt, "default_salt")) {
token, refreshToken, err := s.generate2Token(user.ID)
if err != nil {
logrus.Errorln("Failed to generate tokens:", err)
return nil, errs.ErrInternalServer
}
resp := &dto.UserLoginResp{
Token: token,
RefreshToken: refreshToken,
User: user.ToDto(),
}
return resp, nil
} else {
return nil, errs.ErrInternalServer
}
}
func (s *UserService) UserRegister(req *dto.UserRegisterReq) (*dto.UserRegisterResp, error) {
// 验证邮箱验证码
if !utils.Env.GetAsBool("ENABLE_REGISTER", true) {
return nil, errs.ErrForbidden
}
if utils.Env.GetAsBool("ENABLE_EMAIL_VERIFICATION", true) {
ok, err := s.verifyEmail(req.Email, req.VerificationCode)
if err != nil {
logrus.Errorln("Failed to verify email:", err)
return nil, errs.ErrInternalServer
}
if !ok {
return nil, errs.New(http.StatusForbidden, "Invalid email verification code", nil)
}
}
// 检查用户名或邮箱是否已存在
usernameExist, err := repo.User.CheckUsernameExists(req.Username)
if err != nil {
return nil, errs.ErrInternalServer
}
emailExist, err := repo.User.CheckEmailExists(req.Email)
if err != nil {
return nil, errs.ErrInternalServer
}
if usernameExist || emailExist {
return nil, errs.New(http.StatusConflict, "Username or email already exists", nil)
}
// 创建新用户
hashedPassword, err := utils.Password.HashPassword(req.Password, utils.Env.Get(constant.EnvKeyPasswordSalt, "default_salt"))
if err != nil {
logrus.Errorln("Failed to hash password:", err)
return nil, errs.ErrInternalServer
}
newUser := &model.User{
Username: req.Username,
Nickname: req.Nickname,
Email: req.Email,
Gender: "",
Role: "user",
Password: hashedPassword,
}
err = repo.User.CreateUser(newUser)
if err != nil {
return nil, errs.ErrInternalServer
}
// 创建默认管理员账户
if newUser.ID == 1 {
newUser.Role = constant.RoleAdmin
err = repo.User.UpdateUser(newUser)
if err != nil {
logrus.Errorln("Failed to update user role to admin:", err)
return nil, errs.ErrInternalServer
}
}
// 生成访问令牌和刷新令牌
token, refreshToken, err := s.generate2Token(newUser.ID)
if err != nil {
logrus.Errorln("Failed to generate tokens:", err)
return nil, errs.ErrInternalServer
}
resp := &dto.UserRegisterResp{
Token: token,
RefreshToken: refreshToken,
User: newUser.ToDto(),
}
return resp, nil
// 验证邮箱验证码
if !utils.Env.GetAsBool("ENABLE_REGISTER", true) {
return nil, errs.ErrForbidden
}
if utils.Env.GetAsBool("ENABLE_EMAIL_VERIFICATION", true) {
ok, err := s.verifyEmail(req.Email, req.VerificationCode)
if err != nil {
logrus.Errorln("Failed to verify email:", err)
return nil, errs.ErrInternalServer
}
if !ok {
return nil, errs.New(http.StatusForbidden, "Invalid email verification code", nil)
}
}
// 检查用户名或邮箱是否已存在
usernameExist, err := repo.User.CheckUsernameExists(req.Username)
if err != nil {
return nil, errs.ErrInternalServer
}
emailExist, err := repo.User.CheckEmailExists(req.Email)
if err != nil {
return nil, errs.ErrInternalServer
}
if usernameExist || emailExist {
return nil, errs.New(http.StatusConflict, "Username or email already exists", nil)
}
// 创建新用户
hashedPassword, err := utils.Password.HashPassword(req.Password, utils.Env.Get(constant.EnvKeyPasswordSalt, "default_salt"))
if err != nil {
logrus.Errorln("Failed to hash password:", err)
return nil, errs.ErrInternalServer
}
newUser := &model.User{
Username: req.Username,
Nickname: req.Nickname,
Email: req.Email,
Gender: "",
Role: "user",
Password: hashedPassword,
}
err = repo.User.CreateUser(newUser)
if err != nil {
return nil, errs.ErrInternalServer
}
// 创建默认管理员账户
if newUser.ID == 1 {
newUser.Role = constant.RoleAdmin
err = repo.User.UpdateUser(newUser)
if err != nil {
logrus.Errorln("Failed to update user role to admin:", err)
return nil, errs.ErrInternalServer
}
}
// 生成访问令牌和刷新令牌
token, refreshToken, err := s.generate2Token(newUser.ID)
if err != nil {
logrus.Errorln("Failed to generate tokens:", err)
return nil, errs.ErrInternalServer
}
resp := &dto.UserRegisterResp{
Token: token,
RefreshToken: refreshToken,
User: newUser.ToDto(),
}
return resp, nil
}
func (s *UserService) RequestVerifyEmail(req *dto.VerifyEmailReq) (*dto.VerifyEmailResp, error) {
generatedVerificationCode := utils.Strings.GenerateRandomStringWithCharset(6, "0123456789abcdef")
kv := utils.KV.GetInstance()
kv.Set(constant.KVKeyEmailVerificationCode+req.Email, generatedVerificationCode, time.Minute*10)
generatedVerificationCode := utils.Strings.GenerateRandomStringWithCharset(6, "0123456789abcdef")
kv := utils.KV.GetInstance()
kv.Set(constant.KVKeyEmailVerificationCode+req.Email, generatedVerificationCode, time.Minute*10)
template, err := static.RenderTemplate("email/verification-code.tmpl", map[string]interface{}{})
if err != nil {
return nil, errs.ErrInternalServer
}
if utils.IsDevMode {
logrus.Infof("%s's verification code is %s", req.Email, generatedVerificationCode)
}
err = utils.Email.SendEmail(utils.Email.GetEmailConfigFromEnv(), req.Email, "验证你的电子邮件 / Verify your email", template, true)
template, err := static.RenderTemplate("email/verification-code.tmpl", map[string]interface{}{})
if err != nil {
return nil, errs.ErrInternalServer
}
if utils.IsDevMode {
logrus.Infof("%s's verification code is %s", req.Email, generatedVerificationCode)
}
err = utils.Email.SendEmail(utils.Email.GetEmailConfigFromEnv(), req.Email, "验证你的电子邮件 / Verify your email", template, true)
if err != nil {
return nil, errs.ErrInternalServer
}
return &dto.VerifyEmailResp{Success: true}, nil
if err != nil {
return nil, errs.ErrInternalServer
}
return &dto.VerifyEmailResp{Success: true}, nil
}
func (s *UserService) ListOidcConfigs() ([]dto.UserOidcConfigDto, error) {
enabledOidcConfigs, err := repo.Oidc.ListOidcConfigs(true)
if err != nil {
return nil, errs.ErrInternalServer
}
var oidcConfigsDtos []dto.UserOidcConfigDto
enabledOidcConfigs, err := repo.Oidc.ListOidcConfigs(true)
if err != nil {
return nil, errs.ErrInternalServer
}
var oidcConfigsDtos []dto.UserOidcConfigDto
for _, oidcConfig := range enabledOidcConfigs {
state := utils.Strings.GenerateRandomString(32)
kvStore := utils.KV.GetInstance()
kvStore.Set(constant.KVKeyOidcState+state, oidcConfig.Name, 5*time.Minute)
loginUrl := utils.Url.BuildUrl(oidcConfig.AuthorizationEndpoint, map[string]string{
"client_id": oidcConfig.ClientID,
"redirect_uri": fmt.Sprintf("%s%s%s/%sREDIRECT_BACK", // 这个大占位符给前端替换用的替换时也要uri编码因为是层层包的
strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/"),
constant.ApiSuffix,
constant.OidcUri,
oidcConfig.Name,
),
"response_type": "code",
"scope": "openid email profile",
"state": state,
})
for _, oidcConfig := range enabledOidcConfigs {
state := utils.Strings.GenerateRandomString(32)
kvStore := utils.KV.GetInstance()
kvStore.Set(constant.KVKeyOidcState+state, oidcConfig.Name, 5*time.Minute)
loginUrl := utils.Url.BuildUrl(oidcConfig.AuthorizationEndpoint, map[string]string{
"client_id": oidcConfig.ClientID,
"redirect_uri": fmt.Sprintf("%s%s%s/%sREDIRECT_BACK", // 这个大占位符给前端替换用的替换时也要uri编码因为是层层包的
strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/"),
constant.ApiSuffix,
constant.OidcUri,
oidcConfig.Name,
),
"response_type": "code",
"scope": "openid email profile",
"state": state,
})
if oidcConfig.Type == constant.OidcProviderTypeMisskey {
// Misskey OIDC 特殊处理
loginUrl = utils.Url.BuildUrl(oidcConfig.AuthorizationEndpoint, map[string]string{
"client_id": oidcConfig.ClientID,
"redirect_uri": fmt.Sprintf("%s%s%s/%s", // 这个大占位符给前端替换用的替换时也要uri编码因为是层层包的
strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/"),
constant.ApiSuffix,
constant.OidcUri,
oidcConfig.Name,
),
"response_type": "code",
"scope": "read:account",
"state": state,
})
}
if oidcConfig.Type == constant.OidcProviderTypeMisskey {
// Misskey OIDC 特殊处理
loginUrl = utils.Url.BuildUrl(oidcConfig.AuthorizationEndpoint, map[string]string{
"client_id": oidcConfig.ClientID,
"redirect_uri": fmt.Sprintf("%s%s%s/%s", // 这个大占位符给前端替换用的替换时也要uri编码因为是层层包的
strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/"),
constant.ApiSuffix,
constant.OidcUri,
oidcConfig.Name,
),
"response_type": "code",
"scope": "read:account",
"state": state,
})
}
oidcConfigsDtos = append(oidcConfigsDtos, dto.UserOidcConfigDto{
Name: oidcConfig.Name,
DisplayName: oidcConfig.DisplayName,
Icon: oidcConfig.Icon,
LoginUrl: loginUrl,
})
}
return oidcConfigsDtos, nil
oidcConfigsDtos = append(oidcConfigsDtos, dto.UserOidcConfigDto{
Name: oidcConfig.Name,
DisplayName: oidcConfig.DisplayName,
Icon: oidcConfig.Icon,
LoginUrl: loginUrl,
})
}
return oidcConfigsDtos, nil
}
func (s *UserService) OidcLogin(req *dto.OidcLoginReq) (*dto.OidcLoginResp, error) {
// 验证state
kvStore := utils.KV.GetInstance()
storedName, ok := kvStore.Get(constant.KVKeyOidcState + req.State)
if !ok || storedName != req.Name {
return nil, errs.New(http.StatusForbidden, "invalid oidc state", nil)
}
// 获取OIDC配置
oidcConfig, err := repo.Oidc.GetOidcConfigByName(req.Name)
if err != nil {
return nil, errs.ErrInternalServer
}
if oidcConfig == nil {
return nil, errs.New(http.StatusNotFound, "OIDC configuration not found", nil)
}
// 请求访问令牌
tokenResp, err := utils.Oidc.RequestToken(
oidcConfig.TokenEndpoint,
oidcConfig.ClientID,
oidcConfig.ClientSecret,
req.Code,
strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/")+constant.OidcUri+oidcConfig.Name,
)
if err != nil {
logrus.Errorln("Failed to request OIDC token:", err)
return nil, errs.ErrInternalServer
}
userInfo, err := utils.Oidc.RequestUserInfo(oidcConfig.UserInfoEndpoint, tokenResp.AccessToken)
if err != nil {
logrus.Errorln("Failed to request OIDC user info:", err)
return nil, errs.ErrInternalServer
}
// 验证state
kvStore := utils.KV.GetInstance()
storedName, ok := kvStore.Get(constant.KVKeyOidcState + req.State)
if !ok || storedName != req.Name {
return nil, errs.New(http.StatusForbidden, "invalid oidc state", nil)
}
// 获取OIDC配置
oidcConfig, err := repo.Oidc.GetOidcConfigByName(req.Name)
if err != nil {
return nil, errs.ErrInternalServer
}
if oidcConfig == nil {
return nil, errs.New(http.StatusNotFound, "OIDC configuration not found", nil)
}
// 请求访问令牌
tokenResp, err := utils.Oidc.RequestToken(
oidcConfig.TokenEndpoint,
oidcConfig.ClientID,
oidcConfig.ClientSecret,
req.Code,
strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/")+constant.OidcUri+oidcConfig.Name,
)
if err != nil {
logrus.Errorln("Failed to request OIDC token:", err)
return nil, errs.ErrInternalServer
}
userInfo, err := utils.Oidc.RequestUserInfo(oidcConfig.UserInfoEndpoint, tokenResp.AccessToken)
if err != nil {
logrus.Errorln("Failed to request OIDC user info:", err)
return nil, errs.ErrInternalServer
}
// 绑定过登录
userOpenID, err := repo.User.GetUserOpenIDByIssuerAndSub(oidcConfig.Issuer, userInfo.Sub)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errs.ErrInternalServer
}
if userOpenID != nil {
user, err := repo.User.GetUserByID(userOpenID.UserID)
if err != nil {
return nil, errs.ErrInternalServer
}
token, refreshToken, err := s.generate2Token(user.ID)
if err != nil {
logrus.Errorln("Failed to generate tokens:", err)
return nil, errs.ErrInternalServer
}
resp := &dto.OidcLoginResp{
Token: token,
RefreshToken: refreshToken,
User: user.ToDto(),
}
return resp, nil
} else {
// 若没有绑定过登录,则先通过邮箱查找用户,若没有再创建新用户
user, err := repo.User.GetUserByEmail(userInfo.Email)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
logrus.Errorln("Failed to get user by email:", err)
return nil, errs.ErrInternalServer
}
if user != nil {
userOpenID = &model.UserOpenID{
UserID: user.ID,
Issuer: oidcConfig.Issuer,
Sub: userInfo.Sub,
}
err = repo.User.CreateOrUpdateUserOpenID(userOpenID)
if err != nil {
logrus.Errorln("Failed to create or update user OpenID:", err)
return nil, errs.ErrInternalServer
}
token, refreshToken, err := s.generate2Token(user.ID)
if err != nil {
logrus.Errorln("Failed to generate tokens:", err)
return nil, errs.ErrInternalServer
}
resp := &dto.OidcLoginResp{
Token: token,
RefreshToken: refreshToken,
User: user.ToDto(),
}
return resp, nil
} else {
user = &model.User{
Username: userInfo.Name,
Nickname: userInfo.Name,
AvatarUrl: userInfo.Picture,
Email: userInfo.Email,
}
err = repo.User.CreateUser(user)
if err != nil {
logrus.Errorln("Failed to create user:", err)
return nil, errs.ErrInternalServer
}
userOpenID = &model.UserOpenID{
UserID: user.ID,
Issuer: oidcConfig.Issuer,
Sub: userInfo.Sub,
}
err = repo.User.CreateOrUpdateUserOpenID(userOpenID)
if err != nil {
logrus.Errorln("Failed to create or update user OpenID:", err)
return nil, errs.ErrInternalServer
}
token, refreshToken, err := s.generate2Token(user.ID)
if err != nil {
logrus.Errorln("Failed to generate tokens:", err)
return nil, errs.ErrInternalServer
}
resp := &dto.OidcLoginResp{
Token: token,
RefreshToken: refreshToken,
User: user.ToDto(),
}
return resp, nil
}
}
// 绑定过登录
userOpenID, err := repo.User.GetUserOpenIDByIssuerAndSub(oidcConfig.Issuer, userInfo.Sub)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errs.ErrInternalServer
}
if userOpenID != nil {
user, err := repo.User.GetUserByID(userOpenID.UserID)
if err != nil {
return nil, errs.ErrInternalServer
}
token, refreshToken, err := s.generate2Token(user.ID)
if err != nil {
logrus.Errorln("Failed to generate tokens:", err)
return nil, errs.ErrInternalServer
}
resp := &dto.OidcLoginResp{
Token: token,
RefreshToken: refreshToken,
User: user.ToDto(),
}
return resp, nil
} else {
// 若没有绑定过登录,则先通过邮箱查找用户,若没有再创建新用户
user, err := repo.User.GetUserByEmail(userInfo.Email)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
logrus.Errorln("Failed to get user by email:", err)
return nil, errs.ErrInternalServer
}
if user != nil {
userOpenID = &model.UserOpenID{
UserID: user.ID,
Issuer: oidcConfig.Issuer,
Sub: userInfo.Sub,
}
err = repo.User.CreateOrUpdateUserOpenID(userOpenID)
if err != nil {
logrus.Errorln("Failed to create or update user OpenID:", err)
return nil, errs.ErrInternalServer
}
token, refreshToken, err := s.generate2Token(user.ID)
if err != nil {
logrus.Errorln("Failed to generate tokens:", err)
return nil, errs.ErrInternalServer
}
resp := &dto.OidcLoginResp{
Token: token,
RefreshToken: refreshToken,
User: user.ToDto(),
}
return resp, nil
} else {
user = &model.User{
Username: userInfo.Name,
Nickname: userInfo.Name,
AvatarUrl: userInfo.Picture,
Email: userInfo.Email,
}
err = repo.User.CreateUser(user)
if err != nil {
logrus.Errorln("Failed to create user:", err)
return nil, errs.ErrInternalServer
}
userOpenID = &model.UserOpenID{
UserID: user.ID,
Issuer: oidcConfig.Issuer,
Sub: userInfo.Sub,
}
err = repo.User.CreateOrUpdateUserOpenID(userOpenID)
if err != nil {
logrus.Errorln("Failed to create or update user OpenID:", err)
return nil, errs.ErrInternalServer
}
token, refreshToken, err := s.generate2Token(user.ID)
if err != nil {
logrus.Errorln("Failed to generate tokens:", err)
return nil, errs.ErrInternalServer
}
resp := &dto.OidcLoginResp{
Token: token,
RefreshToken: refreshToken,
User: user.ToDto(),
}
return resp, nil
}
}
}
func (s *UserService) GetUser(req *dto.GetUserReq) (*dto.GetUserResp, error) {
if req.UserID == 0 {
return nil, errs.New(http.StatusBadRequest, "user_id is required", nil)
}
user, err := repo.User.GetUserByID(req.UserID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errs.ErrNotFound
}
logrus.Errorln("Failed to get user by ID:", err)
return nil, errs.ErrInternalServer
}
if user == nil {
return nil, errs.ErrNotFound
}
return &dto.GetUserResp{
User: user.ToDto(),
}, nil
if req.UserID == 0 {
return nil, errs.New(http.StatusBadRequest, "user_id is required", nil)
}
user, err := repo.User.GetUserByID(req.UserID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errs.ErrNotFound
}
logrus.Errorln("Failed to get user by ID:", err)
return nil, errs.ErrInternalServer
}
if user == nil {
return nil, errs.ErrNotFound
}
return &dto.GetUserResp{
User: user.ToDto(),
}, nil
}
func (s *UserService) GetUserByUsername(req *dto.GetUserByUsernameReq) (*dto.GetUserResp, error) {
if req.Username == "" {
return nil, errs.New(http.StatusBadRequest, "username is required", nil)
}
user, err := repo.User.GetUserByUsername(req.Username)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errs.ErrNotFound
}
logrus.Errorln("Failed to get user by username:", err)
return nil, errs.ErrInternalServer
}
if user == nil {
return nil, errs.ErrNotFound
}
return &dto.GetUserResp{
User: user.ToDto(),
}, nil
if req.Username == "" {
return nil, errs.New(http.StatusBadRequest, "username is required", nil)
}
user, err := repo.User.GetUserByUsername(req.Username)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errs.ErrNotFound
}
logrus.Errorln("Failed to get user by username:", err)
return nil, errs.ErrInternalServer
}
if user == nil {
return nil, errs.ErrNotFound
}
return &dto.GetUserResp{
User: user.ToDto(),
}, nil
}
func (s *UserService) UpdateUser(req *dto.UpdateUserReq) (*dto.UpdateUserResp, error) {
user := &model.User{
Model: gorm.Model{
ID: req.ID,
},
Username: req.Username,
Nickname: req.Nickname,
Gender: req.Gender,
AvatarUrl: req.AvatarUrl,
}
err := repo.User.UpdateUser(user)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errs.ErrNotFound
}
logrus.Errorln("Failed to update user:", err)
return nil, errs.ErrInternalServer
}
return &dto.UpdateUserResp{}, nil
user := &model.User{
Model: gorm.Model{
ID: req.ID,
},
Username: req.Username,
Nickname: req.Nickname,
Gender: req.Gender,
AvatarUrl: req.AvatarUrl,
}
err := repo.User.UpdateUser(user)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errs.ErrNotFound
}
logrus.Errorln("Failed to update user:", err)
return nil, errs.ErrInternalServer
}
return &dto.UpdateUserResp{}, nil
}
func (s *UserService) generate2Token(userID uint) (string, string, error) {
token := utils.Jwt.NewClaims(userID, "", false, time.Duration(utils.Env.GetAsInt(constant.EnvKeyTokenDuration, constant.EnvKeyTokenDurationDefault))*time.Second)
tokenString, err := token.ToString()
if err != nil {
return "", "", errs.ErrInternalServer
}
refreshToken := utils.Jwt.NewClaims(userID, utils.Strings.GenerateRandomString(64), true, time.Duration(utils.Env.GetAsInt(constant.EnvKeyRefreshTokenDuration, constant.EnvKeyRefreshTokenDurationDefault))*time.Second)
refreshTokenString, err := refreshToken.ToString()
if err != nil {
return "", "", errs.ErrInternalServer
}
err = repo.Session.SaveSession(refreshToken.SessionKey)
if err != nil {
return "", "", errs.ErrInternalServer
}
return tokenString, refreshTokenString, nil
token := utils.Jwt.NewClaims(userID, "", false, time.Duration(utils.Env.GetAsInt(constant.EnvKeyTokenDuration, constant.EnvKeyTokenDurationDefault))*time.Second)
tokenString, err := token.ToString()
if err != nil {
return "", "", errs.ErrInternalServer
}
refreshToken := utils.Jwt.NewClaims(userID, utils.Strings.GenerateRandomString(64), true, time.Duration(utils.Env.GetAsInt(constant.EnvKeyRefreshTokenDuration, constant.EnvKeyRefreshTokenDurationDefault))*time.Second)
refreshTokenString, err := refreshToken.ToString()
if err != nil {
return "", "", errs.ErrInternalServer
}
err = repo.Session.SaveSession(refreshToken.SessionKey)
if err != nil {
return "", "", errs.ErrInternalServer
}
return tokenString, refreshTokenString, nil
}
func (s *UserService) verifyEmail(email, code string) (bool, error) {
kv := utils.KV.GetInstance()
verificationCode, ok := kv.Get(constant.KVKeyEmailVerificationCode + email)
if !ok || verificationCode != code {
return false, errs.New(http.StatusForbidden, "Invalid email verification code", nil)
}
return true, nil
kv := utils.KV.GetInstance()
verificationCode, ok := kv.Get(constant.KVKeyEmailVerificationCode + email)
if !ok || verificationCode != code {
return false, errs.New(http.StatusForbidden, "Invalid email verification code", nil)
}
return true, nil
}

View File

@ -15,6 +15,7 @@
"@radix-ui/react-dialog": "^1.1.14",
"@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",
@ -23,7 +24,6 @@
"clsx": "^2.1.1",
"deepmerge": "^4.3.1",
"field-conv": "^1.0.9",
"framer-motion": "^12.23.9",
"highlight.js": "^11.11.1",
"lucide-react": "^0.525.0",
"motion": "^12.23.12",

230
web/pnpm-lock.yaml generated
View File

@ -26,6 +26,9 @@ importers:
'@radix-ui/react-navigation-menu':
specifier: ^1.2.13
version: 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)
'@radix-ui/react-select':
specifier: ^2.2.6
version: 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)
'@radix-ui/react-separator':
specifier: ^1.1.7
version: 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)
@ -50,9 +53,6 @@ importers:
field-conv:
specifier: ^1.0.9
version: 1.0.9
framer-motion:
specifier: ^12.23.9
version: 12.23.9(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
highlight.js:
specifier: ^11.11.1
version: 11.11.1
@ -193,6 +193,21 @@ packages:
resolution: {integrity: sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@floating-ui/core@1.7.3':
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
'@floating-ui/dom@1.7.4':
resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==}
'@floating-ui/react-dom@2.1.6':
resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
'@formatjs/ecma402-abstract@2.3.4':
resolution: {integrity: sha512-qrycXDeaORzIqNhBOx0btnhpD1c+/qFIHAN9znofuMJX6QBwtbrmlpWfD4oiUUD2vJUOIYFA/gYtg2KAMGG7sA==}
@ -467,12 +482,28 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
'@radix-ui/primitive@1.1.2':
resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==}
'@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
'@radix-ui/react-arrow@1.1.7':
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
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:
@ -552,6 +583,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-dismissable-layer@1.1.11':
resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==}
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:
@ -561,6 +605,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-focus-guards@1.1.3':
resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-focus-scope@1.1.7':
resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==}
peerDependencies:
@ -609,6 +662,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-popper@1.2.8':
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
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-portal@1.1.9':
resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
peerDependencies:
@ -661,6 +727,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-select@2.2.6':
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
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-separator@1.1.7':
resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==}
peerDependencies:
@ -750,6 +829,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-rect@1.1.1':
resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==}
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-size@1.1.1':
resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==}
peerDependencies:
@ -772,6 +860,9 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
'@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
@ -1619,20 +1710,6 @@ packages:
react-dom:
optional: true
framer-motion@12.23.9:
resolution: {integrity: sha512-TqEHXj8LWfQSKqfdr5Y4mYltYLw96deu6/K9kGDd+ysqRJPNwF9nb5mZcrLmybHbU7gcJ+HQar41U3UTGanbbQ==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
@ -2187,9 +2264,6 @@ packages:
motion-dom@12.23.12:
resolution: {integrity: sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==}
motion-dom@12.23.9:
resolution: {integrity: sha512-6Sv++iWS8XMFCgU1qwKj9l4xuC47Hp4+2jvPfyTXkqDg2tTzSgX6nWKD4kNFXk0k7llO59LZTPuJigza4A2K1A==}
motion-utils@12.23.6:
resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==}
@ -2921,6 +2995,23 @@ snapshots:
'@eslint/core': 0.15.1
levn: 0.4.1
'@floating-ui/core@1.7.3':
dependencies:
'@floating-ui/utils': 0.2.10
'@floating-ui/dom@1.7.4':
dependencies:
'@floating-ui/core': 1.7.3
'@floating-ui/utils': 0.2.10
'@floating-ui/react-dom@2.1.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@floating-ui/dom': 1.7.4
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
'@floating-ui/utils@0.2.10': {}
'@formatjs/ecma402-abstract@2.3.4':
dependencies:
'@formatjs/fast-memoize': 2.2.7
@ -3169,10 +3260,21 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.2': {}
'@radix-ui/primitive@1.1.3': {}
'@radix-ui/react-arrow@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)':
dependencies:
'@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)
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
@ -3254,12 +3356,31 @@ snapshots:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@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)':
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-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-escape-keydown': 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-focus-guards@1.1.2(@types/react@19.1.8)(react@19.1.0)':
dependencies:
react: 19.1.0
optionalDependencies:
'@types/react': 19.1.8
'@radix-ui/react-focus-guards@1.1.3(@types/react@19.1.8)(react@19.1.0)':
dependencies:
react: 19.1.0
optionalDependencies:
'@types/react': 19.1.8
'@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)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
@ -3309,6 +3430,24 @@ snapshots:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@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)':
dependencies:
'@floating-ui/react-dom': 2.1.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-arrow': 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-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-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-use-rect': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-use-size': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/rect': 1.1.1
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-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)':
dependencies:
'@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)
@ -3348,6 +3487,35 @@ snapshots:
'@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
'@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-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-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)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-use-previous': 1.1.1(@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)
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-separator@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)':
dependencies:
'@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)
@ -3419,6 +3587,13 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.8
'@radix-ui/react-use-rect@1.1.1(@types/react@19.1.8)(react@19.1.0)':
dependencies:
'@radix-ui/rect': 1.1.1
react: 19.1.0
optionalDependencies:
'@types/react': 19.1.8
'@radix-ui/react-use-size@1.1.1(@types/react@19.1.8)(react@19.1.0)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0)
@ -3435,6 +3610,8 @@ snapshots:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/rect@1.1.1': {}
'@rtsao/scc@1.1.0': {}
'@rushstack/eslint-patch@1.12.0': {}
@ -4442,15 +4619,6 @@ snapshots:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
framer-motion@12.23.9(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
motion-dom: 12.23.9
motion-utils: 12.23.6
tslib: 2.8.1
optionalDependencies:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
function-bind@1.1.2: {}
function.prototype.name@1.1.8:
@ -5220,10 +5388,6 @@ snapshots:
dependencies:
motion-utils: 12.23.6
motion-dom@12.23.9:
dependencies:
motion-utils: 12.23.6
motion-utils@12.23.6: {}
motion@12.23.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0):

View File

@ -20,8 +20,8 @@ export async function createComment(
replyId: number | null
isPrivate: boolean
}
): Promise<BaseResponse<{id: number}>> {
const res = await axiosClient.post<BaseResponse<{id: number}>>('/comment/c', {
): Promise<BaseResponse<{ id: number }>> {
const res = await axiosClient.post<BaseResponse<{ id: number }>>('/comment/c', {
targetType,
targetId,
content,
@ -68,7 +68,7 @@ export async function listComments({
commentId: number
} & PaginationParams
) {
const res = await axiosClient.get<BaseResponse<Comment[]>>(`/comment/list`, {
const res = await axiosClient.get<BaseResponse<{ "comments": Comment[] }>>(`/comment/list`, {
params: {
targetType,
targetId,

View File

@ -1,7 +1,7 @@
import type { Post } from '@/models/post'
import type { BaseResponse } from '@/models/resp'
import axiosClient from './client'
import type { ListPostsParams } from '@/models/post'
import { OrderBy, PaginationParams } from '@/models/common'
export async function getPostById(id: string, token: string=""): Promise<Post | null> {
@ -22,17 +22,25 @@ export async function getPostById(id: string, token: string=""): Promise<Post |
export async function listPosts({
page = 1,
size = 10,
orderBy = 'updated_at',
orderBy = OrderBy.CreatedAt,
desc = false,
keywords = '',
}: ListPostsParams = {}): Promise<BaseResponse<Post[]>> {
const res = await axiosClient.get<BaseResponse<Post[]>>('/post/list', {
labels = '',
labelRule = 'union',
}: {
keywords?: string, // 关键词,逗号分割
labels?: string, // 标签,逗号分割
labelRule?: 'union' | 'intersection' // 标签规则,默认并集
} & PaginationParams): Promise<BaseResponse<{"posts": Post[], "total" : number}>> {
const res = await axiosClient.get<BaseResponse<{"posts": Post[], "total": number}>>('/post/list', {
params: {
page,
size,
orderBy,
desc,
keywords,
labels,
labelRule
},
})
return res.data

View File

@ -1,8 +1,8 @@
'use client'
import { motion } from 'framer-motion'
import { motion } from 'motion/react'
import { usePathname } from 'next/navigation'
import { Navbar } from '@/components/layout/navbar'
import { Navbar } from '@/components/layout/navbar-or-side'
import { BackgroundProvider } from '@/contexts/background-context'
import Footer from '@/components/layout/footer'
import config from '@/config'
@ -12,7 +12,6 @@ export default function RootLayout({
}: Readonly<{
children: React.ReactNode
}>) {
const pathname = usePathname()
return (
<>
<motion.nav
@ -23,10 +22,10 @@ export default function RootLayout({
<Navbar />
</header>
</motion.nav>
<BackgroundProvider>
<div className='container mx-auto pt-16 px-4 sm:px-6 lg:px-10 max-w-7xl'>{children}</div>
</BackgroundProvider>
<Footer />
<BackgroundProvider>
<div className='container mx-auto pt-16 px-4 sm:px-6 lg:px-10 max-w-7xl'>{children}</div>
</BackgroundProvider>
<Footer />
</>
)
}

View File

@ -131,4 +131,24 @@ html, body {
.sonner-toast {
background-color: aqua;
}
::-webkit-scrollbar {
width: 4px; /* 垂直滚动条宽度 */
height: 4px; /* 水平滚动条高度 */
background: transparent; /* 滚动条轨道背景 */
position: absolute; /* 实际不会影响内容布局 */
}
::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.15); /* 滚动条滑块颜色 */
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0,0,0,0.3);
}
::-webkit-scrollbar-corner {
background: transparent;
}

View File

@ -34,7 +34,7 @@ export default async function RootLayout({
>
<Toaster richColors position="top-center" offset={80} />
<DeviceProvider>
<NextIntlClientProvider>{children}</NextIntlClientProvider>
<NextIntlClientProvider>{children}</NextIntlClientProvider>
</DeviceProvider>
</body>
</html>

View File

@ -7,15 +7,14 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
import config from '@/config'
import { cn } from '@/lib/utils'
import { getPostHref } from '@/utils/common/post'
import { motion } from 'framer-motion'
import { motion } from 'motion/react'
import { deceleration } from '@/motion/curve'
interface BlogCardProps {
export function BlogCard({ post, className }: {
post: Post
className?: string
}
export function BlogCard({ post, className }: BlogCardProps) {
}) {
const formatDate = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleDateString('zh-CN', {
@ -57,16 +56,16 @@ export function BlogCard({ post, className }: BlogCardProps) {
// 默认渐变背景 - 基于热度生成颜色
<div
className={cn(
'w-full h-full bg-gradient-to-br',
post.heat > 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',
'w-full h-full bg-gradient-to-br',
post.heat > 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',
)}
/>
)}
@ -210,7 +209,7 @@ export function BlogCardGrid({
if (isLoading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, index) => (
{Array.from({ length: config.postsPerPage }).map((_, index) => (
<BlogCardSkeleton key={index} />
))}
</div>

View File

@ -1,85 +1,84 @@
"use client";
"use client"
import { BlogCardGrid } from "@/components/blog-home/blog-home-card";
import { Button } from "@/components/ui/button";
import { TrendingUp, Clock, } from "lucide-react";
import Sidebar, { SidebarAbout, SidebarHotPosts, SidebarMisskeyIframe, SidebarTags } from "../blog/blog-sidebar-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";
import { useStoredState } from '@/hooks/use-storage-state';
import { listLabels } from "@/api/label";
import { POST_SORT_TYPE } from "@/localstore";
import { motion } from "framer-motion";
import { useDevice } from "@/hooks/use-device";
import { checkIsMobile } from "@/utils/client/device";
import { motion } from "motion/react";
import { useTranslations } from "next-intl";
import { useSearchParams } from "next/navigation";
import { OrderBy } from "@/models/common";
import { PaginationController } from "@/components/common/pagination";
import { QueryKey } from "@/constant";
import { useStoredState } from "@/hooks/use-storage-state";
// 定义排序类型
type SortType = 'latest' | 'popular';
enum SortBy {
Latest = 'latest',
Hottest = 'hottest',
}
const DEFAULT_SORTBY: SortBy = SortBy.Latest;
export default function BlogHome() {
const [labels, setLabels] = useState<Label[]>([]);
// 从路由查询参数中获取页码和标签们
const searchParams = useSearchParams();
const t = useTranslations("BlogHome");
const [labels, setLabels] = useState<string[]>([]);
const [keywords, setKeywords] = useState<string[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [posts, setPosts] = useState<Post[]>([]);
const [totalPosts, setTotalPosts] = useState(0);
const [loading, setLoading] = useState(false);
const [sortType, setSortType, sortTypeLoaded] = useStoredState<SortType>(POST_SORT_TYPE, 'latest');
const [sortBy, setSortBy, isSortByLoaded] = useStoredState<SortBy>(QueryKey.SortBy, DEFAULT_SORTBY);
useEffect(() => {
if (!sortTypeLoaded) return;
const fetchPosts = async () => {
try {
setLoading(true);
let orderBy: string;
let desc: boolean;
switch (sortType) {
case 'latest':
orderBy = 'updated_at';
desc = true;
break;
case 'popular':
orderBy = 'heat';
desc = true;
break;
default:
orderBy = 'updated_at';
desc = true;
}
// 处理关键词,空格分割转逗号
const keywords = ""?.trim() ? ""?.trim().split(/\s+/).join(",") : undefined;
const data = await listPosts({
page: 1,
size: 10,
orderBy: orderBy,
desc: desc,
keywords
});
setPosts(data.data);
} catch (error) {
console.error("Failed to fetch posts:", error);
} finally {
setLoading(false);
if (!isSortByLoaded) return; // wait for stored state loaded
setLoading(true);
listPosts(
{
page: currentPage,
size: config.postsPerPage,
orderBy: sortBy === SortBy.Latest ? OrderBy.CreatedAt : OrderBy.Heat,
desc: true,
keywords: keywords.join(",") || undefined,
labels: labels.join(",") || undefined,
}
};
fetchPosts();
}, [sortType, sortTypeLoaded]);
// 获取标签
useEffect(() => {
listLabels().then(data => {
setLabels(data.data || []);
}).catch(error => {
console.error("Failed to fetch labels:", error);
).then(res => {
setPosts(res.data.posts);
setTotalPosts(res.data.total);
setLoading(false);
}).catch(err => {
console.error(err);
setLoading(false);
});
}, []);
}, [keywords, labels, currentPage, sortBy, isSortByLoaded]);
// 处理排序切换
const handleSortChange = (type: SortType) => {
if (sortType !== type) {
setSortType(type);
const handleSortChange = (type: SortBy) => {
if (sortBy !== type) {
setSortBy(type);
setCurrentPage(1);
}
};
const handlePageChange = (page: number) => {
// 修改查询参数和状态
setCurrentPage(page);
// 不滚动到顶部,用户可能在阅读侧边栏
// window.scrollTo({ top: 0, behavior: 'smooth' });
// 修改查询参数
const params = new URLSearchParams(searchParams.toString());
params.set('page', page.toString());
const newUrl = `${window.location.pathname}?${params.toString()}`;
window.history.replaceState({}, '', newUrl);
}
return (
<>
{/* 主内容区域 */}
@ -90,80 +89,75 @@ export default function BlogHome() {
{/* 主要内容区域 */}
<motion.div
className="lg:col-span-3 self-start"
initial={{ y: checkIsMobile() ? 30 : 60, opacity: 0 }}
initial={{ y: 40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: config.animationDurationSecond, ease: "easeOut" }}>
{/* 文章列表标题 */}
<div className="flex items-center justify-between mb-8">
<h2 className="text-3xl font-bold text-slate-900 dark:text-slate-100">
{sortType === 'latest' ? '最新文章' : '热门文章'}
{sortBy === 'latest' ? t("latest_posts") : t("hottest_posts")}
{posts.length > 0 && (
<span className="text-sm font-normal text-slate-500 ml-2">
({posts.length} )
<span className="text-xl font-normal text-slate-500 ml-2">
({posts.length})
</span>
)}
</h2>
{/* 排序按钮组 */}
<div className="flex items-center gap-2">
{isSortByLoaded && <div className="flex items-center gap-2">
<Button
variant={sortType === 'latest' ? 'default' : 'outline'}
variant={sortBy === SortBy.Latest ? 'default' : 'outline'}
size="sm"
onClick={() => handleSortChange('latest')}
onClick={() => handleSortChange(SortBy.Latest)}
disabled={loading}
className="transition-all duration-200"
>
<Clock className="w-4 h-4 mr-2" />
{t("latest")}
</Button>
<Button
variant={sortType === 'popular' ? 'default' : 'outline'}
variant={sortBy === 'hottest' ? 'default' : 'outline'}
size="sm"
onClick={() => handleSortChange('popular')}
onClick={() => handleSortChange(SortBy.Hottest)}
disabled={loading}
className="transition-all duration-200"
>
<TrendingUp className="w-4 h-4 mr-2" />
{t("hottest")}
</Button>
</div>
</div>}
</div>
{/* 博客卡片网格 */}
<BlogCardGrid posts={posts} isLoading={loading} showPrivate={true} />
{/* 加载更多按钮 */}
{!loading && posts.length > 0 && (
<div className="text-center mt-12">
<Button size="lg" className="px-8">
</Button>
</div>
)}
{/* 分页控制器 */}
<div className="mt-8">
<PaginationController
className="pt-4 flex justify-center"
initialPage={currentPage}
totalPages={Math.ceil(totalPosts / config.postsPerPage)}
onPageChange={handlePageChange}
/>
</div>
{/* 加载状态指示器 */}
{loading && (
<div className="text-center py-8">
<div className="inline-flex items-center gap-2 text-slate-600">
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
{sortType === 'latest' ? '最新' : '热门'}...
<span>{t("loading")}</span>
</div>
</div>
)}
</motion.div>
{/* 侧边栏 */}
<motion.div
initial={checkIsMobile() ? { y: 30, opacity: 0 } : { x: 80, opacity: 0 }}
initial={{ x: 80, opacity: 0 }}
animate={{ x: 0, y: 0, opacity: 1 }}
transition={{ duration: config.animationDurationSecond, ease: "easeOut" }}
>
<Sidebar
cards={[
<SidebarAbout key="about" config={config} />,
posts.length > 0 ? <SidebarHotPosts key="hot" posts={posts} sortType={sortType} /> : null,
<SidebarTags key="tags" labels={labels} />,
posts.length > 0 ? <SidebarHotPosts key="hot" posts={posts} sortType={sortBy} /> : null,
<SidebarTags key="tags" labels={[]} />,
<SidebarMisskeyIframe key="misskey" />,
].filter(Boolean)}
/>

View File

@ -34,7 +34,7 @@ export function SidebarAbout({ config }: { config: typeof configType }) {
<CardContent>
<div className="text-center mb-4">
<div className="w-20 h-20 mx-auto bg-gradient-to-br from-blue-400 to-purple-500 rounded-full flex items-center justify-center text-white text-2xl font-bold overflow-hidden">
<GravatarAvatar email={config.owner.gravatarEmail} className="w-full h-full object-cover" />
<GravatarAvatar email={config.owner.gravatarEmail} className="w-full h-full object-cover" size={200} />
</div>
<h3 className="font-semibold text-lg">{config.owner.name}</h3>
<p className="text-sm text-slate-600">{config.owner.motto}</p>

View File

@ -60,7 +60,7 @@ export function CommentInput(
<div className="fade-in-up">
<div className="flex py-4 fade-in">
<div onClick={user ? () => clickToUserProfile(user.username) : clickToLogin} className="cursor-pointer flex-shrink-0 w-10 h-10 fade-in">
{user && <GravatarAvatar url={user.avatarUrl} email={user.email} size={100}/>}
{user && <GravatarAvatar className="w-full h-full" url={user.avatarUrl} email={user.email} size={100}/>}
{!user && <CircleUser className="w-full h-full fade-in" />}
</div>
<div className="flex-1 pl-2 fade-in-up">

View File

@ -101,7 +101,7 @@ export function CommentItem(
commentId: comment.id
}
).then(response => {
setReplies(response.data);
setReplies(response.data.comments);
setRepliesLoaded(true);
});
}
@ -159,7 +159,7 @@ export function CommentItem(
<div>
<div className="flex">
<div onClick={() => clickToUserProfile(comment.user.username)} className="cursor-pointer fade-in w-12 h-12">
<GravatarAvatar email={comment.user.email} size={120}/>
<GravatarAvatar className="w-full h-full" url={comment.user.avatarUrl} email={comment.user.email} size={100}/>
</div>
<div className="flex-1 pl-2 fade-in-up">
<div className="flex gap-2 md:gap-4 items-center">

View File

@ -17,8 +17,6 @@ import config from "@/config";
import "./style.css";
export function CommentSection(
{
targetType,
@ -59,7 +57,7 @@ export function CommentSection(
size: config.commentsPerPage,
commentId: 0
}).then(response => {
setComments(response.data);
setComments(response.data.comments);
});
}, [])
@ -108,10 +106,10 @@ export function CommentSection(
size: config.commentsPerPage,
commentId: 0
}).then(response => {
if (response.data.length < config.commentsPerPage) {
if (response.data.comments.length < config.commentsPerPage) {
setNeedLoadMore(false);
}
setComments(prevComments => [...prevComments, ...response.data]);
setComments(prevComments => [...prevComments, ...response.data.comments]);
setPage(nextPage);
});
}

View File

@ -5,7 +5,7 @@ import Image from "next/image";
import crypto from "crypto";
// 生成 Gravatar URL 的函数
function getGravatarUrl(email: string, size: number = 40, defaultType: string = "identicon"): string {
function getGravatarUrl(email: string, size: number = 200, defaultType: string = "identicon"): string {
const hash = crypto.createHash('md5').update(email.toLowerCase().trim()).digest('hex');
return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=${defaultType}`;
}
@ -21,35 +21,26 @@ interface GravatarAvatarProps {
const GravatarAvatar: React.FC<GravatarAvatarProps> = ({
email,
size = 40,
size = 200,
className = "",
alt = "avatar",
url,
defaultType = "identicon"
}) => {
// 如果有自定义URL使用自定义URL
if (url && url.trim() !== "") {
return (
// 把尺寸控制交给父组件的 wrapper父组件通过 tailwind 的 w-.. h-.. 控制)
const gravatarUrl = url && url.trim() !== "" ? url : getGravatarUrl(email, size , defaultType);
return (
<div className={`relative overflow-hidden ${className}`}>
<Image
src={url}
width={size}
height={size}
className={`rounded-full object-cover w-full h-full ${className}`}
src={gravatarUrl}
alt={alt}
fill
sizes="(max-width: 640px) 64px, 200px"
className="rounded-full object-cover"
referrerPolicy="no-referrer"
/>
);
}
const gravatarUrl = getGravatarUrl(email, size * 10, defaultType);
return (
<Image
src={gravatarUrl}
width={size}
height={size}
className={`rounded-full object-cover w-full h-full ${className}`}
alt={alt}
referrerPolicy="no-referrer"
/>
</div>
);
};

View File

@ -0,0 +1,144 @@
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination"
import { useEffect, useState, useCallback } from "react"
export function PaginationController({
initialPage = 1,
totalPages = 10,
buttons = 7, // recommended odd number >=5
onPageChange,
...props
}: {
initialPage?: number
totalPages: number
buttons?: number
onPageChange?: (page: number) => void
} & React.HTMLAttributes<HTMLDivElement>) {
// normalize buttons
const btns = Math.max(5, buttons ?? 7);
const buttonsToShow = totalPages < btns ? totalPages : btns;
// rely on shadcn buttonVariants and PaginationLink's isActive prop for styling
const [currentPage, setCurrentPage] = useState(() => Math.min(Math.max(1, initialPage ?? 1), Math.max(1, totalPages)));
const [direction, setDirection] = useState(0) // 1 = forward (right->left), -1 = backward
// sync when initialPage or totalPages props change
useEffect(() => {
const p = Math.min(Math.max(1, initialPage ?? 1), Math.max(1, totalPages));
setCurrentPage(p);
}, [initialPage, totalPages]);
const handleSetPage = useCallback((p: number) => {
const next = Math.min(Math.max(1, Math.floor(p)), Math.max(1, totalPages));
setDirection(next > currentPage ? 1 : next < currentPage ? -1 : 0);
setCurrentPage(next);
if (typeof onPageChange === 'function') onPageChange(next);
}, [onPageChange, totalPages, currentPage]);
// helper to render page link
const renderPage = (pageNum: number) => (
<PaginationItem key={pageNum}>
<PaginationLink
isActive={pageNum === currentPage}
aria-current={pageNum === currentPage ? 'page' : undefined}
onClick={() => handleSetPage(pageNum)}
type="button"
>
{pageNum}
</PaginationLink>
</PaginationItem>
);
// if totalPages small, render all
if (totalPages <= buttonsToShow) {
return (
<Pagination>
<PaginationContent className="select-none">
<PaginationItem>
<PaginationPrevious
aria-disabled={currentPage === 1}
onClick={() => currentPage > 1 && handleSetPage(currentPage - 1)}
/>
</PaginationItem>
{Array.from({ length: totalPages }).map((_, i) => renderPage(i + 1))}
<PaginationItem>
<PaginationNext
aria-disabled={currentPage === totalPages}
onClick={() => currentPage < totalPages && handleSetPage(currentPage + 1)}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
);
}
// for larger totalPages, show: 1, 2 or ellipsis, center range, ellipsis or N-1, N
const centerCount = buttonsToShow - 4; // slots for center pages
let start = currentPage - Math.floor(centerCount / 2);
let end = start + centerCount - 1;
if (start < 3) {
start = 3;
end = start + centerCount - 1;
}
if (end > totalPages - 2) {
end = totalPages - 2;
start = end - (centerCount - 1);
}
const centerPages = [] as number[];
for (let i = start; i <= end; i++) centerPages.push(i);
return (
<div {...props}>
<Pagination >
<PaginationContent className="select-none">
<PaginationItem>
<PaginationPrevious aria-disabled={currentPage === 1} onClick={() => currentPage > 1 && handleSetPage(currentPage - 1)} />
</PaginationItem>
{renderPage(1)}
{/* second slot: either page 2 or ellipsis if center starts later */}
{start > 3 ? (
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
) : renderPage(2)}
{/* center pages */}
{centerPages.map((p) => (
<PaginationItem key={p}>
<PaginationLink
isActive={p === currentPage}
aria-current={p === currentPage ? 'page' : undefined}
onClick={() => handleSetPage(p)}
type="button"
>
{p}
</PaginationLink>
</PaginationItem>
))}
{end < totalPages - 2 ? (
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
) : renderPage(totalPages - 1)}
{renderPage(totalPages)}
<PaginationItem>
<PaginationNext aria-disabled={currentPage === totalPages} onClick={() => currentPage < totalPages && handleSetPage(currentPage + 1)} />
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
);
}

View File

@ -0,0 +1,88 @@
import { useDevice } from "@/contexts/device-context";
import { Sun, Moon, Monitor } from "lucide-react";
import { motion } from "motion/react";
import type React from "react";
import { cn } from "@/lib/utils";
type ThemeMode = "light" | "dark" | "system";
// PC端三状态轮换按钮
export function ThemeModeCycleButton(props: React.ButtonHTMLAttributes<HTMLButtonElement> & { mode: ThemeMode; setMode: (m: ThemeMode) => void }) {
const { mode, setMode, className, style, onClick, ...rest } = props;
const nextMode = (mode: ThemeMode): ThemeMode => {
if (mode === "light") return "dark";
if (mode === "dark") return "system";
return "light";
};
const icon = mode === "light" ? <Sun className="w-4 h-4" /> : mode === "dark" ? <Moon className="w-4 h-4" /> : <Monitor className="w-4 h-4" />;
const label = mode.charAt(0).toUpperCase() + mode.slice(1);
const baseCls = "flex items-center gap-2 px-2 py-2 rounded-full bg-muted hover:bg-accent border border-input text-sm font-medium transition-all";
const mergedClassName = cn(baseCls, className);
return (
<button
className={mergedClassName}
style={style}
onClick={(e) => {
setMode(nextMode(mode));
onClick?.(e);
}}
title={`切换主题(当前:${label}`}
{...rest}
>
{icon}
</button>
);
}
// 移动端:横向按钮组
export function ThemeModeSegmented(props: React.HTMLAttributes<HTMLDivElement> & { mode: ThemeMode; setMode: (m: ThemeMode) => void }) {
const { mode, setMode, className, style, ...rest } = props;
const modes: { value: ThemeMode; icon: React.ReactNode; label: string }[] = [
{ value: "light", icon: <Sun className="w-4 h-4" />, label: "Light" },
{ value: "system", icon: <Monitor className="w-4 h-4" />, label: "System" },
{ value: "dark", icon: <Moon className="w-4 h-4" />, label: "Dark" },
];
const activeIndex = modes.findIndex((m) => m.value === mode);
const baseCls = "relative inline-flex bg-muted rounded-full p-1 gap-1 overflow-hidden";
return (
<div className={cn("theme-mode-segmented-wrapper", className)} style={style} {...rest}>
<div className={baseCls}>
{/* 滑动高亮块 */}
<motion.div
layout
transition={{ type: "spring", stiffness: 400, damping: 30 }}
className="absolute w-12 h-8 rounded-full bg-white/70 shadow-sm z-1 top-1"
style={{
left: `calc(0.25rem + ${activeIndex} * (3rem + 0.25rem))`,
}}
/>
{modes.map((m) => (
<button
key={m.value}
className={cn(
"relative flex items-center justify-center w-12 h-8 rounded-full text-sm font-medium transition-all z-10",
mode === m.value ? "text-primary" : "text-muted-foreground"
)}
onClick={() => setMode(m.value)}
type="button"
>
{m.icon}
</button>
))}
</div>
</div>
);
}
// 总组件:根据设备类型渲染
export function ThemeModeToggle(props: React.HTMLAttributes<HTMLElement> = {}) {
const { isMobile, mode, setMode } = useDevice();
const Comp: React.ElementType = isMobile ? ThemeModeSegmented : ThemeModeCycleButton;
const { className, style } = props;
// 仅转发 className / style避免复杂的 prop 类型不匹配
return <Comp mode={mode} setMode={setMode} className={className} style={style} />;
}

View File

@ -3,7 +3,7 @@ import React from "react";
export default function Footer() {
return (
<footer className="w-full py-6 text-center text-sm text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700 mt-12">
<footer className="w-full py-6 text-center text-sm text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700">
© {new Date().getFullYear()} {config.metadata.name} · Powered by {config.owner.name} · {config.footer.text}
</footer>
);

View File

@ -12,13 +12,13 @@ import {
NavigationMenuTrigger,
navigationMenuTriggerStyle,
} from "@/components/ui/navigation-menu"
import GravatarAvatar from "@/components/common/gravatar"
import { useDevice } from "@/contexts/device-context"
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"
const navbarMenuComponents = [
{
@ -55,7 +55,7 @@ export function Navbar() {
<NavMenuCenter />
</div>
<div className="flex items-center justify-end space-x-2">
<Switch checked={mode === "dark"} onCheckedChange={(checked) => setMode(checked ? "dark" : "light")} />
<ThemeModeToggle className="hidden md:block" />
<SidebarMenuClientOnly />
</div>
</nav>
@ -169,8 +169,11 @@ function SidebarMenu() {
) : null
)}
</nav>
<div className="flex items-center justify-center p-4 border-t border-border">
<ThemeModeToggle/>
</div>
</SheetContent>
</Sheet></div>
)
}

View File

@ -0,0 +1,185 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@ -1,5 +1,8 @@
import { User } from "@/models/user";
import { UserHeader } from "./user-header";
export function UserPage({user}: {user: User}) {
return <div>User: {user.username}</div>;
}
return <div>
<UserHeader user={user} />
</div>;
}

View File

@ -0,0 +1,40 @@
"use client"
import { User } from "@/models/user";
import GravatarAvatar from "@/components/common/gravatar";
import { Mail, User as UserIcon, Shield } from 'lucide-react';
export function UserHeader({ user }: { user: User }) {
return (
<div className="flex flex-col md:flex-row items-center md:items-center h-auto md:h-60">
{/* 左侧 30%(头像容器) */}
<div className="md:basis-[20%] flex justify-center items-center p-4">
{/* wrapper 控制显示大小,父组件给具体 w/h */}
<div className="w-40 h-40 md:w-48 md:h-48 relative">
<GravatarAvatar className="rounded-full w-full h-full" url={user.avatarUrl} email={user.email} size={200} />
</div>
</div>
{/* 右侧 70%(信息区) */}
<div className="md:basis-[70%] p-4 flex flex-col justify-center space-y-2">
<h2 className="text-2xl font-bold mt-0">{user.nickname}</h2>
<p className="text-gray-500 dark:text-gray-400 mt-1">@{user.username}</p>
<div className="flex items-center text-sm text-slate-600 dark:text-slate-400">
<UserIcon className="w-4 h-4 mr-2" />
<span>{user.gender || '未填写'}</span>
</div>
<div className="flex items-center text-sm text-slate-600 dark:text-slate-400">
<Mail className="w-4 h-4 mr-2" />
<span>{user.email || '未填写'}</span>
</div>
<div className="flex items-center text-sm text-slate-600 dark:text-slate-400">
<Shield className="w-4 h-4 mr-2" />
<span>{user.role || '访客'}</span>
</div>
{/* 其他简介、按钮等放这里 */}
</div>
</div>
);
}

View File

@ -1,11 +0,0 @@
"use client"
import { User } from "@/models/user";
import GravatarAvatar from "@/components/common/gravatar";
export function UserProfile({ user }: { user: User }) {
return (
<div className="flex">
<GravatarAvatar email={user.email} size={120}/>
</div>
);
}

View File

@ -14,7 +14,7 @@ const config = {
},
bodyWidth: "80vw",
bodyWidthMobile: "100vw",
postsPerPage: 12,
postsPerPage: 9,
commentsPerPage: 8,
animationDurationSecond: 0.618,
footer: {

6
web/src/constant.ts Normal file
View File

@ -0,0 +1,6 @@
export enum QueryKey {
SortBy = "sort_by",
Page = "page",
Label = "label",
Keywords = "keywords",
};

View File

@ -2,7 +2,7 @@
import React, { createContext, useCallback, useContext, useEffect, useState } from "react";
type Mode = "light" | "dark";
type Mode = "light" | "dark" | "system";
interface DeviceContextProps {
isMobile: boolean;
@ -19,7 +19,7 @@ interface DeviceContextProps {
const DeviceContext = createContext<DeviceContextProps>({
isMobile: false,
mode: "light",
mode: "system",
setMode: () => {},
toggleMode: () => {},
viewport: {
@ -32,7 +32,7 @@ const DeviceContext = createContext<DeviceContextProps>({
export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isMobile, setIsMobile] = useState(false);
const [mode, setModeState] = useState<Mode>("light");
const [mode, setModeState] = useState<Mode>("system");
const [viewport, setViewport] = useState({
width: typeof window !== "undefined" ? window.innerWidth : 0,
height: typeof window !== "undefined" ? window.innerHeight : 0,
@ -45,6 +45,18 @@ export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ childr
? "dark"
: "light";
// 应用主题到 document
const applyTheme = useCallback(
(theme: Mode) => {
let effectiveTheme = theme;
if (theme === "system") {
effectiveTheme = getSystemTheme();
}
document.documentElement.classList.toggle("dark", effectiveTheme === "dark");
},
[]
);
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
checkMobile();
@ -70,47 +82,48 @@ export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ childr
useEffect(() => {
if (typeof window !== "undefined") {
const savedTheme = localStorage.getItem("theme") as Mode | null;
const systemTheme = getSystemTheme();
const theme = savedTheme || systemTheme;
const theme = savedTheme || "system";
setModeState(theme);
document.documentElement.classList.toggle("dark", theme === "dark");
applyTheme(theme);
// 监听系统主题变动
const media = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = (e: MediaQueryListEvent) => {
if (!localStorage.getItem("theme")) {
const newTheme = e.matches ? "dark" : "light";
setModeState(newTheme);
document.documentElement.classList.toggle("dark", newTheme === "dark");
if (!localStorage.getItem("theme") || localStorage.getItem("theme") === "system") {
applyTheme("system");
}
};
media.addEventListener("change", handleChange);
return () => media.removeEventListener("change", handleChange);
}
}, []);
}, [applyTheme]);
const setMode = useCallback((newMode: Mode) => {
setModeState(newMode);
document.documentElement.classList.toggle("dark", newMode === "dark");
if (newMode === getSystemTheme()) {
applyTheme(newMode);
if (newMode === "system") {
localStorage.removeItem("theme");
} else {
localStorage.setItem("theme", newMode);
}
}, []);
}, [applyTheme]);
// 支持三种状态的切换light -> dark -> system -> light ...
const toggleMode = useCallback(() => {
setModeState((prev) => {
const newMode = prev === "dark" ? "light" : "dark";
document.documentElement.classList.toggle("dark", newMode === "dark");
if (newMode === getSystemTheme()) {
let newMode: Mode;
if (prev === "light") newMode = "dark";
else if (prev === "dark") newMode = "system";
else newMode = "light";
applyTheme(newMode);
if (newMode === "system") {
localStorage.removeItem("theme");
} else {
localStorage.setItem("theme", newMode);
}
return newMode;
});
}, []);
}, [applyTheme]);
return (
<DeviceContext.Provider

View File

@ -1,18 +0,0 @@
import { useEffect, useState } from "react";
export function useDevice() {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
// 简单判断移动端
const check = () => {
const ua = navigator.userAgent;
setIsMobile(/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua));
};
check();
window.addEventListener("resize", check);
return () => window.removeEventListener("resize", check);
}, []);
return { isMobile };
}

View File

@ -1,35 +1,35 @@
import { useState, useEffect, useCallback } from 'react';
export function useStoredState<T>(key: string, defaultValue: T) {
const [value, setValue] = useState<T>(defaultValue);
const [isLoaded, setIsLoaded] = useState(false);
const [value, setValue] = useState<T>(defaultValue);
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
useEffect(() => {
try {
const stored = localStorage.getItem(key);
if (stored) {
try {
const stored = localStorage.getItem(key);
if (stored) {
try {
setValue(JSON.parse(stored));
} catch {
setValue(stored as T);
}
}
} catch (error) {
console.error('Error reading from localStorage:', error);
} finally {
setIsLoaded(true);
setValue(JSON.parse(stored));
} catch {
setValue(stored as T);
}
}, [key]);
}
} catch (error) {
console.error('Error reading from localStorage:', error);
} finally {
setIsLoaded(true);
}
}, [key]);
// 使用 useCallback 确保 setter 函数引用稳定
const setStoredValue = useCallback((newValue: T) => {
setValue(newValue);
try {
localStorage.setItem(key, typeof newValue === 'string' ? newValue : JSON.stringify(newValue));
} catch (error) {
console.error('Error writing to localStorage:', error);
}
}, [key]);
// 使用 useCallback 确保 setter 函数引用稳定
const setStoredValue = useCallback((newValue: T) => {
setValue(newValue);
try {
localStorage.setItem(key, typeof newValue === 'string' ? newValue : JSON.stringify(newValue));
} catch (error) {
console.error('Error writing to localStorage:', error);
}
}, [key]);
return [value, setStoredValue, isLoaded] as const;
return [value, setStoredValue, isLoaded] as const;
}

View File

@ -2,6 +2,15 @@
"HomePage": {
"title": "Hello world!"
},
"BlogHome": {
"hottest": "热门",
"hottest_posts": "热门文章",
"latest": "最新",
"latest_posts": "最新文章",
"loading": "加载中...",
"load_more": "加载更多",
"no_more": "没有更多了!"
},
"Captcha": {
"doing": "正在检查你是不是个人...",
"error": "验证失败",