From 9ca307f4d97829f76c1b40ca5705729dd6fec971 Mon Sep 17 00:00:00 2001 From: Snowykami Date: Thu, 24 Jul 2025 09:22:50 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20feat:=20add=20main=20page?= =?UTF-8?q?=20layout=20with=20navigation=20and=20footer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: create random labels page feat: implement login page with OpenID Connect support feat: add Gravatar component for user avatars feat: create Navbar component with navigation menu chore: create Sidebar component placeholder feat: implement login form with OIDC and email/password options feat: add reusable button component feat: create card component for structured content display feat: implement input component for forms feat: create label component for form labels feat: add navigation menu component for site navigation chore: add configuration file for site metadata feat: implement device context for responsive design feat: add utility functions for class name management feat: define OIDC configuration model feat: define base response model for API responses feat: define user model for user data feat: implement i18n for internationalization support feat: add English and Chinese translations for login chore: create index for locale resources chore: add blog home view placeholder --- internal/controller/v1/admin.go | 22 +- internal/controller/v1/post.go | 62 ++- internal/controller/v1/user.go | 24 +- internal/ctxutils/pagenation.go | 45 ++ internal/ctxutils/user.go | 9 + internal/dto/admin.go | 1 + internal/dto/post.go | 30 +- internal/middleware/auth.go | 2 +- internal/model/category.go | 8 + internal/model/label.go | 23 +- internal/model/oidc_config.go | 17 +- internal/model/post.go | 57 ++- internal/repo/init.go | 1 + internal/repo/oidc_config.go | 2 +- internal/repo/post.go | 31 +- internal/router/apiv1/user.go | 1 + internal/service/console.go | 2 + internal/service/post.go | 119 ++++- internal/service/user.go | 55 +- internal/tasks/session_clear.go | 3 + internal/tasks/task_manager.go | 5 + pkg/constant/constant.go | 46 +- pkg/resps/resps.go | 6 +- pkg/utils/oidc.go | 1 - web/components.json | 21 + web/next.config.ts | 27 +- web/package.json | 23 +- web/pnpm-lock.yaml | 581 ++++++++++++++++++++++ web/src/api/client.ts | 31 ++ web/src/api/user.ts | 56 +++ web/src/app/(main)/archives/page.tsx | 9 + web/src/app/(main)/layout.tsx | 18 + web/src/app/{ => (main)}/page.tsx | 3 +- web/src/app/(main)/random/page.tsx | 8 + web/src/app/globals.css | 124 ++++- web/src/app/layout.tsx | 6 +- web/src/app/login/page.tsx | 27 + web/src/components/Gravatar.tsx | 82 +++ web/src/components/Navbar.tsx | 134 +++++ web/src/components/Sidebar.tsx | 0 web/src/components/login-form.tsx | 197 ++++++++ web/src/components/ui/button.tsx | 59 +++ web/src/components/ui/card.tsx | 92 ++++ web/src/components/ui/input.tsx | 21 + web/src/components/ui/label.tsx | 24 + web/src/components/ui/navigation-menu.tsx | 168 +++++++ web/src/config.ts | 8 + web/src/contexts/DeviceContext.tsx | 142 ++++++ web/src/lib/utils.ts | 6 + web/src/models/oidc-config.ts | 12 + web/src/models/resp.ts | 5 + web/src/models/user.ts | 9 + web/src/utils/i18n/index.ts | 27 + web/src/utils/i18n/locales/en-us.ts | 30 ++ web/src/utils/i18n/locales/index.ts | 9 + web/src/utils/i18n/locales/zh-cn.ts | 30 ++ web/src/views/BlogHome.tsx | 0 57 files changed, 2453 insertions(+), 108 deletions(-) create mode 100644 web/components.json create mode 100644 web/src/api/client.ts create mode 100644 web/src/api/user.ts create mode 100644 web/src/app/(main)/archives/page.tsx create mode 100644 web/src/app/(main)/layout.tsx rename web/src/app/{ => (main)}/page.tsx (97%) create mode 100644 web/src/app/(main)/random/page.tsx create mode 100644 web/src/app/login/page.tsx create mode 100644 web/src/components/Gravatar.tsx create mode 100644 web/src/components/Navbar.tsx create mode 100644 web/src/components/Sidebar.tsx create mode 100644 web/src/components/login-form.tsx create mode 100644 web/src/components/ui/button.tsx create mode 100644 web/src/components/ui/card.tsx create mode 100644 web/src/components/ui/input.tsx create mode 100644 web/src/components/ui/label.tsx create mode 100644 web/src/components/ui/navigation-menu.tsx create mode 100644 web/src/config.ts create mode 100644 web/src/contexts/DeviceContext.tsx create mode 100644 web/src/lib/utils.ts create mode 100644 web/src/models/oidc-config.ts create mode 100644 web/src/models/resp.ts create mode 100644 web/src/models/user.ts create mode 100644 web/src/utils/i18n/index.ts create mode 100644 web/src/utils/i18n/locales/en-us.ts create mode 100644 web/src/utils/i18n/locales/index.ts create mode 100644 web/src/utils/i18n/locales/zh-cn.ts create mode 100644 web/src/views/BlogHome.tsx diff --git a/internal/controller/v1/admin.go b/internal/controller/v1/admin.go index af2c0d1..415bbcf 100644 --- a/internal/controller/v1/admin.go +++ b/internal/controller/v1/admin.go @@ -7,6 +7,7 @@ import ( "github.com/snowykami/neo-blog/internal/service" "github.com/snowykami/neo-blog/pkg/errs" "github.com/snowykami/neo-blog/pkg/resps" + "strconv" ) type AdminController struct { @@ -22,7 +23,7 @@ func NewAdminController() *AdminController { func (cc *AdminController) CreateOidc(ctx context.Context, c *app.RequestContext) { var adminCreateOidcReq dto.AdminOidcConfigDto if err := c.BindAndValidate(&adminCreateOidcReq); err != nil { - c.JSON(400, map[string]string{"error": "Invalid parameters"}) + resps.BadRequest(c, resps.ErrParamInvalid) return } err := cc.service.CreateOidcConfig(&adminCreateOidcReq) @@ -77,12 +78,23 @@ func (cc *AdminController) ListOidc(ctx context.Context, c *app.RequestContext) } func (cc *AdminController) UpdateOidc(ctx context.Context, c *app.RequestContext) { - var adminUpdateOidcReq dto.AdminOidcConfigDto - if err := c.BindAndValidate(&adminUpdateOidcReq); err != nil { - c.JSON(400, map[string]string{"error": "Invalid parameters"}) + id := c.Param("id") + if id == "" { + resps.BadRequest(c, resps.ErrParamInvalid) return } - err := cc.service.UpdateOidcConfig(&adminUpdateOidcReq) + idInt, err := strconv.Atoi(id) + if err != nil || idInt <= 0 { + resps.BadRequest(c, resps.ErrParamInvalid) + return + } + var adminUpdateOidcReq dto.AdminOidcConfigDto + if err := c.BindAndValidate(&adminUpdateOidcReq); err != nil { + resps.BadRequest(c, resps.ErrParamInvalid) + return + } + adminUpdateOidcReq.ID = uint(idInt) + err = cc.service.UpdateOidcConfig(&adminUpdateOidcReq) if err != nil { serviceErr := errs.AsServiceError(err) resps.Custom(c, serviceErr.Code, serviceErr.Message, nil) diff --git a/internal/controller/v1/post.go b/internal/controller/v1/post.go index 92e0ee4..4cfa156 100644 --- a/internal/controller/v1/post.go +++ b/internal/controller/v1/post.go @@ -3,10 +3,14 @@ package v1 import ( "context" "github.com/cloudwego/hertz/pkg/app" + "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" + "slices" + "strings" ) type PostController struct { @@ -24,7 +28,7 @@ func (p *PostController) Create(ctx context.Context, c *app.RequestContext) { if err := c.BindAndValidate(&req); err != nil { resps.BadRequest(c, resps.ErrParamInvalid) } - if err := p.service.CreatePost(&req); err != nil { + if err := p.service.CreatePost(ctx, &req); err != nil { serviceErr := errs.AsServiceError(err) resps.Custom(c, serviceErr.Code, serviceErr.Message, nil) return @@ -47,13 +51,63 @@ func (p *PostController) Delete(ctx context.Context, c *app.RequestContext) { } func (p *PostController) Get(ctx context.Context, c *app.RequestContext) { - // TODO: Impl + 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) { - // TODO: Impl + 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 + } + if err := p.service.UpdatePost(ctx, id, &req); err != nil { + serviceErr := errs.AsServiceError(err) + resps.Custom(c, serviceErr.Code, serviceErr.Message, nil) + return + } + resps.Ok(c, resps.Success, nil) } func (p *PostController) List(ctx context.Context, c *app.RequestContext) { - // TODO: Impl + pagination := ctxutils.GetPaginationParams(c) + if pagination.OrderedBy != "" && !slices.Contains(constant.OrderedByEnumPost, pagination.OrderedBy) { + resps.BadRequest(c, "无效的排序字段") + return + } + keywords := c.Query("keywords") + keywordsArray := strings.Split(keywords, ",") + req := &dto.ListPostReq{ + Keywords: keywordsArray, + Page: pagination.Page, + Size: pagination.Size, + OrderedBy: pagination.OrderedBy, + Reverse: pagination.Reverse, + } + resp, err := p.service.ListPosts(ctx, req) + if err != nil { + serviceErr := errs.AsServiceError(err) + resps.Custom(c, serviceErr.Code, serviceErr.Message, nil) + return + } + resps.Ok(c, resps.Success, resp) } diff --git a/internal/controller/v1/user.go b/internal/controller/v1/user.go index b01cb77..2f01196 100644 --- a/internal/controller/v1/user.go +++ b/internal/controller/v1/user.go @@ -2,6 +2,7 @@ package v1 import ( "context" + "fmt" "github.com/cloudwego/hertz/pkg/app" "github.com/cloudwego/hertz/pkg/common/utils" "github.com/snowykami/neo-blog/internal/ctxutils" @@ -82,8 +83,13 @@ func (u *UserController) OidcList(ctx context.Context, c *app.RequestContext) { func (u *UserController) OidcLogin(ctx context.Context, c *app.RequestContext) { name := c.Param("name") - code := c.Param("code") - state := c.Param("state") + code := c.Query("code") + state := c.Query("state") + redirectUri := c.Query("redirect_back") // 前端路由登录前的重定向地址 + if redirectUri == "" { + redirectUri = "/" + } + fmt.Println("redirectBack:", redirectUri) oidcLoginReq := &dto.OidcLoginReq{ Name: name, Code: code, @@ -96,22 +102,14 @@ func (u *UserController) OidcLogin(ctx context.Context, c *app.RequestContext) { return } ctxutils.SetTokenAndRefreshTokenCookie(c, resp.Token, resp.RefreshToken) - resps.Ok(c, resps.Success, map[string]any{ - "token": resp.Token, - "user": resp.User, - }) + resps.Redirect(c, redirectUri) // 重定向到前端路由 } func (u *UserController) GetUser(ctx context.Context, c *app.RequestContext) { userID := c.Param("id") - if userID == "" { - resps.BadRequest(c, resps.ErrParamInvalid) - return - } userIDInt, err := strconv.Atoi(userID) if err != nil || userIDInt <= 0 { - resps.BadRequest(c, resps.ErrParamInvalid) - return + userIDInt = int(ctxutils.GetCurrentUserID(ctx)) } resp, err := u.service.GetUser(&dto.GetUserReq{UserID: uint(userIDInt)}) @@ -142,7 +140,7 @@ func (u *UserController) UpdateUser(ctx context.Context, c *app.RequestContext) updateUserReq.ID = uint(userIDInt) currentUser := ctxutils.GetCurrentUser(ctx) if currentUser == nil { - resps.UnAuthorized(c, resps.ErrUnauthorized) + resps.Unauthorized(c, resps.ErrUnauthorized) return } if currentUser.ID != updateUserReq.ID { diff --git a/internal/ctxutils/pagenation.go b/internal/ctxutils/pagenation.go index 9a41ba9..d8213b0 100644 --- a/internal/ctxutils/pagenation.go +++ b/internal/ctxutils/pagenation.go @@ -1 +1,46 @@ package ctxutils + +import ( + "github.com/cloudwego/hertz/pkg/app" + "strconv" +) + +type PaginationParams struct { + Page uint64 + Size uint64 + OrderedBy string + Reverse bool // 默认是从大值到小值 +} + +func GetPaginationParams(c *app.RequestContext) *PaginationParams { + page := c.Query("page") + size := c.Query("size") + orderedBy := c.Query("ordered_by") + reverse := c.Query("reverse") + if page == "" { + page = "1" + } + if size == "" { + size = "10" + } + var reverseBool bool + if reverse == "" || reverse == "false" || reverse == "0" { + reverseBool = false + } else { + reverseBool = true + } + pageNum, err := strconv.ParseUint(page, 10, 64) + if err != nil || pageNum < 1 { + pageNum = 1 + } + sizeNum, err := strconv.ParseUint(size, 10, 64) + if err != nil || sizeNum < 1 { + sizeNum = 10 + } + return &PaginationParams{ + Page: pageNum, + Size: sizeNum, + OrderedBy: orderedBy, + Reverse: reverseBool, + } +} diff --git a/internal/ctxutils/user.go b/internal/ctxutils/user.go index b1382bc..1c5ed10 100644 --- a/internal/ctxutils/user.go +++ b/internal/ctxutils/user.go @@ -17,3 +17,12 @@ func GetCurrentUser(ctx context.Context) *model.User { } return user } + +// GetCurrentUserID 获取当前用户ID,如果未认证则返回0 +func GetCurrentUserID(ctx context.Context) uint { + user := GetCurrentUser(ctx) + if user == nil { + return 0 + } + return user.ID +} diff --git a/internal/dto/admin.go b/internal/dto/admin.go index f4c1288..855df1e 100644 --- a/internal/dto/admin.go +++ b/internal/dto/admin.go @@ -8,5 +8,6 @@ type AdminOidcConfigDto struct { DisplayName string `json:"display_name"` Icon string `json:"icon"` OidcDiscoveryUrl string `json:"oidc_discovery_url"` + Type string `json:"type"` // oauth2 or misskey Enabled bool `json:"enabled"` } diff --git a/internal/dto/post.go b/internal/dto/post.go index cac3a36..1d486ae 100644 --- a/internal/dto/post.go +++ b/internal/dto/post.go @@ -1,11 +1,16 @@ package dto type PostDto struct { - UserID uint `json:"user_id"` // 发布者的用户ID - Title string `json:"title"` // 帖子标题 - Content string `json:"content"` - Labels []LabelDto `json:"labels"` // 关联的标签 - IsPrivate bool `json:"is_private"` // 是否为私密帖子 + ID uint `json:"id"` // 帖子ID + UserID uint `json:"user_id"` // 发布者的用户ID + Title string `json:"title"` // 帖子标题 + Content string `json:"content"` + Labels []LabelDto `json:"labels"` // 关联的标签 + IsPrivate bool `json:"is_private"` // 是否为私密帖子 + LikeCount uint64 `json:"like_count"` // 点赞数 + CommentCount uint64 `json:"comment_count"` // 评论数 + ViewCount uint64 `json:"view_count"` // 浏览数 + Heat uint64 `json:"heat"` // 热度 } type CreateOrUpdatePostReq struct { @@ -14,3 +19,18 @@ type CreateOrUpdatePostReq struct { IsPrivate bool `json:"is_private"` Labels []uint `json:"labels"` // 标签ID列表 } + +type ListPostReq struct { + Keywords []string `json:"keywords"` // 关键词列表 + OrderedBy string `json:"ordered_by"` // 排序方式 + Page uint64 `json:"page"` // 页码 + Size uint64 `json:"size"` + Reverse bool `json:"reverse"` +} + +type ListPostResp struct { + Posts []PostDto `json:"posts"` + Total uint64 `json:"total"` // 总数 + OrderedBy string `json:"ordered_by"` // 排序方式 + Reverse bool `json:"reverse"` +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 30182c2..ddfd32e 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -54,7 +54,7 @@ func UseAuth(block bool) app.HandlerFunc { // 所有认证方式都失败 if block { // 若需要阻断,返回未授权错误并中止请求 - resps.UnAuthorized(c, resps.ErrUnauthorized) + resps.Unauthorized(c, resps.ErrUnauthorized) c.Abort() } else { // 若不需要阻断,继续请求但不设置用户ID diff --git a/internal/model/category.go b/internal/model/category.go index 8b53790..5a5a112 100644 --- a/internal/model/category.go +++ b/internal/model/category.go @@ -1 +1,9 @@ package model + +import "gorm.io/gorm" + +type Category struct { + gorm.Model + Name string `gorm:"type:text;not null"` + Description string `gorm:"type:text;not null"` // 分类描述 +} diff --git a/internal/model/label.go b/internal/model/label.go index b4ce9b4..15c0c2d 100644 --- a/internal/model/label.go +++ b/internal/model/label.go @@ -1,11 +1,24 @@ package model -import "gorm.io/gorm" +import ( + "github.com/snowykami/neo-blog/internal/dto" + "gorm.io/gorm" +) type Label struct { gorm.Model - Key string `gorm:"uniqueIndex"` // 标签键,唯一标识 - Value string `gorm:"type:text"` // 标签值,描述标签的内容 - Color string `gorm:"type:text"` // 前端可用颜色代码 - TailwindClassName string `gorm:"type:text"` // Tailwind CSS 的类名,用于前端样式 + Key string `gorm:"uniqueIndex"` // 标签键,唯一标识 + Value string `gorm:"type:text"` // 标签值,描述标签的内容 + Color string `gorm:"type:text"` // 前端可用颜色代码 + TailwindClassName string `gorm:"type:text"` // Tailwind CSS 的类名,用于前端样式 + Posts []Post `gorm:"many2many:post_labels;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` // 关联的帖子 +} + +func (l *Label) ToDto() dto.LabelDto { + return dto.LabelDto{ + Key: l.Key, + Value: l.Value, + Color: l.Color, + TailwindClassName: l.TailwindClassName, + } } diff --git a/internal/model/oidc_config.go b/internal/model/oidc_config.go index e552738..4fd5daa 100644 --- a/internal/model/oidc_config.go +++ b/internal/model/oidc_config.go @@ -2,6 +2,7 @@ package model import ( "fmt" + "github.com/sirupsen/logrus" "github.com/snowykami/neo-blog/internal/dto" "gorm.io/gorm" "resty.dev/v3" @@ -17,6 +18,7 @@ type OidcConfig struct { Icon string // 图标url,为空则使用内置默认图标 OidcDiscoveryUrl string // OpenID自动发现URL,例如 :https://pass.liteyuki.icu/.well-known/openid-configuration Enabled bool `gorm:"default:true"` // 是否启用 + Type string `gorm:"oauth2"` // OIDC类型,默认为oauth2,也可以为misskey // 以下字段为自动获取字段,每次更新配置时自动填充 Issuer string AuthorizationEndpoint string @@ -42,7 +44,7 @@ type oidcDiscoveryResp struct { EndSessionEndpoint string `json:"end_session_endpoint,omitempty"` } -func updateOidcConfigFromUrl(url string) (*oidcDiscoveryResp, error) { +func updateOidcConfigFromUrl(url string, typ string) (*oidcDiscoveryResp, error) { client := resty.New() client.SetTimeout(10 * time.Second) // 设置超时时间 var discovery oidcDiscoveryResp @@ -57,6 +59,11 @@ func updateOidcConfigFromUrl(url string) (*oidcDiscoveryResp, error) { return nil, fmt.Errorf("请求OIDC发现端点失败,状态码: %d", resp.StatusCode()) } // 验证必要字段 + if typ == "misskey" { + discovery.UserInfoEndpoint = discovery.Issuer + "/api/users/me" // Misskey的用户信息端点 + discovery.JwksUri = discovery.Issuer + "/api/jwks" + } + fmt.Println(discovery) if discovery.Issuer == "" || discovery.AuthorizationEndpoint == "" || discovery.TokenEndpoint == "" || @@ -69,10 +76,12 @@ func updateOidcConfigFromUrl(url string) (*oidcDiscoveryResp, error) { func (o *OidcConfig) BeforeSave(tx *gorm.DB) (err error) { // 只有在创建新记录或更新 OidcDiscoveryUrl 字段时才更新端点信息 - if tx.Statement.Changed("OidcDiscoveryUrl") { - discoveryResp, err := updateOidcConfigFromUrl(o.OidcDiscoveryUrl) + if tx.Statement.Changed("OidcDiscoveryUrl") || o.ID == 0 { + logrus.Infof("Updating OIDC config for %s, OidcDiscoveryUrl: %s", o.Name, o.OidcDiscoveryUrl) + discoveryResp, err := updateOidcConfigFromUrl(o.OidcDiscoveryUrl, o.Type) if err != nil { - return fmt.Errorf("更新OIDC配置失败: %w", err) + logrus.Error("Updating OIDC config failed: ", err) + return fmt.Errorf("updating OIDC config failed: %w", err) } o.Issuer = discoveryResp.Issuer o.AuthorizationEndpoint = discoveryResp.AuthorizationEndpoint diff --git a/internal/model/post.go b/internal/model/post.go index 2a09dc3..0f5bc8c 100644 --- a/internal/model/post.go +++ b/internal/model/post.go @@ -1,16 +1,57 @@ package model -import "gorm.io/gorm" +import ( + "github.com/snowykami/neo-blog/internal/dto" + "github.com/snowykami/neo-blog/pkg/constant" + "gorm.io/gorm" +) type Post struct { gorm.Model - UserID uint `gorm:"index"` // 发布者的用户ID - User User `gorm:"foreignKey:UserID;references:ID"` // 关联的用户 - Title string `gorm:"type:text;not null"` // 帖子标题 - Content string `gorm:"type:text;not null"` // 帖子内容 - Labels []Label `gorm:"many2many:post_labels;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` // 关联的标签 - IsPrivate bool `gorm:"default:false"` // 是否为私密帖子 + UserID uint `gorm:"index"` // 发布者的用户ID + User User `gorm:"foreignKey:UserID;references:ID"` // 关联的用户 + Title string `gorm:"type:text;not null"` // 帖子标题 + Content string `gorm:"type:text;not null"` // 帖子内容 + CategoryID uint `gorm:"index"` // 帖子分类ID + Category Category `gorm:"foreignKey:CategoryID;references:ID"` // 关联的分类 + Labels []Label `gorm:"many2many:post_labels;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` // 关联的标签 + IsPrivate bool `gorm:"default:false"` // 是否为私密帖子 LikeCount uint64 CommentCount uint64 - VisitorCount uint64 + ViewCount uint64 + Heat uint64 `gorm:"default:0"` +} + +// CalculateHeat 热度计算 +func (p *Post) CalculateHeat() float64 { + return float64( + p.LikeCount*constant.HeatFactorLikeWeight + + p.CommentCount*constant.HeatFactorCommentWeight + + p.ViewCount*constant.HeatFactorViewWeight, + ) +} + +// AfterUpdate 热度指标更新后更新热度 +func (p *Post) AfterUpdate(tx *gorm.DB) (err error) { + if tx.Statement.Changed("LikeCount") || tx.Statement.Changed("CommentCount") || tx.Statement.Changed("ViewCount") { + p.Heat = uint64(p.CalculateHeat()) + if err := tx.Model(p).Update("heat", p.Heat).Error; err != nil { + return err + } + } + return nil +} + +func (p *Post) ToDto() dto.PostDto { + return dto.PostDto{ + ID: p.ID, + UserID: p.UserID, + Title: p.Title, + Content: p.Content, + IsPrivate: p.IsPrivate, + LikeCount: p.LikeCount, + CommentCount: p.CommentCount, + ViewCount: p.ViewCount, + Heat: p.Heat, + } } diff --git a/internal/repo/init.go b/internal/repo/init.go index e7cf5dc..4eda049 100644 --- a/internal/repo/init.go +++ b/internal/repo/init.go @@ -124,6 +124,7 @@ func initSQLite(path string, gormConfig *gorm.Config) (*gorm.DB, error) { func migrate() error { return GetDB().AutoMigrate( + &model.Category{}, &model.Comment{}, &model.Label{}, &model.Like{}, diff --git a/internal/repo/oidc_config.go b/internal/repo/oidc_config.go index 39a4bf1..aa0be4a 100644 --- a/internal/repo/oidc_config.go +++ b/internal/repo/oidc_config.go @@ -62,7 +62,7 @@ func (o *oidcRepo) UpdateOidcConfig(oidcConfig *model.OidcConfig) error { if oidcConfig.ID == 0 { return errs.New(http.StatusBadRequest, "invalid OIDC config ID", nil) } - if err := GetDB().Updates(oidcConfig).Error; err != nil { + if err := GetDB().Select("Enabled").Updates(oidcConfig).Error; err != nil { return err } return nil diff --git a/internal/repo/post.go b/internal/repo/post.go index c102a94..1272c3d 100644 --- a/internal/repo/post.go +++ b/internal/repo/post.go @@ -2,8 +2,10 @@ package repo import ( "github.com/snowykami/neo-blog/internal/model" + "github.com/snowykami/neo-blog/pkg/constant" "github.com/snowykami/neo-blog/pkg/errs" "net/http" + "slices" ) type postRepo struct{} @@ -45,9 +47,34 @@ func (p *postRepo) UpdatePost(post *model.Post) error { return nil } -func (p *postRepo) ListPosts(limit, offset int) ([]model.Post, error) { +func (p *postRepo) ListPosts(currentUserID uint, keywords []string, page, size uint64, orderedBy string, reverse bool) ([]model.Post, error) { var posts []model.Post - if err := GetDB().Limit(limit).Offset(offset).Find(&posts).Error; err != nil { + if !slices.Contains(constant.OrderedByEnumPost, orderedBy) { + return nil, errs.New(http.StatusBadRequest, "invalid ordered_by parameter", nil) + } + order := orderedBy + if reverse { + order += " ASC" + } else { + order += " DESC" + } + query := GetDB().Model(&model.Post{}).Preload("User") + if currentUserID > 0 { + query = query.Where("is_private = ? OR (is_private = ? AND user_id = ?)", false, true, currentUserID) + } else { + query = query.Where("is_private = ?", false) + } + if len(keywords) > 0 { + for _, keyword := range keywords { + if keyword != "" { + // 使用LIKE进行模糊匹配,搜索标题、内容和标签 + query = query.Where("title LIKE ? OR content LIKE ? OR tags LIKE ?", + "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") + } + } + } + query = query.Order(order).Offset(int((page - 1) * size)).Limit(int(size)) + if err := query.Find(&posts).Error; err != nil { return nil, err } return posts, nil diff --git a/internal/router/apiv1/user.go b/internal/router/apiv1/user.go index e935036..e6ecd6d 100644 --- a/internal/router/apiv1/user.go +++ b/internal/router/apiv1/user.go @@ -18,6 +18,7 @@ func registerUserRoutes(group *route.RouterGroup) { userGroupWithoutAuth.GET("/oidc/list", userController.OidcList) userGroupWithoutAuth.GET("/oidc/login/:name", userController.OidcLogin) userGroupWithoutAuth.GET("/u/:id", userController.GetUser) + userGroup.GET("/u", userController.GetUser) userGroup.POST("/logout", userController.Logout) userGroup.PUT("/u/:id", userController.UpdateUser) } diff --git a/internal/service/console.go b/internal/service/console.go index da0d819..7c1a4f6 100644 --- a/internal/service/console.go +++ b/internal/service/console.go @@ -23,6 +23,7 @@ func (c *AdminService) CreateOidcConfig(req *dto.AdminOidcConfigDto) error { ClientSecret: req.ClientSecret, OidcDiscoveryUrl: req.OidcDiscoveryUrl, Enabled: req.Enabled, + Type: req.Type, } return repo.Oidc.CreateOidcConfig(oidcConfig) } @@ -70,6 +71,7 @@ func (c *AdminService) UpdateOidcConfig(req *dto.AdminOidcConfigDto) error { ClientSecret: req.ClientSecret, OidcDiscoveryUrl: req.OidcDiscoveryUrl, Enabled: req.Enabled, + Type: req.Type, } return repo.Oidc.UpdateOidcConfig(oidcConfig) } diff --git a/internal/service/post.go b/internal/service/post.go index 61b3e5c..9c9fa54 100644 --- a/internal/service/post.go +++ b/internal/service/post.go @@ -7,7 +7,6 @@ import ( "github.com/snowykami/neo-blog/internal/model" "github.com/snowykami/neo-blog/internal/repo" "github.com/snowykami/neo-blog/pkg/errs" - "net/http" ) type PostService struct{} @@ -21,15 +20,22 @@ func (p *PostService) CreatePost(ctx context.Context, req *dto.CreateOrUpdatePos if currentUser == nil { return errs.ErrUnauthorized } - post := &model.Post{ - Title: req.Title, - Content: req.Content, - UserID: currentUser.ID, - Labels: req.Labels, + Title: req.Title, + Content: req.Content, + UserID: currentUser.ID, + Labels: func() []model.Label { + labelModels := make([]model.Label, 0) + for _, labelID := range req.Labels { + labelModel, err := repo.Label.GetLabelByID(labelID) + if err == nil { + labelModels = append(labelModels, *labelModel) + } + } + return labelModels + }(), IsPrivate: req.IsPrivate, } - if err := repo.Post.CreatePost(post); err != nil { return err } @@ -37,10 +43,103 @@ func (p *PostService) CreatePost(ctx context.Context, req *dto.CreateOrUpdatePos } func (p *PostService) DeletePost(ctx context.Context, id string) error { + currentUser := ctxutils.GetCurrentUser(ctx) + if currentUser == nil { + return errs.ErrUnauthorized + } + if id == "" { + return errs.ErrBadRequest + } + post, err := repo.Post.GetPostByID(id) + if err != nil { + return errs.New(errs.ErrNotFound.Code, "post not found", err) + } + if post.UserID != currentUser.ID { + return errs.ErrForbidden + } + if err := repo.Post.DeletePost(id); err != nil { + return errs.ErrInternalServer + } + return nil } -func (p *PostService) GetPost(ctx context.Context, id string) (*model.Post, error) {} +func (p *PostService) GetPost(ctx context.Context, id string) (*dto.PostDto, error) { + currentUser := ctxutils.GetCurrentUser(ctx) + if currentUser == nil { + return nil, errs.ErrUnauthorized + } + if id == "" { + return nil, errs.ErrBadRequest + } + post, err := repo.Post.GetPostByID(id) + if err != nil { + return nil, errs.New(errs.ErrNotFound.Code, "post not found", err) + } + if post.IsPrivate && post.UserID != currentUser.ID { + return nil, errs.ErrForbidden + } + return &dto.PostDto{ + UserID: post.UserID, + Title: post.Title, + Content: post.Content, + Labels: func() []dto.LabelDto { + labelDtos := make([]dto.LabelDto, 0) + for _, label := range post.Labels { + labelDtos = append(labelDtos, label.ToDto()) + } + return labelDtos + }(), + }, nil +} -func (p *PostService) UpdatePost(req *dto.CreateOrUpdatePostReq) error {} +func (p *PostService) UpdatePost(ctx context.Context, id string, req *dto.CreateOrUpdatePostReq) error { + currentUser := ctxutils.GetCurrentUser(ctx) + if currentUser == nil { + return errs.ErrUnauthorized + } + if id == "" { + return errs.ErrBadRequest + } + post, err := repo.Post.GetPostByID(id) + if err != nil { + return errs.New(errs.ErrNotFound.Code, "post not found", err) + } + if post.UserID != currentUser.ID { + return errs.ErrForbidden + } + post.Title = req.Title + post.Content = req.Content + post.IsPrivate = req.IsPrivate + post.Labels = func() []model.Label { + labelModels := make([]model.Label, len(req.Labels)) + for _, labelID := range req.Labels { + labelModel, err := repo.Label.GetLabelByID(labelID) + if err == nil { + labelModels = append(labelModels, *labelModel) + } + } + return labelModels + }() + if err := repo.Post.UpdatePost(post); err != nil { + return errs.ErrInternalServer + } + return nil +} -func (p *PostService) ListPosts() {} +func (p *PostService) ListPosts(ctx context.Context, req *dto.ListPostReq) (*dto.ListPostResp, error) { + postDtos := make([]dto.PostDto, 0) + currentUserID := ctxutils.GetCurrentUserID(ctx) + posts, err := repo.Post.ListPosts(currentUserID, req.Keywords, req.Page, req.Size, req.OrderedBy, req.Reverse) + if err != nil { + return nil, errs.New(errs.ErrInternalServer.Code, "failed to list posts", err) + } + for _, post := range posts { + postDtos = append(postDtos, post.ToDto()) + } + return &dto.ListPostResp{ + Posts: postDtos, + Total: uint64(len(posts)), + OrderedBy: req.OrderedBy, + Reverse: req.Reverse, + }, nil +} diff --git a/internal/service/user.go b/internal/service/user.go index 79e2212..c06262c 100644 --- a/internal/service/user.go +++ b/internal/service/user.go @@ -2,6 +2,7 @@ package service import ( "errors" + "fmt" "github.com/sirupsen/logrus" "github.com/snowykami/neo-blog/internal/dto" "github.com/snowykami/neo-blog/internal/model" @@ -25,6 +26,10 @@ func NewUserService() *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 { @@ -92,6 +97,15 @@ func (s *UserService) UserRegister(req *dto.UserRegisterReq) (*dto.UserRegisterR 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 { @@ -137,17 +151,40 @@ func (s *UserService) ListOidcConfigs() (*dto.ListOidcConfigResp, error) { 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, + }) + } + oidcConfigsDtos = append(oidcConfigsDtos, dto.UserOidcConfigDto{ Name: oidcConfig.Name, DisplayName: oidcConfig.DisplayName, Icon: oidcConfig.Icon, - LoginUrl: utils.Url.BuildUrl(oidcConfig.AuthorizationEndpoint, map[string]string{ - "client_id": oidcConfig.ClientID, - "redirect_uri": strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/") + constant.OidcUri + oidcConfig.Name, - "response_type": "code", - "scope": "openid email profile", - "state": state, - }), + LoginUrl: loginUrl, }) } return &dto.ListOidcConfigResp{ @@ -190,7 +227,7 @@ func (s *UserService) OidcLogin(req *dto.OidcLoginReq) (*dto.OidcLoginResp, erro // 绑定过登录 userOpenID, err := repo.User.GetUserOpenIDByIssuerAndSub(oidcConfig.Issuer, userInfo.Sub) - if !errors.Is(err, gorm.ErrRecordNotFound) { + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, errs.ErrInternalServer } if userOpenID != nil { @@ -212,7 +249,7 @@ func (s *UserService) OidcLogin(req *dto.OidcLoginReq) (*dto.OidcLoginResp, erro } else { // 若没有绑定过登录,则先通过邮箱查找用户,若没有再创建新用户 user, err := repo.User.GetUserByEmail(userInfo.Email) - if !errors.Is(err, gorm.ErrRecordNotFound) { + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { logrus.Errorln("Failed to get user by email:", err) return nil, errs.ErrInternalServer } diff --git a/internal/tasks/session_clear.go b/internal/tasks/session_clear.go index 9b29ce4..c6cfad8 100644 --- a/internal/tasks/session_clear.go +++ b/internal/tasks/session_clear.go @@ -1 +1,4 @@ package tasks + +// ClearSessionDaemon 定时任务:清理过期会话 +func ClearSessionDaemon() {} diff --git a/internal/tasks/task_manager.go b/internal/tasks/task_manager.go index 9b29ce4..8d91f33 100644 --- a/internal/tasks/task_manager.go +++ b/internal/tasks/task_manager.go @@ -1 +1,6 @@ package tasks + +func RunTaskManager() { + // 启动任务管理器 + +} diff --git a/pkg/constant/constant.go b/pkg/constant/constant.go index c00b63e..0c44645 100644 --- a/pkg/constant/constant.go +++ b/pkg/constant/constant.go @@ -1,15 +1,14 @@ package constant const ( - CaptchaTypeDisable = "disable" // 禁用验证码 - CaptchaTypeHCaptcha = "hcaptcha" // HCaptcha验证码 - CaptchaTypeTurnstile = "turnstile" // Turnstile验证码 - CaptchaTypeReCaptcha = "recaptcha" // ReCaptcha验证码 - ModeDev = "dev" - ModeProd = "prod" - RoleUser = "user" - RoleAdmin = "admin" - + CaptchaTypeDisable = "disable" // 禁用验证码 + CaptchaTypeHCaptcha = "hcaptcha" // HCaptcha验证码 + CaptchaTypeTurnstile = "turnstile" // Turnstile验证码 + CaptchaTypeReCaptcha = "recaptcha" // ReCaptcha验证码 + ModeDev = "dev" + ModeProd = "prod" + RoleUser = "user" + RoleAdmin = "admin" EnvKeyBaseUrl = "BASE_URL" // 环境变量:基础URL EnvKeyMode = "MODE" // 环境变量:运行模式 EnvKeyJwtSecrete = "JWT_SECRET" // 环境变量:JWT密钥 @@ -18,13 +17,26 @@ const ( EnvKeyTokenDurationDefault = 300 EnvKeyRefreshTokenDuration = "REFRESH_TOKEN_DURATION" // 环境变量:刷新令牌有效期 EnvKeyRefreshTokenDurationWithRemember = "REFRESH_TOKEN_DURATION_WITH_REMEMBER" // 环境变量:记住我刷新令牌有效期 + KVKeyEmailVerificationCode = "email_verification_code:" // KV存储:邮箱验证码 + KVKeyOidcState = "oidc_state:" // KV存储:OIDC状态 + ApiSuffix = "/api/v1" // API版本前缀 + OidcUri = "/user/oidc/login" // OIDC登录URI + OidcProviderTypeMisskey = "misskey" // OIDC提供者类型:Misskey + OidcProviderTypeOauth2 = "oauth2" // OIDC提供者类型:GitHub + DefaultBaseUrl = "http://localhost:3000" // 默认BaseUrl + TargetTypePost = "post" + TargetTypeComment = "comment" + OrderedByCreatedAt = "created_at" // 按创建时间排序 + OrderedByUpdatedAt = "updated_at" // 按更新时间排序 + OrderedByLikeCount = "like_count" // 按点赞数排序 + OrderedByCommentCount = "comment_count" // 按评论数排序 + OrderedByViewCount = "view_count" // 按浏览量排序 + HeatFactorViewWeight = 1 // 热度因子:浏览量权重 + HeatFactorLikeWeight = 5 // 热度因子:点赞权重 + HeatFactorCommentWeight = 10 // 热度因子:评论权重 - KVKeyEmailVerificationCode = "email_verification_code:" // KV存储:邮箱验证码 - KVKeyOidcState = "oidc_state:" // KV存储:OIDC状态 - - OidcUri = "/user/oidc/login" // OIDC登录URI - DefaultBaseUrl = "http://localhost:3000" // 默认BaseUrl - - TargetTypePost = "post" - TargetTypeComment = "comment" +) + +var ( + OrderedByEnumPost = []string{OrderedByCreatedAt, OrderedByUpdatedAt, OrderedByLikeCount, OrderedByCommentCount, OrderedByViewCount} // 帖子可用的排序方式 ) diff --git a/pkg/resps/resps.go b/pkg/resps/resps.go index 06467e1..da57fe3 100644 --- a/pkg/resps/resps.go +++ b/pkg/resps/resps.go @@ -18,11 +18,15 @@ func Ok(c *app.RequestContext, message string, data any) { Custom(c, 200, message, data) } +func Redirect(c *app.RequestContext, url string) { + c.Redirect(302, []byte(url)) +} + func BadRequest(c *app.RequestContext, message string) { Custom(c, 400, message, nil) } -func UnAuthorized(c *app.RequestContext, message string) { +func Unauthorized(c *app.RequestContext, message string) { Custom(c, 401, message, nil) } diff --git a/pkg/utils/oidc.go b/pkg/utils/oidc.go index 2568807..169c5dd 100644 --- a/pkg/utils/oidc.go +++ b/pkg/utils/oidc.go @@ -42,7 +42,6 @@ func (u *oidcUtils) RequestUserInfo(userInfoEndpoint, accessToken string) (*User SetHeader("Accept", "application/json"). SetResult(&UserInfo{}). Get(userInfoEndpoint) - if err != nil { return nil, err } diff --git a/web/components.json b/web/components.json new file mode 100644 index 0000000..ffe928f --- /dev/null +++ b/web/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/web/next.config.ts b/web/next.config.ts index e9ffa30..a3eb05e 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -1,7 +1,32 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'www.gravatar.com', + port: '', + pathname: '/avatar/**', + }, + { + protocol: 'https', + hostname: 'cdn.liteyuki.org', + port: '', + pathname: '/**', + }, + ], + }, + async rewrites() { + const backendUrl = (process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8888") + console.log("Using development API base URL:", backendUrl); + return [ + { + source: '/api/:path*', + destination: backendUrl + '/api/:path*', + }, + ] + } }; export default nextConfig; diff --git a/web/package.json b/web/package.json index d0e1b1e..ff07455 100644 --- a/web/package.json +++ b/web/package.json @@ -9,19 +9,32 @@ "lint": "next lint" }, "dependencies": { + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-navigation-menu": "^1.2.13", + "@radix-ui/react-slot": "^1.2.3", + "axios": "^1.11.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "field-conv": "^1.0.9", + "i18next": "^25.3.2", + "lucide-react": "^0.525.0", + "next": "15.4.1", "react": "19.1.0", "react-dom": "19.1.0", - "next": "15.4.1" + "react-i18next": "^15.6.1", + "react-icons": "^5.5.0", + "tailwind-merge": "^3.3.1" }, "devDependencies": { - "typescript": "^5", + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", - "@tailwindcss/postcss": "^4", - "tailwindcss": "^4", "eslint": "^9", "eslint-config-next": "15.4.1", - "@eslint/eslintrc": "^3" + "tailwindcss": "^4", + "tw-animate-css": "^1.3.5", + "typescript": "^5" } } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 36e45c5..d7d89fb 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -8,6 +8,33 @@ importers: .: dependencies: + '@radix-ui/react-label': + specifier: ^2.1.7 + version: 2.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@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-slot': + specifier: ^1.2.3 + version: 1.2.3(@types/react@19.1.8)(react@19.1.0) + axios: + specifier: ^1.11.0 + version: 1.11.0 + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + field-conv: + specifier: ^1.0.9 + version: 1.0.9 + i18next: + specifier: ^25.3.2 + version: 25.3.2(typescript@5.8.3) + lucide-react: + specifier: ^0.525.0 + version: 0.525.0(react@19.1.0) next: specifier: 15.4.1 version: 15.4.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -17,6 +44,15 @@ importers: react-dom: specifier: 19.1.0 version: 19.1.0(react@19.1.0) + react-i18next: + specifier: ^15.6.1 + version: 15.6.1(i18next@25.3.2(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3) + react-icons: + specifier: ^5.5.0 + version: 5.5.0(react@19.1.0) + tailwind-merge: + specifier: ^3.3.1 + version: 3.3.1 devDependencies: '@eslint/eslintrc': specifier: ^3 @@ -42,6 +78,9 @@ importers: tailwindcss: specifier: ^4 version: 4.1.11 + tw-animate-css: + specifier: ^1.3.5 + version: 1.3.5 typescript: specifier: ^5 version: 5.8.3 @@ -56,6 +95,10 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} + '@babel/runtime@7.27.6': + resolution: {integrity: sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==} + engines: {node: '>=6.9.0'} + '@emnapi/core@1.4.4': resolution: {integrity: sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g==} @@ -335,6 +378,199 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@radix-ui/primitive@1.1.2': + resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==} + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + 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-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.10': + resolution: {integrity: sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==} + 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-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.7': + resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-navigation-menu@1.2.13': + resolution: {integrity: sha512-WG8wWfDiJlSF5hELjwfjSGOXcBR/ZMhBFCGYe8vERpC39CQYZeq1PQ2kaYHdye3V95d06H89KGMsVCIE4LWo3g==} + peerDependencies: + '@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-presence@1.1.4': + resolution: {integrity: sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==} + 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-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + 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-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + 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-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + 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-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + 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-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + 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-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + 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 + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -447,6 +683,9 @@ packages: '@types/node@20.19.8': resolution: {integrity: sha512-HzbgCY53T6bfu4tT7Aq3TvViJyHjLjPNaAS3HOuMc9pw97KHsUtXNX4L+wu59g1WnjsZSko35MbEqnO58rihhw==} + '@types/node@24.1.0': + resolution: {integrity: sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==} + '@types/react-dom@19.1.6': resolution: {integrity: sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==} peerDependencies: @@ -672,6 +911,9 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -680,6 +922,9 @@ packages: resolution: {integrity: sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==} engines: {node: '>=4'} + axios@1.11.0: + resolution: {integrity: sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -724,9 +969,16 @@ packages: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -741,6 +993,10 @@ packages: resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} engines: {node: '>=12.5.0'} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -794,6 +1050,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + detect-libc@2.0.4: resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} engines: {node: '>=8'} @@ -993,6 +1253,9 @@ packages: picomatch: optional: true + field-conv@1.0.9: + resolution: {integrity: sha512-e9yPUB6r67BSHw2D2cN1aruO8rTL5Ty2kvhnS5AGI0qGPkM5NARP78SiOB74OtAkQam/mLKuHSTt1GC7ollCMw==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -1012,10 +1275,23 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + follow-redirects@1.15.9: + resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + form-data@4.0.4: + resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} + engines: {node: '>= 6'} + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -1094,6 +1370,17 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + + i18next@25.3.2: + resolution: {integrity: sha512-JSnbZDxRVbphc5jiptxr3o2zocy5dEqpVm9qCGdJwRNO+9saUJS0/u4LnM/13C23fUEWxAylPqKU/NpMV/IjqA==} + peerDependencies: + typescript: ^5 + peerDependenciesMeta: + typescript: + optional: true + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1347,6 +1634,11 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lucide-react@0.525.0: + resolution: {integrity: sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} @@ -1362,6 +1654,14 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -1515,6 +1815,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -1527,6 +1830,27 @@ packages: peerDependencies: react: ^19.1.0 + react-i18next@15.6.1: + resolution: {integrity: sha512-uGrzSsOUUe2sDBG/+FJq2J1MM+Y4368/QW8OLEKSFvnDflHBbZhSd1u3UkW0Z06rMhZmnB/AQrhCpYfE5/5XNg==} + peerDependencies: + i18next: '>= 23.2.3' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + + react-icons@5.5.0: + resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==} + peerDependencies: + react: '*' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -1695,6 +2019,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + tailwind-merge@3.3.1: + resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==} + tailwindcss@4.1.11: resolution: {integrity: sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==} @@ -1726,6 +2053,9 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tw-animate-css@1.3.5: + resolution: {integrity: sha512-t3u+0YNoloIhj1mMXs779P6MO9q3p3mvGn4k1n3nJPqJw/glZcuijG2qTSN4z4mgNRfW5ZC3aXJFLwDtiipZXA==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -1758,12 +2088,19 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.8.0: + resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==} + unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -1806,6 +2143,8 @@ snapshots: '@jridgewell/gen-mapping': 0.3.12 '@jridgewell/trace-mapping': 0.3.29 + '@babel/runtime@7.27.6': {} + '@emnapi/core@1.4.4': dependencies: '@emnapi/wasi-threads': 1.0.3 @@ -2034,6 +2373,164 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@radix-ui/primitive@1.1.2': {} + + '@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)': + dependencies: + '@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-slot': 1.2.3(@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-compose-refs@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-context@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-direction@1.1.1(@types/react@19.1.8)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.8 + + '@radix-ui/react-dismissable-layer@1.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@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-id@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) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.8 + + '@radix-ui/react-label@2.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + 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-navigation-menu@1.2.13(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@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.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-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) + 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-presence@1.1.4(@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) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + + '@radix-ui/react-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)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@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-slot@1.2.3(@types/react@19.1.8)(react@19.1.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.8 + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.1.8)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.8 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.1.8)(react@19.1.0)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.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) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.8 + + '@radix-ui/react-use-effect-event@0.0.2(@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) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.8 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.1.8)(react@19.1.0)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.8 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.1.8)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.8 + + '@radix-ui/react-use-previous@1.1.1(@types/react@19.1.8)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.8 + + '@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)': + 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) + '@rtsao/scc@1.1.0': {} '@rushstack/eslint-patch@1.12.0': {} @@ -2129,6 +2626,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@24.1.0': + dependencies: + undici-types: 7.8.0 + '@types/react-dom@19.1.6(@types/react@19.1.8)': dependencies: '@types/react': 19.1.8 @@ -2381,12 +2882,22 @@ snapshots: async-function@1.0.0: {} + asynckit@0.4.0: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 axe-core@4.10.3: {} + axios@1.11.0: + dependencies: + follow-redirects: 1.15.9 + form-data: 4.0.4 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + axobject-query@4.1.0: {} balanced-match@1.0.2: {} @@ -2432,8 +2943,14 @@ snapshots: chownr@3.0.0: {} + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + client-only@0.0.1: {} + clsx@2.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -2452,6 +2969,10 @@ snapshots: color-string: 1.9.1 optional: true + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + concat-map@0.0.1: {} cross-spawn@7.0.6: @@ -2504,6 +3025,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + delayed-stream@1.0.0: {} + detect-libc@2.0.4: {} doctrine@2.1.0: @@ -2853,6 +3376,10 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + field-conv@1.0.9: + dependencies: + '@types/node': 24.1.0 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -2873,10 +3400,20 @@ snapshots: flatted@3.3.3: {} + follow-redirects@1.15.9: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 + form-data@4.0.4: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + function-bind@1.1.2: {} function.prototype.name@1.1.8: @@ -2961,6 +3498,16 @@ snapshots: dependencies: function-bind: 1.1.2 + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + + i18next@25.3.2(typescript@5.8.3): + dependencies: + '@babel/runtime': 7.27.6 + optionalDependencies: + typescript: 5.8.3 + ignore@5.3.2: {} ignore@7.0.5: {} @@ -3200,6 +3747,10 @@ snapshots: dependencies: js-tokens: 4.0.0 + lucide-react@0.525.0(react@19.1.0): + dependencies: + react: 19.1.0 + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.4 @@ -3213,6 +3764,12 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -3365,6 +3922,8 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + proxy-from-env@1.1.0: {} + punycode@2.3.1: {} queue-microtask@1.2.3: {} @@ -3374,6 +3933,20 @@ snapshots: react: 19.1.0 scheduler: 0.26.0 + react-i18next@15.6.1(i18next@25.3.2(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3): + dependencies: + '@babel/runtime': 7.27.6 + html-parse-stringify: 3.0.1 + i18next: 25.3.2(typescript@5.8.3) + react: 19.1.0 + optionalDependencies: + react-dom: 19.1.0(react@19.1.0) + typescript: 5.8.3 + + react-icons@5.5.0(react@19.1.0): + dependencies: + react: 19.1.0 + react-is@16.13.1: {} react@19.1.0: {} @@ -3610,6 +4183,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + tailwind-merge@3.3.1: {} + tailwindcss@4.1.11: {} tapable@2.2.2: {} @@ -3645,6 +4220,8 @@ snapshots: tslib@2.8.1: {} + tw-animate-css@1.3.5: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -3693,6 +4270,8 @@ snapshots: undici-types@6.21.0: {} + undici-types@7.8.0: {} + unrs-resolver@1.11.1: dependencies: napi-postinstall: 0.3.0 @@ -3721,6 +4300,8 @@ snapshots: dependencies: punycode: 2.3.1 + void-elements@3.1.0: {} + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..24db3ef --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,31 @@ +import axios from "axios"; +import { camelToSnakeObj, snakeToCamelObj } from "field-conv"; + +const API_SUFFIX = "/api/v1"; + +const axiosInstance = axios.create({ + baseURL: API_SUFFIX, + timeout: 10000, +}); + +axiosInstance.interceptors.request.use((config) => { + if (config.data && typeof config.data === "object") { + config.data = camelToSnakeObj(config.data); + } + if (config.params && typeof config.params === "object") { + config.params = camelToSnakeObj(config.params); + } + return config; +}); + +axiosInstance.interceptors.response.use( + (response) => { + if (response.data && typeof response.data === "object") { + response.data = snakeToCamelObj(response.data); + } + return response; + }, + (error) => Promise.reject(error), +); + +export default axiosInstance; diff --git a/web/src/api/user.ts b/web/src/api/user.ts new file mode 100644 index 0000000..1f1063e --- /dev/null +++ b/web/src/api/user.ts @@ -0,0 +1,56 @@ +import axiosInstance from "./client"; + +import type { OidcConfig } from "@/models/oidc-config"; +import type { User } from "@/models/user"; +import type { BaseResponse } from "@/models/resp"; + +export function userLogin( + username: string, + password: string +): Promise> { + return axiosInstance + .post>( + "/user/login", + { + username, + password, + } + ) + .then(res => res.data); +} + +export function userRegister( + username: string, + password: string, + nickname: string, + email: string, + verificationCode?: string +): Promise> { + return axiosInstance + .post>( + "/user/register", + { + username, + password, + nickname, + email, + verificationCode, + } + ) + .then(res => res.data); +} + +export function ListOidcConfigs(): Promise> { + return axiosInstance + .get>("/user/oidc/list") + .then(res => { + const data = res.data; + if ('configs' in data) { + return { + ...data, + oidcConfigs: data.configs, + }; + } + return data; + }); +} \ No newline at end of file diff --git a/web/src/app/(main)/archives/page.tsx b/web/src/app/(main)/archives/page.tsx new file mode 100644 index 0000000..3de5719 --- /dev/null +++ b/web/src/app/(main)/archives/page.tsx @@ -0,0 +1,9 @@ + +export default function ArchivesPage() { + return ( +
+

归档

+

这里是博客文章的归档页面。

+
+ ) +} diff --git a/web/src/app/(main)/layout.tsx b/web/src/app/(main)/layout.tsx new file mode 100644 index 0000000..cf28d24 --- /dev/null +++ b/web/src/app/(main)/layout.tsx @@ -0,0 +1,18 @@ +import { Navbar } from "@/components/Navbar"; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + <> +
+ +
+
+ {children} +
+ + ); +} diff --git a/web/src/app/page.tsx b/web/src/app/(main)/page.tsx similarity index 97% rename from web/src/app/page.tsx rename to web/src/app/(main)/page.tsx index a932894..b4caef1 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/(main)/page.tsx @@ -1,9 +1,10 @@ +import { NavigationMenu } from "@/components/ui/navigation-menu"; import Image from "next/image"; export default function Home() { return (
-
+
+

标签

+

这里是博客文章的标签页面。

+
+ ) +} diff --git a/web/src/app/globals.css b/web/src/app/globals.css index a2dc41e..dc98be7 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -1,26 +1,122 @@ @import "tailwindcss"; +@import "tw-animate-css"; -:root { - --background: #ffffff; - --foreground: #171717; -} +@custom-variant dark (&:is(.dark *)); @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; } } - -body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; -} diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index f7fa87e..29b62fa 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import { Navbar } from "@/components/Navbar"; +import { DeviceProvider } from "@/contexts/DeviceContext"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -27,7 +29,9 @@ export default function RootLayout({ - {children} + + {children} + ); diff --git a/web/src/app/login/page.tsx b/web/src/app/login/page.tsx new file mode 100644 index 0000000..b1a9549 --- /dev/null +++ b/web/src/app/login/page.tsx @@ -0,0 +1,27 @@ +import { GalleryVerticalEnd } from "lucide-react" + +import Image from "next/image" +import { LoginForm } from "@/components/login-form" +import config from "@/config" + +export default function LoginPage() { + return ( + + ) +} diff --git a/web/src/components/Gravatar.tsx b/web/src/components/Gravatar.tsx new file mode 100644 index 0000000..b2b3690 --- /dev/null +++ b/web/src/components/Gravatar.tsx @@ -0,0 +1,82 @@ +"use client"; + +import React from "react"; +import Image from "next/image"; +import crypto from "crypto"; + +// 生成 Gravatar URL 的函数 +function getGravatarUrl(email: string, size: number = 40, 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}`; +} + +interface GravatarAvatarProps { + email: string; + size?: number; + className?: string; + alt?: string; + url?: string; + defaultType?: 'mm' | 'identicon' | 'monsterid' | 'wavatar' | 'retro' | 'robohash' | 'blank'; +} + +const GravatarAvatar: React.FC = ({ + email, + size = 40, + className = "", + alt = "avatar", + url, + defaultType = "identicon" +}) => { + // 如果有自定义URL,使用自定义URL + if (url) { + return ( + {alt} + ); + } + + // 使用 Gravatar + const gravatarUrl = getGravatarUrl(email, size, defaultType); + + return ( + {alt} + ); +}; + +// 用户类型定义(如果还没有的话) +interface User { + email?: string; + displayName?: string; + name?: string; + avatarUrl?: string; +} + +export function getGravatarByUser(user?: User, className: string = ""): React.ReactElement { + if (!user) { + return ; + } + return ( + + ); +} + +export default GravatarAvatar; \ No newline at end of file diff --git a/web/src/components/Navbar.tsx b/web/src/components/Navbar.tsx new file mode 100644 index 0000000..b4f5f12 --- /dev/null +++ b/web/src/components/Navbar.tsx @@ -0,0 +1,134 @@ +"use client" + +import * as React from "react" +import Link from "next/link" + +import { + NavigationMenu, + NavigationMenuContent, + NavigationMenuItem, + NavigationMenuLink, + NavigationMenuList, + NavigationMenuTrigger, + navigationMenuTriggerStyle, +} from "@/components/ui/navigation-menu" +import GravatarAvatar from "./Gravatar" +import { useDevice } from "@/contexts/DeviceContext" + +const components: { title: string; href: string }[] = [ + { + title: "归档", + href: "/archives" + }, + { + title: "标签", + href: "/labels" + }, + { + title: "随机", + href: "/random" + } +] + +const navbarMenuComponents = [ + { + title: "首页", + href: "/" + }, + { + title: "文章", + children: [ + { title: "归档", href: "/archives" }, + { title: "标签", href: "/labels" }, + { title: "随机", href: "/random" } + ] + }, + { + title: "页面", + children: [ + { title: "关于我", href: "/about" }, + { title: "联系我", href: "/contact" }, + { title: "友链", href: "/links" }, + { title: "隐私政策", href: "/privacy-policy" }, + ] + } +] + +export function Navbar() { + return ( + + ) +} + +function NavMenu() { + const { isMobile } = useDevice() + console.log("isMobile", isMobile) + if (isMobile) return null + return ( + + + {navbarMenuComponents.map((item) => ( + + {item.href ? ( + + + {item.title} + + + ) : item.children ? ( + <> + + {item.title} + + +
    + {item.children.map((child) => ( + + ))} +
+
+ + ) : null} +
+ ))} +
+
+ ) +} + +function ListItem({ + title, + children, + href, + ...props +}: React.ComponentPropsWithoutRef<"li"> & { href: string }) { + return ( +
  • + + +
    {title}
    +

    + {children} +

    + +
    +
  • + ) +} diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx new file mode 100644 index 0000000..e69de29 diff --git a/web/src/components/login-form.tsx b/web/src/components/login-form.tsx new file mode 100644 index 0000000..e52774b --- /dev/null +++ b/web/src/components/login-form.tsx @@ -0,0 +1,197 @@ +"use client" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import Image from "next/image" +import { useEffect, useState } from "react" +import type { OidcConfig } from "@/models/oidc-config" +import { ListOidcConfigs, userLogin } from "@/api/user" +import Link from "next/link" // 使用 Next.js 的 Link 而不是 lucide 的 Link +import { useRouter, useSearchParams } from "next/navigation" + +export function LoginForm({ + className, + ...props +}: React.ComponentProps<"div">) { + const [oidcConfigs, setOidcConfigs] = useState([]) + const [{ username, password }, setCredentials] = useState({ username: '', password: '' }) + const router = useRouter() + const searchParams = useSearchParams() + const redirectBack = searchParams.get("redirect_back") || "/" + + + useEffect(() => { + ListOidcConfigs() + .then((res) => { + setOidcConfigs(res.data.oidcConfigs || []) // 确保是数组 + console.log("OIDC configs fetched:", res.data.oidcConfigs) + }) + .catch((error) => { + console.error("Error fetching OIDC configs:", error) + setOidcConfigs([]) // 错误时设置为空数组 + }) + }, []) + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault() + try { + const res = await userLogin(username, password) + console.log("Login successful:", res) + router.push(redirectBack) + } catch (error) { + console.error("Login failed:", error) + } + } + + return ( +
    + + + Welcome back + + Login with Open ID Connect or your email and password. + + + +
    +
    + {/* OIDC 登录选项 */} + {oidcConfigs.length > 0 && ( +
    + {oidcConfigs.map((config, index) => { + // 生成唯一的 key + const uniqueKey = config.id || + config.loginUrl || + `${config.displayName}-${index}` || + `oidc-${index}`; + + return ( + + ); + })} +
    + )} + + {/* 分隔线 */} + {oidcConfigs.length > 0 && ( +
    + + Or continue with + +
    + )} + + {/* 邮箱密码登录 */} +
    +
    + + setCredentials(c => ({ ...c, username: e.target.value }))} + /> +
    +
    + + setCredentials(c => ({ ...c, password: e.target.value }))} + /> +
    + +
    + + {/* 注册链接 */} +
    + Don't have an account?{" "} + + Sign up + +
    +
    +
    +
    +
    + + {/* 服务条款 */} +
    + By clicking continue, you agree to our{" "} + + Terms of Service + {" "} + and{" "} + + Privacy Policy + . +
    +
    + ) +} + +interface LoginWithOidcProps { + loginUrl: string; + displayName?: string; + icon?: string; +} + +function LoginWithOidc({ + loginUrl, + displayName = "Login with OIDC", + icon = "/oidc-icon.svg", +}: LoginWithOidcProps) { + return ( + + ) +} \ No newline at end of file diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx new file mode 100644 index 0000000..a2df8dc --- /dev/null +++ b/web/src/components/ui/button.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", + destructive: + "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/web/src/components/ui/card.tsx b/web/src/components/ui/card.tsx new file mode 100644 index 0000000..d05bbc6 --- /dev/null +++ b/web/src/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/web/src/components/ui/input.tsx b/web/src/components/ui/input.tsx new file mode 100644 index 0000000..03295ca --- /dev/null +++ b/web/src/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/web/src/components/ui/label.tsx b/web/src/components/ui/label.tsx new file mode 100644 index 0000000..fb5fbc3 --- /dev/null +++ b/web/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" + +import { cn } from "@/lib/utils" + +function Label({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Label } diff --git a/web/src/components/ui/navigation-menu.tsx b/web/src/components/ui/navigation-menu.tsx new file mode 100644 index 0000000..422cb6d --- /dev/null +++ b/web/src/components/ui/navigation-menu.tsx @@ -0,0 +1,168 @@ +import * as React from "react" +import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu" +import { cva } from "class-variance-authority" +import { ChevronDownIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function NavigationMenu({ + className, + children, + viewport = true, + ...props +}: React.ComponentProps & { + viewport?: boolean +}) { + return ( + + {children} + {viewport && } + + ) +} + +function NavigationMenuList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function NavigationMenuItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +const navigationMenuTriggerStyle = cva( + "group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-lg font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1" +) + +function NavigationMenuTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + {children}{" "} + + ) +} + +function NavigationMenuContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function NavigationMenuViewport({ + className, + ...props +}: React.ComponentProps) { + return ( +
    + +
    + ) +} + +function NavigationMenuLink({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function NavigationMenuIndicator({ + className, + ...props +}: React.ComponentProps) { + return ( + +
    + + ) +} + +export { + NavigationMenu, + NavigationMenuList, + NavigationMenuItem, + NavigationMenuContent, + NavigationMenuTrigger, + NavigationMenuLink, + NavigationMenuIndicator, + NavigationMenuViewport, + navigationMenuTriggerStyle, +} diff --git a/web/src/config.ts b/web/src/config.ts new file mode 100644 index 0000000..263acf3 --- /dev/null +++ b/web/src/config.ts @@ -0,0 +1,8 @@ +const config = { + metadata: { + name: "Snowykami's Blog", + icon: "https://cdn.liteyuki.org/snowykami/avatar.jpg", + } +} + +export default config \ No newline at end of file diff --git a/web/src/contexts/DeviceContext.tsx b/web/src/contexts/DeviceContext.tsx new file mode 100644 index 0000000..21181da --- /dev/null +++ b/web/src/contexts/DeviceContext.tsx @@ -0,0 +1,142 @@ +"use client"; + +import React, { createContext, useCallback, useContext, useEffect, useState } from "react"; + +import i18n, { getDefaultLang } from "@/utils/i18n"; + +type Mode = "light" | "dark"; +type Lang = string; + +interface DeviceContextProps { + isMobile: boolean; + mode: Mode; + setMode: (mode: Mode) => void; + toggleMode: () => void; + lang: Lang; + setLang: (lang: Lang) => void; + viewport: { + width: number; + height: number; + }; +} + +const DeviceContext = createContext({ + isMobile: false, + mode: "light", + setMode: () => {}, + toggleMode: () => {}, + lang: "zh-cn", + setLang: () => {}, + viewport: { + width: 0, + height: 0, + }, +}); + +export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [isMobile, setIsMobile] = useState(false); + const [mode, setModeState] = useState("light"); + const [lang, setLangState] = useState(getDefaultLang()); + const [viewport, setViewport] = useState({ + width: typeof window !== "undefined" ? window.innerWidth : 0, + height: typeof window !== "undefined" ? window.innerHeight : 0, + }); + + // 检查系统主题 + const getSystemTheme = () => + typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + + useEffect(() => { + const checkMobile = () => setIsMobile(window.innerWidth <= 768); + checkMobile(); + window.addEventListener("resize", checkMobile); + return () => window.removeEventListener("resize", checkMobile); + }, []); + + useEffect(() => { + // 更新检测函数以同时更新视窗尺寸 + const handleResize = () => { + const width = window.innerWidth; + const height = window.innerHeight; + setIsMobile(width <= 768); + setViewport({ width, height }); + }; + + handleResize(); // 初始化 + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + // 初始化主题和系统主题变化监听 + useEffect(() => { + if (typeof window !== "undefined") { + const savedTheme = localStorage.getItem("theme") as Mode | null; + const systemTheme = getSystemTheme(); + const theme = savedTheme || systemTheme; + setModeState(theme); + document.documentElement.classList.toggle("dark", theme === "dark"); + + // 监听系统主题变动 + 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"); + } + }; + media.addEventListener("change", handleChange); + return () => media.removeEventListener("change", handleChange); + } + }, []); + + // 初始化语言 + useEffect(() => { + if (typeof window !== "undefined") { + const savedLang = localStorage.getItem("language") || getDefaultLang(); + setLangState(savedLang); + i18n.changeLanguage(savedLang); + } + }, []); + + const setMode = useCallback((newMode: Mode) => { + setModeState(newMode); + document.documentElement.classList.toggle("dark", newMode === "dark"); + if (newMode === getSystemTheme()) { + localStorage.removeItem("theme"); + } else { + localStorage.setItem("theme", newMode); + } + }, []); + + const toggleMode = useCallback(() => { + setModeState((prev) => { + const newMode = prev === "dark" ? "light" : "dark"; + document.documentElement.classList.toggle("dark", newMode === "dark"); + if (newMode === getSystemTheme()) { + localStorage.removeItem("theme"); + } else { + localStorage.setItem("theme", newMode); + } + return newMode; + }); + }, []); + + const setLang = useCallback((newLang: Lang) => { + setLangState(newLang); + i18n.changeLanguage(newLang); + localStorage.setItem("language", newLang); + }, []); + + return ( + + {children} + + ); +}; + +export const useDevice = () => useContext(DeviceContext); diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts new file mode 100644 index 0000000..bd0c391 --- /dev/null +++ b/web/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/web/src/models/oidc-config.ts b/web/src/models/oidc-config.ts new file mode 100644 index 0000000..aa6b671 --- /dev/null +++ b/web/src/models/oidc-config.ts @@ -0,0 +1,12 @@ +export interface OidcConfig { + id: number; + name: string; + displayName: string; + icon: string; + loginUrl: string; + // for admin + oidcDiscoveryUrl?: string; + clientId?: string; + clientSecret?: string; + enabled?: boolean; +} \ No newline at end of file diff --git a/web/src/models/resp.ts b/web/src/models/resp.ts new file mode 100644 index 0000000..0067ddd --- /dev/null +++ b/web/src/models/resp.ts @@ -0,0 +1,5 @@ +export interface BaseResponse { + data: T; + message: string; + status: number; +} \ No newline at end of file diff --git a/web/src/models/user.ts b/web/src/models/user.ts new file mode 100644 index 0000000..671a218 --- /dev/null +++ b/web/src/models/user.ts @@ -0,0 +1,9 @@ +export interface User { + id: number; + username: string; + nickname: string; + avatarUrl: string; + email: string; + gender: string; + role: string; +} \ No newline at end of file diff --git a/web/src/utils/i18n/index.ts b/web/src/utils/i18n/index.ts new file mode 100644 index 0000000..71f9050 --- /dev/null +++ b/web/src/utils/i18n/index.ts @@ -0,0 +1,27 @@ +import { initReactI18next } from "react-i18next"; + +import i18n from "i18next"; + +import resources from "./locales"; + +export const getDefaultLang = () => { + if (typeof window !== "undefined") { + return ( + localStorage.getItem("language") || + navigator.language.replace("_", "-") || // 保证格式 + "zh-CN" + ); + } + return "zh-CN"; +}; + +i18n.use(initReactI18next).init({ + resources: resources, + lng: getDefaultLang(), + fallbackLng: "zh-CN", + interpolation: { + escapeValue: false, + }, +}); + +export default i18n; diff --git a/web/src/utils/i18n/locales/en-us.ts b/web/src/utils/i18n/locales/en-us.ts new file mode 100644 index 0000000..7bab739 --- /dev/null +++ b/web/src/utils/i18n/locales/en-us.ts @@ -0,0 +1,30 @@ +const resources = { + translation: { + name: "English", + hello: "Hello", + login: { + login: "Login", + failed: "Login failed", + forgotPassword: "Forgot password?", + username: "Username", + usernameOrEmail: "Username or Email", + password: "Password", + remember: "Remember this device", + captcha: { + no: "No captcha required", + failed: "Captcha verification failed, please try again", + fetchFailed: "Failed to fetch captcha, please try again later", + processing: "Waiting for verification...", + reCaptchaProcessing: "Processing reCAPTCHA verification, please wait...", + reCaptchaFailed: "reCAPTCHA verification failed, please try again", + reCaptchaSuccess: "reCAPTCHA verification successful", + }, + oidc: { + fetchFailed: "Failed to fetch OIDC providers, please try again later", + use: "Login with {{provider}}", + }, + }, + }, +}; + +export default resources; diff --git a/web/src/utils/i18n/locales/index.ts b/web/src/utils/i18n/locales/index.ts new file mode 100644 index 0000000..421adf9 --- /dev/null +++ b/web/src/utils/i18n/locales/index.ts @@ -0,0 +1,9 @@ +import enUS from "./en-us"; +import zhCN from "./zh-cn"; + +const resources = { + "zh-CN": zhCN, + "en-US": enUS, +}; + +export default resources; diff --git a/web/src/utils/i18n/locales/zh-cn.ts b/web/src/utils/i18n/locales/zh-cn.ts new file mode 100644 index 0000000..35a81cb --- /dev/null +++ b/web/src/utils/i18n/locales/zh-cn.ts @@ -0,0 +1,30 @@ +const resources = { + translation: { + name: "中文", + hello: "你好", + login: { + login: "登录", + failed: "登录失败", + forgotPassword: "忘了密码?", + username: "用户名", + usernameOrEmail: "用户名或邮箱", + password: "密码", + remember: "记住这个设备", + captcha: { + no: "无需进行机器人挑战", + failed: "机器人挑战失败,请重试", + fetchFailed: "获取验证码失败,请稍后再试", + processing: "等待验证...", + reCaptchaProcessing: "正在处理 reCAPTCHA 验证,请稍候...", + reCaptchaFailed: "reCAPTCHA 验证失败,请重试", + reCaptchaSuccess: "reCAPTCHA 验证成功", + }, + oidc: { + fetchFailed: "获取 OIDC 提供商失败,请稍后再试", + use: "使用 {{provider}} 登录", + }, + }, + }, +}; + +export default resources; diff --git a/web/src/views/BlogHome.tsx b/web/src/views/BlogHome.tsx new file mode 100644 index 0000000..e69de29