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"), TargetType: c.Query("target_type"),
CommentID: commentID, CommentID: commentID,
} }
resp, err := cc.service.GetCommentList(ctx, &req) commentDtos, err := cc.service.GetCommentList(ctx, &req)
if err != nil { if err != nil {
serviceErr := errs.AsServiceError(err) serviceErr := errs.AsServiceError(err)
resps.Custom(c, serviceErr.Code, serviceErr.Message, nil) resps.Custom(c, serviceErr.Code, serviceErr.Message, nil)
return 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) { func (cc *CommentController) ReactComment(ctx context.Context, c *app.RequestContext) {

View File

@ -104,18 +104,47 @@ func (p *PostController) List(ctx context.Context, c *app.RequestContext) {
} }
keywords := c.Query("keywords") keywords := c.Query("keywords")
keywordsArray := strings.Split(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{ req := &dto.ListPostReq{
Keywords: keywordsArray, Keywords: keywordsArray,
Labels: labelDtos,
LabelRule: labelRule,
Page: pagination.Page, Page: pagination.Page,
Size: pagination.Size, Size: pagination.Size,
OrderBy: pagination.OrderBy, OrderBy: pagination.OrderBy,
Desc: pagination.Desc, Desc: pagination.Desc,
} }
posts, err := p.service.ListPosts(ctx, req) posts, total, err := p.service.ListPosts(ctx, req)
if err != nil { if err != nil {
serviceErr := errs.AsServiceError(err) serviceErr := errs.AsServiceError(err)
resps.Custom(c, serviceErr.Code, serviceErr.Message, nil) resps.Custom(c, serviceErr.Code, serviceErr.Message, nil)
return return
} }
resps.Ok(c, resps.Success, posts) resps.Ok(c, resps.Success, utils.H{"posts": posts, "total": total})
} }

View File

@ -36,6 +36,8 @@ type ListPostReq struct {
Page uint64 `json:"page"` // 页码 Page uint64 `json:"page"` // 页码
Size uint64 `json:"size"` Size uint64 `json:"size"`
Desc bool `json:"desc"` Desc bool `json:"desc"`
Labels []LabelDto `json:"labels"`
LabelRule string `json:"label_rule"` // 标签过滤规则 union or intersection
} }
type ListPostResp struct { type ListPostResp struct {

View File

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

View File

@ -18,6 +18,26 @@ func (l *labelRepo) GetLabelByKey(key string) (*model.Label, error) {
return &label, nil 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) { func (l *labelRepo) GetLabelByID(id string) (*model.Label, error) {
var label model.Label var label model.Label
if err := GetDB().Where("id = ?", id).First(&label).Error; err != nil { if err := GetDB().Where("id = ?", id).First(&label).Error; err != nil {

View File

@ -1,12 +1,15 @@
package repo package repo
import ( import (
"errors"
"net/http" "net/http"
"slices" "slices"
"github.com/snowykami/neo-blog/internal/dto"
"github.com/snowykami/neo-blog/internal/model" "github.com/snowykami/neo-blog/internal/model"
"github.com/snowykami/neo-blog/pkg/constant" "github.com/snowykami/neo-blog/pkg/constant"
"github.com/snowykami/neo-blog/pkg/errs" "github.com/snowykami/neo-blog/pkg/errs"
"gorm.io/gorm"
) )
type postRepo struct{} type postRepo struct{}
@ -48,9 +51,9 @@ func (p *postRepo) UpdatePost(post *model.Post) error {
return nil 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) { 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") query := GetDB().Model(&model.Post{}).Preload("User")
if currentUserID > 0 { if currentUserID > 0 {
@ -58,20 +61,43 @@ func (p *postRepo) ListPosts(currentUserID uint, keywords []string, page, size u
} else { } else {
query = query.Where("is_private = ?", false) 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 { if len(keywords) > 0 {
for _, keyword := range keywords { for _, keyword := range keywords {
if keyword != "" { if keyword != "" {
// 使用LIKE进行模糊匹配搜索标题、内容和标签 query = query.Where("title LIKE ? OR content LIKE ?",
query = query.Where("title LIKE ? OR content LIKE ?", // TODO: 支持标签搜索
"%"+keyword+"%", "%"+keyword+"%") "%"+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) items, _, err := PaginateQuery[model.Post](query, page, size, orderBy, desc)
if err != nil { 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) { 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 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) 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.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 { 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 { for _, post := range posts {
postDtos = append(postDtos, post.ToDtoWithShortContent(100)) postDtos = append(postDtos, post.ToDtoWithShortContent(100))
} }
return postDtos, nil return postDtos, total, nil
} }
func (p *PostService) ToggleLikePost(ctx context.Context, id string) (bool, error) { func (p *PostService) ToggleLikePost(ctx context.Context, id string) (bool, error) {

View File

@ -15,6 +15,7 @@
"@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-navigation-menu": "^1.2.13", "@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-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-switch": "^1.2.5",
@ -23,7 +24,6 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"deepmerge": "^4.3.1", "deepmerge": "^4.3.1",
"field-conv": "^1.0.9", "field-conv": "^1.0.9",
"framer-motion": "^12.23.9",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"motion": "^12.23.12", "motion": "^12.23.12",

230
web/pnpm-lock.yaml generated
View File

@ -26,6 +26,9 @@ importers:
'@radix-ui/react-navigation-menu': '@radix-ui/react-navigation-menu':
specifier: ^1.2.13 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) 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': '@radix-ui/react-separator':
specifier: ^1.1.7 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) 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: field-conv:
specifier: ^1.0.9 specifier: ^1.0.9
version: 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: highlight.js:
specifier: ^11.11.1 specifier: ^11.11.1
version: 11.11.1 version: 11.11.1
@ -193,6 +193,21 @@ packages:
resolution: {integrity: sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==} resolution: {integrity: sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 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': '@formatjs/ecma402-abstract@2.3.4':
resolution: {integrity: sha512-qrycXDeaORzIqNhBOx0btnhpD1c+/qFIHAN9znofuMJX6QBwtbrmlpWfD4oiUUD2vJUOIYFA/gYtg2KAMGG7sA==} resolution: {integrity: sha512-qrycXDeaORzIqNhBOx0btnhpD1c+/qFIHAN9znofuMJX6QBwtbrmlpWfD4oiUUD2vJUOIYFA/gYtg2KAMGG7sA==}
@ -467,12 +482,28 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'} engines: {node: '>=12.4.0'}
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
'@radix-ui/primitive@1.1.2': '@radix-ui/primitive@1.1.2':
resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==} resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==}
'@radix-ui/primitive@1.1.3': '@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} 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': '@radix-ui/react-checkbox@1.3.3':
resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==}
peerDependencies: peerDependencies:
@ -552,6 +583,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true 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': '@radix-ui/react-focus-guards@1.1.2':
resolution: {integrity: sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==} resolution: {integrity: sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==}
peerDependencies: peerDependencies:
@ -561,6 +605,15 @@ packages:
'@types/react': '@types/react':
optional: true 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': '@radix-ui/react-focus-scope@1.1.7':
resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==}
peerDependencies: peerDependencies:
@ -609,6 +662,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true 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': '@radix-ui/react-portal@1.1.9':
resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
peerDependencies: peerDependencies:
@ -661,6 +727,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true 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': '@radix-ui/react-separator@1.1.7':
resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==} resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==}
peerDependencies: peerDependencies:
@ -750,6 +829,15 @@ packages:
'@types/react': '@types/react':
optional: true 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': '@radix-ui/react-use-size@1.1.1':
resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==}
peerDependencies: peerDependencies:
@ -772,6 +860,9 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
'@rtsao/scc@1.1.0': '@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
@ -1619,20 +1710,6 @@ packages:
react-dom: react-dom:
optional: true 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: function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
@ -2187,9 +2264,6 @@ packages:
motion-dom@12.23.12: motion-dom@12.23.12:
resolution: {integrity: sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==} resolution: {integrity: sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==}
motion-dom@12.23.9:
resolution: {integrity: sha512-6Sv++iWS8XMFCgU1qwKj9l4xuC47Hp4+2jvPfyTXkqDg2tTzSgX6nWKD4kNFXk0k7llO59LZTPuJigza4A2K1A==}
motion-utils@12.23.6: motion-utils@12.23.6:
resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==} resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==}
@ -2921,6 +2995,23 @@ snapshots:
'@eslint/core': 0.15.1 '@eslint/core': 0.15.1
levn: 0.4.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': '@formatjs/ecma402-abstract@2.3.4':
dependencies: dependencies:
'@formatjs/fast-memoize': 2.2.7 '@formatjs/fast-memoize': 2.2.7
@ -3169,10 +3260,21 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {} '@nolyfill/is-core-module@1.0.39': {}
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.2': {} '@radix-ui/primitive@1.1.2': {}
'@radix-ui/primitive@1.1.3': {} '@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)': '@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: dependencies:
'@radix-ui/primitive': 1.1.3 '@radix-ui/primitive': 1.1.3
@ -3254,12 +3356,31 @@ snapshots:
'@types/react': 19.1.8 '@types/react': 19.1.8
'@types/react-dom': 19.1.6(@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)': '@radix-ui/react-focus-guards@1.1.2(@types/react@19.1.8)(react@19.1.0)':
dependencies: dependencies:
react: 19.1.0 react: 19.1.0
optionalDependencies: optionalDependencies:
'@types/react': 19.1.8 '@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)': '@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: dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) '@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': 19.1.8
'@types/react-dom': 19.1.6(@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)': '@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: 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) '@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': 19.1.8
'@types/react-dom': 19.1.6(@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)': '@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: 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) '@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: optionalDependencies:
'@types/react': 19.1.8 '@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)': '@radix-ui/react-use-size@1.1.1(@types/react@19.1.8)(react@19.1.0)':
dependencies: dependencies:
'@radix-ui/react-use-layout-effect': 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)
@ -3435,6 +3610,8 @@ snapshots:
'@types/react': 19.1.8 '@types/react': 19.1.8
'@types/react-dom': 19.1.6(@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': {} '@rtsao/scc@1.1.0': {}
'@rushstack/eslint-patch@1.12.0': {} '@rushstack/eslint-patch@1.12.0': {}
@ -4442,15 +4619,6 @@ snapshots:
react: 19.1.0 react: 19.1.0
react-dom: 19.1.0(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-bind@1.1.2: {}
function.prototype.name@1.1.8: function.prototype.name@1.1.8:
@ -5220,10 +5388,6 @@ snapshots:
dependencies: dependencies:
motion-utils: 12.23.6 motion-utils: 12.23.6
motion-dom@12.23.9:
dependencies:
motion-utils: 12.23.6
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): 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 replyId: number | null
isPrivate: boolean isPrivate: boolean
} }
): Promise<BaseResponse<{id: number}>> { ): Promise<BaseResponse<{ id: number }>> {
const res = await axiosClient.post<BaseResponse<{id: number}>>('/comment/c', { const res = await axiosClient.post<BaseResponse<{ id: number }>>('/comment/c', {
targetType, targetType,
targetId, targetId,
content, content,
@ -68,7 +68,7 @@ export async function listComments({
commentId: number commentId: number
} & PaginationParams } & PaginationParams
) { ) {
const res = await axiosClient.get<BaseResponse<Comment[]>>(`/comment/list`, { const res = await axiosClient.get<BaseResponse<{ "comments": Comment[] }>>(`/comment/list`, {
params: { params: {
targetType, targetType,
targetId, targetId,

View File

@ -1,7 +1,7 @@
import type { Post } from '@/models/post' import type { Post } from '@/models/post'
import type { BaseResponse } from '@/models/resp' import type { BaseResponse } from '@/models/resp'
import axiosClient from './client' 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> { 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({ export async function listPosts({
page = 1, page = 1,
size = 10, size = 10,
orderBy = 'updated_at', orderBy = OrderBy.CreatedAt,
desc = false, desc = false,
keywords = '', keywords = '',
}: ListPostsParams = {}): Promise<BaseResponse<Post[]>> { labels = '',
const res = await axiosClient.get<BaseResponse<Post[]>>('/post/list', { 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: { params: {
page, page,
size, size,
orderBy, orderBy,
desc, desc,
keywords, keywords,
labels,
labelRule
}, },
}) })
return res.data return res.data

View File

@ -1,8 +1,8 @@
'use client' 'use client'
import { motion } from 'framer-motion' import { motion } from 'motion/react'
import { usePathname } from 'next/navigation' 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 { BackgroundProvider } from '@/contexts/background-context'
import Footer from '@/components/layout/footer' import Footer from '@/components/layout/footer'
import config from '@/config' import config from '@/config'
@ -12,7 +12,6 @@ export default function RootLayout({
}: Readonly<{ }: Readonly<{
children: React.ReactNode children: React.ReactNode
}>) { }>) {
const pathname = usePathname()
return ( return (
<> <>
<motion.nav <motion.nav

View File

@ -132,3 +132,23 @@ html, body {
.sonner-toast { .sonner-toast {
background-color: aqua; 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

@ -7,15 +7,14 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
import config from '@/config' import config from '@/config'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { getPostHref } from '@/utils/common/post' import { getPostHref } from '@/utils/common/post'
import { motion } from 'framer-motion' import { motion } from 'motion/react'
import { deceleration } from '@/motion/curve' import { deceleration } from '@/motion/curve'
interface BlogCardProps {
export function BlogCard({ post, className }: {
post: Post post: Post
className?: string className?: string
} }) {
export function BlogCard({ post, className }: BlogCardProps) {
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
const date = new Date(dateString) const date = new Date(dateString)
return date.toLocaleDateString('zh-CN', { return date.toLocaleDateString('zh-CN', {
@ -210,7 +209,7 @@ export function BlogCardGrid({
if (isLoading) { if (isLoading) {
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <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} /> <BlogCardSkeleton key={index} />
))} ))}
</div> </div>

View File

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

View File

@ -34,7 +34,7 @@ export function SidebarAbout({ config }: { config: typeof configType }) {
<CardContent> <CardContent>
<div className="text-center mb-4"> <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"> <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> </div>
<h3 className="font-semibold text-lg">{config.owner.name}</h3> <h3 className="font-semibold text-lg">{config.owner.name}</h3>
<p className="text-sm text-slate-600">{config.owner.motto}</p> <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="fade-in-up">
<div className="flex py-4 fade-in"> <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"> <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" />} {!user && <CircleUser className="w-full h-full fade-in" />}
</div> </div>
<div className="flex-1 pl-2 fade-in-up"> <div className="flex-1 pl-2 fade-in-up">

View File

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

View File

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

View File

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

View File

@ -12,13 +12,13 @@ import {
NavigationMenuTrigger, NavigationMenuTrigger,
navigationMenuTriggerStyle, navigationMenuTriggerStyle,
} from "@/components/ui/navigation-menu" } from "@/components/ui/navigation-menu"
import GravatarAvatar from "@/components/common/gravatar"
import { useDevice } from "@/contexts/device-context" import { useDevice } from "@/contexts/device-context"
import config from "@/config" import config from "@/config"
import { useState } from "react" import { useState } from "react"
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet" import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
import { Menu } from "lucide-react" import { Menu } from "lucide-react"
import { Switch } from "../ui/switch" import { Switch } from "../ui/switch"
import { ThemeModeToggle } from "../common/theme-toggle"
const navbarMenuComponents = [ const navbarMenuComponents = [
{ {
@ -55,7 +55,7 @@ export function Navbar() {
<NavMenuCenter /> <NavMenuCenter />
</div> </div>
<div className="flex items-center justify-end space-x-2"> <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 /> <SidebarMenuClientOnly />
</div> </div>
</nav> </nav>
@ -169,8 +169,11 @@ function SidebarMenu() {
) : null ) : null
)} )}
</nav> </nav>
<div className="flex items-center justify-center p-4 border-t border-border">
<ThemeModeToggle/>
</div>
</SheetContent> </SheetContent>
</Sheet></div> </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 { User } from "@/models/user";
import { UserHeader } from "./user-header";
export function UserPage({user}: {user: User}) { 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", bodyWidth: "80vw",
bodyWidthMobile: "100vw", bodyWidthMobile: "100vw",
postsPerPage: 12, postsPerPage: 9,
commentsPerPage: 8, commentsPerPage: 8,
animationDurationSecond: 0.618, animationDurationSecond: 0.618,
footer: { 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"; import React, { createContext, useCallback, useContext, useEffect, useState } from "react";
type Mode = "light" | "dark"; type Mode = "light" | "dark" | "system";
interface DeviceContextProps { interface DeviceContextProps {
isMobile: boolean; isMobile: boolean;
@ -19,7 +19,7 @@ interface DeviceContextProps {
const DeviceContext = createContext<DeviceContextProps>({ const DeviceContext = createContext<DeviceContextProps>({
isMobile: false, isMobile: false,
mode: "light", mode: "system",
setMode: () => {}, setMode: () => {},
toggleMode: () => {}, toggleMode: () => {},
viewport: { viewport: {
@ -32,7 +32,7 @@ const DeviceContext = createContext<DeviceContextProps>({
export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
const [mode, setModeState] = useState<Mode>("light"); const [mode, setModeState] = useState<Mode>("system");
const [viewport, setViewport] = useState({ const [viewport, setViewport] = useState({
width: typeof window !== "undefined" ? window.innerWidth : 0, width: typeof window !== "undefined" ? window.innerWidth : 0,
height: typeof window !== "undefined" ? window.innerHeight : 0, height: typeof window !== "undefined" ? window.innerHeight : 0,
@ -45,6 +45,18 @@ export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ childr
? "dark" ? "dark"
: "light"; : "light";
// 应用主题到 document
const applyTheme = useCallback(
(theme: Mode) => {
let effectiveTheme = theme;
if (theme === "system") {
effectiveTheme = getSystemTheme();
}
document.documentElement.classList.toggle("dark", effectiveTheme === "dark");
},
[]
);
useEffect(() => { useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth <= 768); const checkMobile = () => setIsMobile(window.innerWidth <= 768);
checkMobile(); checkMobile();
@ -70,47 +82,48 @@ export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ childr
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const savedTheme = localStorage.getItem("theme") as Mode | null; const savedTheme = localStorage.getItem("theme") as Mode | null;
const systemTheme = getSystemTheme(); const theme = savedTheme || "system";
const theme = savedTheme || systemTheme;
setModeState(theme); setModeState(theme);
document.documentElement.classList.toggle("dark", theme === "dark"); applyTheme(theme);
// 监听系统主题变动 // 监听系统主题变动
const media = window.matchMedia("(prefers-color-scheme: dark)"); const media = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = (e: MediaQueryListEvent) => { const handleChange = (e: MediaQueryListEvent) => {
if (!localStorage.getItem("theme")) { if (!localStorage.getItem("theme") || localStorage.getItem("theme") === "system") {
const newTheme = e.matches ? "dark" : "light"; applyTheme("system");
setModeState(newTheme);
document.documentElement.classList.toggle("dark", newTheme === "dark");
} }
}; };
media.addEventListener("change", handleChange); media.addEventListener("change", handleChange);
return () => media.removeEventListener("change", handleChange); return () => media.removeEventListener("change", handleChange);
} }
}, []); }, [applyTheme]);
const setMode = useCallback((newMode: Mode) => { const setMode = useCallback((newMode: Mode) => {
setModeState(newMode); setModeState(newMode);
document.documentElement.classList.toggle("dark", newMode === "dark"); applyTheme(newMode);
if (newMode === getSystemTheme()) { if (newMode === "system") {
localStorage.removeItem("theme"); localStorage.removeItem("theme");
} else { } else {
localStorage.setItem("theme", newMode); localStorage.setItem("theme", newMode);
} }
}, []); }, [applyTheme]);
// 支持三种状态的切换light -> dark -> system -> light ...
const toggleMode = useCallback(() => { const toggleMode = useCallback(() => {
setModeState((prev) => { setModeState((prev) => {
const newMode = prev === "dark" ? "light" : "dark"; let newMode: Mode;
document.documentElement.classList.toggle("dark", newMode === "dark"); if (prev === "light") newMode = "dark";
if (newMode === getSystemTheme()) { else if (prev === "dark") newMode = "system";
else newMode = "light";
applyTheme(newMode);
if (newMode === "system") {
localStorage.removeItem("theme"); localStorage.removeItem("theme");
} else { } else {
localStorage.setItem("theme", newMode); localStorage.setItem("theme", newMode);
} }
return newMode; return newMode;
}); });
}, []); }, [applyTheme]);
return ( return (
<DeviceContext.Provider <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

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