mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-26 19:16:24 +00:00
@ -1,7 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
type ConfigController struct{}
|
|
||||||
|
|
||||||
func NewConfigController() *ConfigController {
|
|
||||||
return &ConfigController{}
|
|
||||||
}
|
|
64
internal/controller/v1/misc.go
Normal file
64
internal/controller/v1/misc.go
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/cloudwego/hertz/pkg/app"
|
||||||
|
"github.com/cloudwego/hertz/pkg/common/utils"
|
||||||
|
"github.com/snowykami/neo-blog/internal/repo"
|
||||||
|
"github.com/snowykami/neo-blog/pkg/resps"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
KeySiteInfo = "site_info"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MiscController struct{}
|
||||||
|
|
||||||
|
func NewMiscController() *MiscController {
|
||||||
|
return &MiscController{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mc *MiscController) GetSiteInfo(ctx context.Context, c *app.RequestContext) {
|
||||||
|
value, err := repo.KV.GetKV(KeySiteInfo, utils.H{
|
||||||
|
"metadata": utils.H{
|
||||||
|
"name": "Neo Blog S",
|
||||||
|
"icon": "https://cdn.liteyuki.org/snowykami/avatar.jpg",
|
||||||
|
"description": "A neo blog system.",
|
||||||
|
},
|
||||||
|
"color_schemes": []string{"blue", "green", "orange", "red", "rose", "violet", "yellow"},
|
||||||
|
"default_cover": "https://cdn.liteyuki.org/blog/background.png",
|
||||||
|
"owner": utils.H{
|
||||||
|
"name": "SnowyKami",
|
||||||
|
"description": "A full-stack developer.",
|
||||||
|
"avatar": "https://cdn.liteyuki.org/snowykami/avatar.jpg",
|
||||||
|
},
|
||||||
|
"posts_per_page": 9,
|
||||||
|
"comments_per_page": 8,
|
||||||
|
"verify_code_cool_down": 60,
|
||||||
|
"animation_duration_second": 0.618,
|
||||||
|
"footer": utils.H{
|
||||||
|
"text": "Liteyuki ICP 114514",
|
||||||
|
"links": []string{"https://www.liteyuki.com/"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
resps.InternalServerError(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resps.Ok(c, "", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mc *MiscController) SetSiteInfo(ctx context.Context, c *app.RequestContext) {
|
||||||
|
data := make(map[string]interface{})
|
||||||
|
err := c.BindAndValidate(&data)
|
||||||
|
if err != nil {
|
||||||
|
resps.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = repo.KV.SetKV(KeySiteInfo, data)
|
||||||
|
if err != nil {
|
||||||
|
resps.InternalServerError(c, err.Error())
|
||||||
|
}
|
||||||
|
resps.Ok(c, "", nil)
|
||||||
|
}
|
@ -1,102 +1,104 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
type UserDto struct {
|
type UserDto struct {
|
||||||
ID uint `json:"id"` // 用户ID
|
ID uint `json:"id"` // 用户ID
|
||||||
Username string `json:"username"` // 用户名
|
Username string `json:"username"` // 用户名
|
||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
AvatarUrl string `json:"avatar_url"` // 头像URL
|
AvatarUrl string `json:"avatar_url"` // 头像URL
|
||||||
BackgroundUrl string `json:"background_url"`
|
BackgroundUrl string `json:"background_url"`
|
||||||
Email string `json:"email"` // 邮箱
|
PreferredColor string `json:"preferred_color"` // 主题色
|
||||||
Gender string `json:"gender"`
|
Email string `json:"email"` // 邮箱
|
||||||
Role string `json:"role"`
|
Gender string `json:"gender"`
|
||||||
Language string `json:"language"` // 语言
|
Role string `json:"role"`
|
||||||
|
Language string `json:"language"` // 语言
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserOidcConfigDto struct {
|
type UserOidcConfigDto struct {
|
||||||
Name string `json:"name"` // OIDC配置名称
|
Name string `json:"name"` // OIDC配置名称
|
||||||
DisplayName string `json:"display_name"` // OIDC配置显示名称
|
DisplayName string `json:"display_name"` // OIDC配置显示名称
|
||||||
Icon string `json:"icon"` // OIDC配置图标URL
|
Icon string `json:"icon"` // OIDC配置图标URL
|
||||||
LoginUrl string `json:"login_url"` // OIDC登录URL
|
LoginUrl string `json:"login_url"` // OIDC登录URL
|
||||||
}
|
}
|
||||||
type UserLoginReq struct {
|
type UserLoginReq struct {
|
||||||
Username string `json:"username"` // username or email
|
Username string `json:"username"` // username or email
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserLoginResp struct {
|
type UserLoginResp struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
RefreshToken string `json:"refresh_token"`
|
RefreshToken string `json:"refresh_token"`
|
||||||
User UserDto `json:"user"`
|
User UserDto `json:"user"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserRegisterReq struct {
|
type UserRegisterReq struct {
|
||||||
Username string `json:"username"` // 用户名
|
Username string `json:"username"` // 用户名
|
||||||
Nickname string `json:"nickname"` // 昵称
|
Nickname string `json:"nickname"` // 昵称
|
||||||
Password string `json:"password"` // 密码
|
Password string `json:"password"` // 密码
|
||||||
Email string `json:"-" binding:"-"`
|
Email string `json:"-" binding:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserRegisterResp struct {
|
type UserRegisterResp struct {
|
||||||
Token string `json:"token"` // 访问令牌
|
Token string `json:"token"` // 访问令牌
|
||||||
RefreshToken string `json:"refresh_token"` // 刷新令牌
|
RefreshToken string `json:"refresh_token"` // 刷新令牌
|
||||||
User UserDto `json:"user"` // 用户信息
|
User UserDto `json:"user"` // 用户信息
|
||||||
}
|
}
|
||||||
|
|
||||||
type VerifyEmailReq struct {
|
type VerifyEmailReq struct {
|
||||||
Email string `json:"email"` // 邮箱地址
|
Email string `json:"email"` // 邮箱地址
|
||||||
}
|
}
|
||||||
|
|
||||||
type VerifyEmailResp struct {
|
type VerifyEmailResp struct {
|
||||||
Success bool `json:"success"` // 验证码发送成功与否
|
Success bool `json:"success"` // 验证码发送成功与否
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcLoginReq struct {
|
type OidcLoginReq struct {
|
||||||
Name string `json:"name"` // OIDC配置名称
|
Name string `json:"name"` // OIDC配置名称
|
||||||
Code string `json:"code"` // OIDC授权码
|
Code string `json:"code"` // OIDC授权码
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcLoginResp struct {
|
type OidcLoginResp struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
RefreshToken string `json:"refresh_token"`
|
RefreshToken string `json:"refresh_token"`
|
||||||
User UserDto `json:"user"`
|
User UserDto `json:"user"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListOidcConfigResp struct {
|
type ListOidcConfigResp struct {
|
||||||
OidcConfigs []UserOidcConfigDto `json:"oidc_configs"` // OIDC配置列表
|
OidcConfigs []UserOidcConfigDto `json:"oidc_configs"` // OIDC配置列表
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetUserReq struct {
|
type GetUserReq struct {
|
||||||
UserID uint `json:"user_id"`
|
UserID uint `json:"user_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetUserByUsernameReq struct {
|
type GetUserByUsernameReq struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetUserResp struct {
|
type GetUserResp struct {
|
||||||
User UserDto `json:"user"` // 用户信息
|
User UserDto `json:"user"` // 用户信息
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateUserReq struct {
|
type UpdateUserReq struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
AvatarUrl string `json:"avatar_url"`
|
AvatarUrl string `json:"avatar_url"`
|
||||||
BackgroundUrl string `json:"background_url"`
|
BackgroundUrl string `json:"background_url"`
|
||||||
Gender string `json:"gender"`
|
PreferredColor string `json:"preferred_color"`
|
||||||
|
Gender string `json:"gender"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateUserResp struct {
|
type UpdateUserResp struct {
|
||||||
User *UserDto `json:"user"` // 更新后的用户信息
|
User *UserDto `json:"user"` // 更新后的用户信息
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdatePasswordReq struct {
|
type UpdatePasswordReq struct {
|
||||||
OldPassword string `json:"old_password"`
|
OldPassword string `json:"old_password"`
|
||||||
NewPassword string `json:"new_password"`
|
NewPassword string `json:"new_password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ResetPasswordReq struct {
|
type ResetPasswordReq struct {
|
||||||
Email string `json:"-" binding:"-"`
|
Email string `json:"-" binding:"-"`
|
||||||
NewPassword string `json:"new_password"`
|
NewPassword string `json:"new_password"`
|
||||||
}
|
}
|
||||||
|
84
internal/model/json.go
Normal file
84
internal/model/json.go
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JSONMap 是一个通用的 JSON 类型(map[string]any)
|
||||||
|
type JSONMap map[string]any
|
||||||
|
|
||||||
|
func (JSONMap) GormDataType() string {
|
||||||
|
return "json"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (JSONMap) GormDBDataType(db *gorm.DB, _ *schema.Field) string {
|
||||||
|
switch db.Dialector.Name() {
|
||||||
|
case "mysql":
|
||||||
|
return "JSON"
|
||||||
|
case "postgres":
|
||||||
|
return "JSONB"
|
||||||
|
default: // sqlite 等
|
||||||
|
return "TEXT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value 实现 driver.Valuer,用于写入数据库
|
||||||
|
func (j JSONMap) Value() (driver.Value, error) {
|
||||||
|
if j == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(map[string]any(j))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan 实现 sql.Scanner,用于从数据库读取并反序列化
|
||||||
|
func (j *JSONMap) Scan(src any) error {
|
||||||
|
if src == nil {
|
||||||
|
*j = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var data []byte
|
||||||
|
switch v := src.(type) {
|
||||||
|
case string:
|
||||||
|
data = []byte(v)
|
||||||
|
case []byte:
|
||||||
|
data = v
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("cannot scan JSONMap from %T", src)
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
*j = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal(data, &m); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*j = JSONMap(m)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j JSONMap) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(map[string]any(j))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *JSONMap) UnmarshalJSON(b []byte) error {
|
||||||
|
if len(b) == 0 || string(b) == "null" {
|
||||||
|
*j = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal(b, &m); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*j = JSONMap(m)
|
||||||
|
return nil
|
||||||
|
}
|
6
internal/model/kv.go
Normal file
6
internal/model/kv.go
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
type KV struct {
|
||||||
|
Key string `gorm:"primaryKey;type:varchar(64);not null;comment:键"`
|
||||||
|
Value JSONMap
|
||||||
|
}
|
@ -1,41 +1,43 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/snowykami/neo-blog/internal/dto"
|
"github.com/snowykami/neo-blog/internal/dto"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
Username string `gorm:"uniqueIndex;not null"` // 用户名,唯一
|
Username string `gorm:"uniqueIndex;not null"` // 用户名,唯一
|
||||||
Nickname string `gorm:"default:''"` // 昵称
|
Nickname string `gorm:"default:''"` // 昵称
|
||||||
AvatarUrl string
|
AvatarUrl string
|
||||||
BackgroundUrl string
|
BackgroundUrl string
|
||||||
Email string `gorm:"uniqueIndex"`
|
PreferredColor string `gorm:"default:'global'"` // 主题色
|
||||||
Gender string `gorm:"default:''"`
|
Email string `gorm:"uniqueIndex"`
|
||||||
Role string `gorm:"default:'user'"` // user editor admin
|
Gender string `gorm:"default:''"`
|
||||||
Language string `gorm:"default:'en'"`
|
Role string `gorm:"default:'user'"` // user editor admin
|
||||||
Password string // 密码,存储加密后的值
|
Language string `gorm:"default:'en'"`
|
||||||
|
Password string // 密码,存储加密后的值
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserOpenID struct {
|
type UserOpenID struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
UserID uint `gorm:"index"`
|
UserID uint `gorm:"index"`
|
||||||
User User `gorm:"foreignKey:UserID;references:ID"`
|
User User `gorm:"foreignKey:UserID;references:ID"`
|
||||||
Issuer string `gorm:"index"` // OIDC Issuer
|
Issuer string `gorm:"index"` // OIDC Issuer
|
||||||
Sub string `gorm:"index"` // OIDC Sub openid
|
Sub string `gorm:"index"` // OIDC Sub openid
|
||||||
}
|
}
|
||||||
|
|
||||||
func (user *User) ToDto() dto.UserDto {
|
func (user *User) ToDto() dto.UserDto {
|
||||||
return dto.UserDto{
|
return dto.UserDto{
|
||||||
ID: user.ID,
|
ID: user.ID,
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
Nickname: user.Nickname,
|
Nickname: user.Nickname,
|
||||||
AvatarUrl: user.AvatarUrl,
|
AvatarUrl: user.AvatarUrl,
|
||||||
BackgroundUrl: user.BackgroundUrl,
|
BackgroundUrl: user.BackgroundUrl,
|
||||||
Email: user.Email,
|
PreferredColor: user.PreferredColor,
|
||||||
Gender: user.Gender,
|
Email: user.Email,
|
||||||
Role: user.Role,
|
Gender: user.Gender,
|
||||||
Language: user.Language,
|
Role: user.Role,
|
||||||
}
|
Language: user.Language,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -129,6 +129,7 @@ func migrate() error {
|
|||||||
&model.Label{},
|
&model.Label{},
|
||||||
&model.Like{},
|
&model.Like{},
|
||||||
&model.File{},
|
&model.File{},
|
||||||
|
&model.KV{},
|
||||||
&model.OidcConfig{},
|
&model.OidcConfig{},
|
||||||
&model.Post{},
|
&model.Post{},
|
||||||
&model.Session{},
|
&model.Session{},
|
||||||
|
50
internal/repo/kv.go
Normal file
50
internal/repo/kv.go
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/snowykami/neo-blog/internal/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type kvRepo struct{}
|
||||||
|
|
||||||
|
var KV = &kvRepo{}
|
||||||
|
|
||||||
|
func (k *kvRepo) GetKV(key string, defaultValue ...any) (any, error) {
|
||||||
|
var kv = &model.KV{}
|
||||||
|
err := GetDB().First(kv, "key = ?", key).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
if len(defaultValue) > 0 {
|
||||||
|
return defaultValue[0], nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if kv.Value == nil {
|
||||||
|
if len(defaultValue) > 0 {
|
||||||
|
return defaultValue[0], nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := kv.Value["value"]; ok {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(defaultValue) > 0 {
|
||||||
|
return defaultValue[0], nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *kvRepo) SetKV(key string, value any) error {
|
||||||
|
kv := &model.KV{
|
||||||
|
Key: key,
|
||||||
|
Value: map[string]any{"value": value},
|
||||||
|
}
|
||||||
|
return GetDB().Save(kv).Error
|
||||||
|
}
|
18
internal/repo/kv_test.go
Normal file
18
internal/repo/kv_test.go
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestKvRepo_GetKV(t *testing.T) {
|
||||||
|
err := KV.SetKV("AAA", map[string]interface{}{"b": 1, "c": "2"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
v, _ := KV.GetKV("AAA")
|
||||||
|
t.Log(v)
|
||||||
|
if v.(map[string]interface{})["b"] != float64(1) {
|
||||||
|
t.Fatal("b not equal")
|
||||||
|
}
|
||||||
|
if v.(map[string]interface{})["c"] != "2" {
|
||||||
|
t.Fatal("c not equal")
|
||||||
|
}
|
||||||
|
}
|
@ -1,22 +0,0 @@
|
|||||||
package apiv1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/cloudwego/hertz/pkg/route"
|
|
||||||
"github.com/snowykami/neo-blog/internal/controller/v1"
|
|
||||||
"github.com/snowykami/neo-blog/internal/middleware"
|
|
||||||
"github.com/snowykami/neo-blog/pkg/constant"
|
|
||||||
)
|
|
||||||
|
|
||||||
func registerConfigRoutes(group *route.RouterGroup) {
|
|
||||||
// Need Admin Middleware
|
|
||||||
adminController := v1.NewAdminController()
|
|
||||||
consoleGroup := group.Group("/admin").Use(middleware.UseAuth(true)).Use(middleware.UseRole(constant.RoleAdmin))
|
|
||||||
{
|
|
||||||
consoleGroup.POST("/oidc/o", adminController.CreateOidc)
|
|
||||||
consoleGroup.DELETE("/oidc/o/:id", adminController.DeleteOidc)
|
|
||||||
consoleGroup.GET("/oidc/o/:id", adminController.GetOidcByID)
|
|
||||||
consoleGroup.GET("/oidc/list", adminController.ListOidc)
|
|
||||||
consoleGroup.PUT("/oidc/o/:id", adminController.UpdateOidc)
|
|
||||||
consoleGroup.GET("/dashboard", adminController.GetDashboard)
|
|
||||||
}
|
|
||||||
}
|
|
18
internal/router/apiv1/misc.go
Normal file
18
internal/router/apiv1/misc.go
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
package apiv1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/cloudwego/hertz/pkg/route"
|
||||||
|
v1 "github.com/snowykami/neo-blog/internal/controller/v1"
|
||||||
|
"github.com/snowykami/neo-blog/internal/middleware"
|
||||||
|
"github.com/snowykami/neo-blog/pkg/constant"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerMiscRoutes(group *route.RouterGroup) {
|
||||||
|
miscController := v1.NewMiscController()
|
||||||
|
miscGroupAdmin := group.Group("/misc").Use(middleware.UseAuth(true)).Use(middleware.UseRole(constant.RoleAdmin))
|
||||||
|
miscGroupWithoutAuth := group.Group("/misc").Use(middleware.UseAuth(false))
|
||||||
|
{
|
||||||
|
miscGroupWithoutAuth.GET("/site-info", miscController.GetSiteInfo)
|
||||||
|
miscGroupAdmin.PUT("/site-info", miscController.SetSiteInfo)
|
||||||
|
}
|
||||||
|
}
|
@ -3,15 +3,16 @@ package apiv1
|
|||||||
import "github.com/cloudwego/hertz/pkg/app/server"
|
import "github.com/cloudwego/hertz/pkg/app/server"
|
||||||
|
|
||||||
func RegisterRoutes(h *server.Hertz) {
|
func RegisterRoutes(h *server.Hertz) {
|
||||||
apiV1Group := h.Group("/api/v1")
|
apiV1Group := h.Group("/api/v1")
|
||||||
{
|
{
|
||||||
registerCommentRoutes(apiV1Group)
|
registerCommentRoutes(apiV1Group)
|
||||||
registerAdminRoutes(apiV1Group)
|
registerAdminRoutes(apiV1Group)
|
||||||
registerFileRoutes(apiV1Group)
|
registerFileRoutes(apiV1Group)
|
||||||
registerLabelRoutes(apiV1Group)
|
registerLabelRoutes(apiV1Group)
|
||||||
registerLikeRoutes(apiV1Group)
|
registerLikeRoutes(apiV1Group)
|
||||||
registerPageRoutes(apiV1Group)
|
registerMiscRoutes(apiV1Group)
|
||||||
registerPostRoutes(apiV1Group)
|
registerPageRoutes(apiV1Group)
|
||||||
registerUserRoutes(apiV1Group)
|
registerPostRoutes(apiV1Group)
|
||||||
}
|
registerUserRoutes(apiV1Group)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -355,10 +355,12 @@ func (s *UserService) UpdateUser(req *dto.UpdateUserReq) (*dto.UpdateUserResp, e
|
|||||||
Model: gorm.Model{
|
Model: gorm.Model{
|
||||||
ID: req.ID,
|
ID: req.ID,
|
||||||
},
|
},
|
||||||
Username: req.Username,
|
Username: req.Username,
|
||||||
Nickname: req.Nickname,
|
Nickname: req.Nickname,
|
||||||
Gender: req.Gender,
|
Gender: req.Gender,
|
||||||
AvatarUrl: req.AvatarUrl,
|
AvatarUrl: req.AvatarUrl,
|
||||||
|
BackgroundUrl: req.BackgroundUrl,
|
||||||
|
PreferredColor: req.PreferredColor,
|
||||||
}
|
}
|
||||||
err := repo.User.UpdateUser(user)
|
err := repo.User.UpdateUser(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
9
web/src/api/misc.ts
Normal file
9
web/src/api/misc.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { BaseResponse } from "@/models/resp";
|
||||||
|
import axiosClient from "./client";
|
||||||
|
import type { SiteInfo } from "@/contexts/site-info-context";
|
||||||
|
|
||||||
|
|
||||||
|
export async function getSiteInfo(): Promise<BaseResponse<SiteInfo>>{
|
||||||
|
const res = await axiosClient.get<BaseResponse<SiteInfo>>('/misc/site-info');
|
||||||
|
return res.data;
|
||||||
|
}
|
@ -16,7 +16,6 @@ export async function userLogin(
|
|||||||
rememberMe?: boolean,
|
rememberMe?: boolean,
|
||||||
captcha?: string,
|
captcha?: string,
|
||||||
}): Promise<BaseResponse<{ token: string, user: User }>> {
|
}): Promise<BaseResponse<{ token: string, user: User }>> {
|
||||||
console.log("Logging in with captcha:", captcha)
|
|
||||||
const res = await axiosClient.post<BaseResponse<{ token: string, user: User }>>(
|
const res = await axiosClient.post<BaseResponse<{ token: string, user: User }>>(
|
||||||
'/user/login',
|
'/user/login',
|
||||||
{ username, password, rememberMe },
|
{ username, password, rememberMe },
|
||||||
|
@ -4,19 +4,22 @@ import { motion } from 'motion/react'
|
|||||||
import { Navbar } from '@/components/layout/nav/navbar-or-side'
|
import { Navbar } from '@/components/layout/nav/navbar-or-side'
|
||||||
import { BackgroundProvider } from '@/contexts/background-context'
|
import { BackgroundProvider } from '@/contexts/background-context'
|
||||||
import Footer from '@/components/layout/footer'
|
import Footer from '@/components/layout/footer'
|
||||||
import config from '@/config'
|
import { useSiteInfo } from '@/contexts/site-info-context'
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}>) {
|
}>) {
|
||||||
|
|
||||||
|
const { siteInfo } = useSiteInfo();
|
||||||
|
if (!siteInfo) return null;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<motion.nav
|
<motion.nav
|
||||||
initial={{ y: -64, opacity: 0 }}
|
initial={{ y: -64, opacity: 0 }}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
transition={{ duration: config.animationDurationSecond, ease: "easeOut" }}>
|
transition={{ duration: siteInfo.animationDurationSecond, ease: "easeOut" }}>
|
||||||
<header className="fixed top-0 left-0 h-16 w-full z-50 bg-white/80 dark:bg-slate-900/80 backdrop-blur flex justify-center border-b border-slate-200 dark:border-slate-800">
|
<header className="fixed top-0 left-0 h-16 w-full z-50 bg-white/80 dark:bg-slate-900/80 backdrop-blur flex justify-center border-b border-slate-200 dark:border-slate-800">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
</header>
|
</header>
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { UserPreferencePage } from "@/components/console/user-preference";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return <div>个性化设置</div>
|
return <UserPreferencePage />
|
||||||
}
|
}
|
@ -4,13 +4,13 @@ import { Geist, Geist_Mono } from "next/font/google";
|
|||||||
import { DeviceProvider } from "@/contexts/device-context";
|
import { DeviceProvider } from "@/contexts/device-context";
|
||||||
import { NextIntlClientProvider } from 'next-intl';
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
import { AuthProvider } from "@/contexts/auth-context";
|
import { AuthProvider } from "@/contexts/auth-context";
|
||||||
import config from "@/config";
|
|
||||||
import { getFirstLocale } from '@/i18n/request';
|
import { getFirstLocale } from '@/i18n/request';
|
||||||
import { Toaster } from "@/components/ui/sonner"
|
import { Toaster } from "@/components/ui/sonner"
|
||||||
import { getLoginUser } from "@/api/user";
|
import { getLoginUser } from "@/api/user";
|
||||||
import { NuqsAdapter } from 'nuqs/adapters/next/app'
|
import { NuqsAdapter } from 'nuqs/adapters/next/app'
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { fallbackSiteInfo, SiteInfoProvider } from "@/contexts/site-info-context";
|
||||||
|
import { getSiteInfo } from "@/api/misc";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@ -22,10 +22,43 @@ const geistMono = Geist_Mono({
|
|||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
title: config.metadata.name,
|
const siteInfo = await getSiteInfo().then(res => res.data).catch(() => fallbackSiteInfo);
|
||||||
description: config.metadata.description,
|
const siteName = siteInfo?.metadata?.name ?? "Snowykami's Blog";
|
||||||
};
|
const description = siteInfo?.metadata?.description ?? "分享一些好玩的东西";
|
||||||
|
const icon = siteInfo?.metadata?.icon ?? "/favicon.ico";
|
||||||
|
const defaultImage = siteInfo?.defaultCover ?? icon;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: {
|
||||||
|
default: siteName,
|
||||||
|
template: `%s - ${siteName}`,
|
||||||
|
},
|
||||||
|
description,
|
||||||
|
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'),
|
||||||
|
icons: {
|
||||||
|
icon,
|
||||||
|
apple: icon,
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: siteName,
|
||||||
|
description,
|
||||||
|
type: 'website',
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: defaultImage,
|
||||||
|
alt: siteName,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: siteName,
|
||||||
|
description,
|
||||||
|
images: defaultImage ? [defaultImage] : undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
@ -36,9 +69,11 @@ export default async function RootLayout({
|
|||||||
const token = (await cookies()).get("token")?.value || "";
|
const token = (await cookies()).get("token")?.value || "";
|
||||||
const refreshToken = (await cookies()).get("refresh_token")?.value || "";
|
const refreshToken = (await cookies()).get("refresh_token")?.value || "";
|
||||||
const user = await getLoginUser({ token, refreshToken }).then(res => res.data).catch(() => null);
|
const user = await getLoginUser({ token, refreshToken }).then(res => res.data).catch(() => null);
|
||||||
|
const siteInfo = await getSiteInfo().then(res => res.data).catch(() => fallbackSiteInfo);
|
||||||
|
const colorSchemes = siteInfo?.colorSchemes ? siteInfo.colorSchemes : fallbackSiteInfo.colorSchemes;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={await getFirstLocale() || "en"} className="h-full" user-color="blue">
|
<html lang={await getFirstLocale() || "en"} className="h-full" data-user-color={(colorSchemes).includes(user?.preferredColor || "") ? user?.preferredColor : "blue"}>
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
@ -47,7 +82,9 @@ export default async function RootLayout({
|
|||||||
<DeviceProvider>
|
<DeviceProvider>
|
||||||
<NextIntlClientProvider>
|
<NextIntlClientProvider>
|
||||||
<AuthProvider initialUser={user}>
|
<AuthProvider initialUser={user}>
|
||||||
{children}
|
<SiteInfoProvider initialData={siteInfo!}>
|
||||||
|
{children}
|
||||||
|
</SiteInfoProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</DeviceProvider>
|
</DeviceProvider>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
:root[user-color="blue"] {
|
[data-user-color="blue"] {
|
||||||
--radius: 0.65rem;
|
--radius: 0.65rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.141 0.005 285.823);
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
@ -33,7 +33,7 @@
|
|||||||
--sidebar-ring: oklch(0.623 0.214 259.815);
|
--sidebar-ring: oklch(0.623 0.214 259.815);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark[user-color="blue"] {
|
.dark[data-user-color="blue"] {
|
||||||
--background: oklch(0.141 0.005 285.823);
|
--background: oklch(0.141 0.005 285.823);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.21 0.006 285.885);
|
--card: oklch(0.21 0.006 285.885);
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
:root[user-color="green"] {
|
[data-user-color="green"] {
|
||||||
--radius: 0.65rem;
|
--radius: 0.65rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.141 0.005 285.823);
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
@ -33,7 +33,7 @@
|
|||||||
--sidebar-ring: oklch(0.723 0.219 149.579);
|
--sidebar-ring: oklch(0.723 0.219 149.579);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark[user-color="green"] {
|
.dark[data-user-color="green"] {
|
||||||
--background: oklch(0.141 0.005 285.823);
|
--background: oklch(0.141 0.005 285.823);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.21 0.006 285.885);
|
--card: oklch(0.21 0.006 285.885);
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
:root[user-color="orange"] {
|
[data-user-color="orange"] {
|
||||||
--radius: 0.65rem;
|
--radius: 0.65rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.141 0.005 285.823);
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
@ -33,7 +33,7 @@
|
|||||||
--sidebar-ring: oklch(0.705 0.213 47.604);
|
--sidebar-ring: oklch(0.705 0.213 47.604);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark[user-color="orange"] {
|
.dark[data-user-color="orange"] {
|
||||||
--background: oklch(0.141 0.005 285.823);
|
--background: oklch(0.141 0.005 285.823);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.21 0.006 285.885);
|
--card: oklch(0.21 0.006 285.885);
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
:root[user-color="red"] {
|
[data-user-color="red"] {
|
||||||
--radius: 0.65rem;
|
--radius: 0.65rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.141 0.005 285.823);
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
@ -33,7 +33,7 @@
|
|||||||
--sidebar-ring: oklch(0.637 0.237 25.331);
|
--sidebar-ring: oklch(0.637 0.237 25.331);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark[user-color="red"] {
|
.dark[data-user-color="red"] {
|
||||||
--background: oklch(0.141 0.005 285.823);
|
--background: oklch(0.141 0.005 285.823);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.21 0.006 285.885);
|
--card: oklch(0.21 0.006 285.885);
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
:root[user-color="rose"] {
|
[data-user-color="rose"] {
|
||||||
--radius: 0.65rem;
|
--radius: 0.65rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.141 0.005 285.823);
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
@ -33,7 +33,7 @@
|
|||||||
--sidebar-ring: oklch(0.645 0.246 16.439);
|
--sidebar-ring: oklch(0.645 0.246 16.439);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark[user-color="rose"] {
|
.dark[data-user-color="rose"] {
|
||||||
--background: oklch(0.141 0.005 285.823);
|
--background: oklch(0.141 0.005 285.823);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.21 0.006 285.885);
|
--card: oklch(0.21 0.006 285.885);
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
:root[user-color="violet"] {
|
[data-user-color="violet"] {
|
||||||
--radius: 0.65rem;
|
--radius: 0.65rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.141 0.005 285.823);
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
@ -33,7 +33,7 @@
|
|||||||
--sidebar-ring: oklch(0.606 0.25 292.717);
|
--sidebar-ring: oklch(0.606 0.25 292.717);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark[user-color="violet"] {
|
.dark[data-user-color="violet"] {
|
||||||
--background: oklch(0.141 0.005 285.823);
|
--background: oklch(0.141 0.005 285.823);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.21 0.006 285.885);
|
--card: oklch(0.21 0.006 285.885);
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
:root[user-color="violet"] {
|
[data-user-color="yellow"] {
|
||||||
--radius: 0.65rem;
|
--radius: 0.65rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.141 0.005 285.823);
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
@ -33,7 +33,7 @@
|
|||||||
--sidebar-ring: oklch(0.795 0.184 86.047);
|
--sidebar-ring: oklch(0.795 0.184 86.047);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark[user-color="violet"] {
|
.dark[data-user-color="yellow"] {
|
||||||
--background: oklch(0.141 0.005 285.823);
|
--background: oklch(0.141 0.005 285.823);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.21 0.006 285.885);
|
--card: oklch(0.21 0.006 285.885);
|
||||||
|
@ -1,21 +1,24 @@
|
|||||||
import config from "@/config";
|
import { useSiteInfo } from "@/contexts/site-info-context";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
export function AuthHeader() {
|
export function AuthHeader() {
|
||||||
|
const { siteInfo } = useSiteInfo();
|
||||||
|
|
||||||
|
if (!siteInfo) return null;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 self-center font-bold text-2xl">
|
<div className="flex items-center gap-2 self-center font-bold text-2xl">
|
||||||
<Link href="/" className="flex items-center gap-3">
|
<Link href="/" className="flex items-center gap-3">
|
||||||
<div className="flex size-10 items-center justify-center rounded-full overflow-hidden border-2 border-gray-300 dark:border-gray-600">
|
<div className="flex size-10 items-center justify-center rounded-full overflow-hidden border-2 border-gray-300 dark:border-gray-600">
|
||||||
<Image
|
<Image
|
||||||
src={config.metadata.icon}
|
src={siteInfo.metadata?.icon || ''}
|
||||||
alt="Logo"
|
alt="Logo"
|
||||||
width={40}
|
width={40}
|
||||||
height={40}
|
height={40}
|
||||||
className="rounded-full object-cover"
|
className="rounded-full object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-bold text-2xl">{config.metadata.name}</span>
|
<span className="font-bold text-2xl">{siteInfo.metadata?.name || ''}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -103,7 +103,7 @@ export function LoginForm({
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
{user && <CurrentLogged />}
|
{user && <CurrentLogged />}
|
||||||
<SectionDivider className="mb-6">{t("with_oidc")}</SectionDivider>
|
<SectionDivider className="mb-6">{t("with_oidc")}</SectionDivider>
|
||||||
<form>
|
<form onSubmit={handleLogin}>
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{/* OIDC 登录选项 */}
|
{/* OIDC 登录选项 */}
|
||||||
{oidcConfigs.length > 0 && (
|
{oidcConfigs.length > 0 && (
|
||||||
@ -168,9 +168,8 @@ export function LoginForm({
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="submit"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={handleLogin}
|
|
||||||
disabled={!captchaToken || isLogging}
|
disabled={!captchaToken || isLogging}
|
||||||
>
|
>
|
||||||
{isLogging ? t("logging") : t("login")}
|
{isLogging ? t("logging") : t("login")}
|
||||||
@ -215,6 +214,7 @@ function LoginWithOidc({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={handleOidcLogin}
|
onClick={handleOidcLogin}
|
||||||
|
@ -4,18 +4,19 @@ import Image from 'next/image'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import config from '@/config'
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { getPostHref } from '@/utils/common/post'
|
import { getPostHref } from '@/utils/common/post'
|
||||||
import { motion } from 'motion/react'
|
import { motion } from 'motion/react'
|
||||||
import { deceleration } from '@/motion/curve'
|
import { deceleration } from '@/motion/curve'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { useSiteInfo } from '@/contexts/site-info-context'
|
||||||
|
|
||||||
|
|
||||||
export function BlogCard({ post, className }: {
|
export function BlogCard({ post, className }: {
|
||||||
post: Post
|
post: Post
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
|
const {siteInfo} = useSiteInfo();
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
const date = new Date(dateString)
|
const date = new Date(dateString)
|
||||||
return date.toLocaleDateString('zh-CN', {
|
return date.toLocaleDateString('zh-CN', {
|
||||||
@ -39,14 +40,14 @@ export function BlogCard({ post, className }: {
|
|||||||
initial={{ scale: 1.2, opacity: 0 }}
|
initial={{ scale: 1.2, opacity: 0 }}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
transition={{
|
transition={{
|
||||||
duration: config.animationDurationSecond,
|
duration: siteInfo.animationDurationSecond,
|
||||||
ease: deceleration,
|
ease: deceleration,
|
||||||
}}
|
}}
|
||||||
className="absolute inset-0 w-full h-full"
|
className="absolute inset-0 w-full h-full"
|
||||||
>
|
>
|
||||||
{(post.cover || config.defaultCover) ? (
|
{(post.cover || siteInfo.defaultCover) ? (
|
||||||
<Image
|
<Image
|
||||||
src={post.cover || config.defaultCover}
|
src={post.cover || siteInfo.defaultCover || "https://cdn.liteyuki.org/blog/background.png"}
|
||||||
alt={post.title}
|
alt={post.title}
|
||||||
fill
|
fill
|
||||||
className="object-cover w-full h-full transition-transform duration-300 group-hover:scale-105"
|
className="object-cover w-full h-full transition-transform duration-300 group-hover:scale-105"
|
||||||
@ -226,12 +227,13 @@ export function BlogCardGrid({
|
|||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
showPrivate?: boolean
|
showPrivate?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const {siteInfo} = useSiteInfo();
|
||||||
const filteredPosts = showPrivate ? posts : posts.filter(post => !post.isPrivate)
|
const filteredPosts = showPrivate ? posts : posts.filter(post => !post.isPrivate)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{Array.from({ length: config.postsPerPage }).map((_, index) => (
|
{Array.from({ length: siteInfo.postsPerPage || 9 }).map((_, index) => (
|
||||||
<BlogCardSkeleton key={index} />
|
<BlogCardSkeleton key={index} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
@ -4,7 +4,6 @@ import { BlogCardGrid } from "@/components/blog-home/blog-home-card";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { TrendingUp, Clock, } from "lucide-react";
|
import { TrendingUp, Clock, } from "lucide-react";
|
||||||
import Sidebar, { SidebarAbout, SidebarHotPosts, SidebarMisskeyIframe, SidebarTags } from "../blog/blog-sidebar-card";
|
import Sidebar, { SidebarAbout, SidebarHotPosts, SidebarMisskeyIframe, SidebarTags } from "../blog/blog-sidebar-card";
|
||||||
import config from '@/config';
|
|
||||||
import type { Post } from "@/models/post";
|
import type { Post } from "@/models/post";
|
||||||
import { listPosts } from "@/api/post";
|
import { listPosts } from "@/api/post";
|
||||||
|
|
||||||
@ -17,6 +16,7 @@ import { PaginationController } from "@/components/common/pagination";
|
|||||||
import { QueryKey } from "@/constant";
|
import { QueryKey } from "@/constant";
|
||||||
import { useStoredState } from "@/hooks/use-storage-state";
|
import { useStoredState } from "@/hooks/use-storage-state";
|
||||||
import { parseAsInteger, useQueryState } from "nuqs";
|
import { parseAsInteger, useQueryState } from "nuqs";
|
||||||
|
import { useSiteInfo } from "@/contexts/site-info-context";
|
||||||
|
|
||||||
// 定义排序类型
|
// 定义排序类型
|
||||||
enum SortBy {
|
enum SortBy {
|
||||||
@ -29,6 +29,7 @@ const DEFAULT_SORTBY: SortBy = SortBy.Latest;
|
|||||||
export default function BlogHome() {
|
export default function BlogHome() {
|
||||||
// 从路由查询参数中获取页码和标签们
|
// 从路由查询参数中获取页码和标签们
|
||||||
const t = useTranslations("BlogHome");
|
const t = useTranslations("BlogHome");
|
||||||
|
const {siteInfo} = useSiteInfo();
|
||||||
const [labels, setLabels] = useState<string[]>([]);
|
const [labels, setLabels] = useState<string[]>([]);
|
||||||
const [keywords, setKeywords] = useState<string[]>([]);
|
const [keywords, setKeywords] = useState<string[]>([]);
|
||||||
const [page, setPage] = useQueryState("page", parseAsInteger.withDefault(1).withOptions({ history: "replace", clearOnDefault: true }));
|
const [page, setPage] = useQueryState("page", parseAsInteger.withDefault(1).withOptions({ history: "replace", clearOnDefault: true }));
|
||||||
@ -43,7 +44,7 @@ export default function BlogHome() {
|
|||||||
listPosts(
|
listPosts(
|
||||||
{
|
{
|
||||||
page,
|
page,
|
||||||
size: config.postsPerPage,
|
size: siteInfo.postsPerPage || 9,
|
||||||
orderBy: sortBy === SortBy.Latest ? OrderBy.CreatedAt : OrderBy.Heat,
|
orderBy: sortBy === SortBy.Latest ? OrderBy.CreatedAt : OrderBy.Heat,
|
||||||
desc: true,
|
desc: true,
|
||||||
keywords: keywords.join(",") || undefined,
|
keywords: keywords.join(",") || undefined,
|
||||||
@ -83,7 +84,7 @@ export default function BlogHome() {
|
|||||||
className="lg:col-span-3 self-start"
|
className="lg:col-span-3 self-start"
|
||||||
initial={{ y: 40, opacity: 0 }}
|
initial={{ y: 40, opacity: 0 }}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
transition={{ duration: config.animationDurationSecond, ease: "easeOut" }}>
|
transition={{ duration: siteInfo.animationDurationSecond, ease: "easeOut" }}>
|
||||||
{/* 文章列表标题 */}
|
{/* 文章列表标题 */}
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<h2 className="text-3xl font-bold text-slate-900 dark:text-slate-100">
|
<h2 className="text-3xl font-bold text-slate-900 dark:text-slate-100">
|
||||||
@ -124,7 +125,7 @@ export default function BlogHome() {
|
|||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
{totalPosts > 0 && <PaginationController
|
{totalPosts > 0 && <PaginationController
|
||||||
className="pt-4 flex justify-center"
|
className="pt-4 flex justify-center"
|
||||||
pageSize={config.postsPerPage}
|
pageSize={siteInfo.postsPerPage}
|
||||||
initialPage={page}
|
initialPage={page}
|
||||||
total={totalPosts}
|
total={totalPosts}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
@ -144,11 +145,11 @@ export default function BlogHome() {
|
|||||||
<motion.div
|
<motion.div
|
||||||
initial={{ x: 80, opacity: 0 }}
|
initial={{ x: 80, opacity: 0 }}
|
||||||
animate={{ x: 0, y: 0, opacity: 1 }}
|
animate={{ x: 0, y: 0, opacity: 1 }}
|
||||||
transition={{ duration: config.animationDurationSecond, ease: "easeOut" }}
|
transition={{ duration: siteInfo.animationDurationSecond, ease: "easeOut" }}
|
||||||
>
|
>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
cards={[
|
cards={[
|
||||||
<SidebarAbout key="about" config={config} />,
|
<SidebarAbout key="about" />,
|
||||||
posts.length > 0 ? <SidebarHotPosts key="hot" posts={posts} sortType={sortBy} /> : null,
|
posts.length > 0 ? <SidebarHotPosts key="hot" posts={posts} sortType={sortBy} /> : null,
|
||||||
<SidebarTags key="tags" labels={[]} />,
|
<SidebarTags key="tags" labels={[]} />,
|
||||||
<SidebarMisskeyIframe key="misskey" />,
|
<SidebarMisskeyIframe key="misskey" />,
|
||||||
|
@ -7,7 +7,8 @@ import { calculateReadingTime } from "@/utils/common/post";
|
|||||||
import { CommentSection } from "@/components/comment";
|
import { CommentSection } from "@/components/comment";
|
||||||
import { TargetType } from '@/models/types';
|
import { TargetType } from '@/models/types';
|
||||||
import * as motion from "motion/react-client"
|
import * as motion from "motion/react-client"
|
||||||
import config from "@/config";
|
import { fallbackSiteInfo, useSiteInfo } from "@/contexts/site-info-context";
|
||||||
|
import { getSiteInfo } from "@/api/misc";
|
||||||
|
|
||||||
function PostMeta({ post }: { post: Post }) {
|
function PostMeta({ post }: { post: Post }) {
|
||||||
return (
|
return (
|
||||||
@ -141,6 +142,7 @@ async function PostContent({ post }: { post: Post }) {
|
|||||||
|
|
||||||
|
|
||||||
async function BlogPost({ post }: { post: Post}) {
|
async function BlogPost({ post }: { post: Post}) {
|
||||||
|
const siteInfo = await getSiteInfo().then(res => res.data).catch(() => fallbackSiteInfo);
|
||||||
return (
|
return (
|
||||||
<div className="h-full"
|
<div className="h-full"
|
||||||
>
|
>
|
||||||
@ -148,14 +150,14 @@ async function BlogPost({ post }: { post: Post}) {
|
|||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: -30 }}
|
initial={{ opacity: 0, y: -30 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: config.animationDurationSecond, ease: "easeOut" }}>
|
transition={{ duration: siteInfo.animationDurationSecond, ease: "easeOut" }}>
|
||||||
<PostHeader post={post} />
|
<PostHeader post={post} />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 30 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: config.animationDurationSecond, ease: "easeOut" }}>
|
transition={{ duration: siteInfo.animationDurationSecond, ease: "easeOut" }}>
|
||||||
<PostContent post={post} />
|
<PostContent post={post} />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
@ -3,7 +3,6 @@ import { Heart, TrendingUp, Eye } from "lucide-react";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import type { Label } from "@/models/label";
|
import type { Label } from "@/models/label";
|
||||||
import type { Post } from "@/models/post";
|
import type { Post } from "@/models/post";
|
||||||
import type configType from '@/config';
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@ -11,6 +10,7 @@ import { getPostHref } from "@/utils/common/post";
|
|||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { getGravatarUrl } from "@/utils/common/gravatar";
|
import { getGravatarUrl } from "@/utils/common/gravatar";
|
||||||
import { getFallbackAvatarFromUsername } from "@/utils/common/username";
|
import { getFallbackAvatarFromUsername } from "@/utils/common/username";
|
||||||
|
import { useSiteInfo } from "@/contexts/site-info-context";
|
||||||
|
|
||||||
// 侧边栏父组件,接收卡片组件列表
|
// 侧边栏父组件,接收卡片组件列表
|
||||||
export default function Sidebar({ cards }: { cards: React.ReactNode[] }) {
|
export default function Sidebar({ cards }: { cards: React.ReactNode[] }) {
|
||||||
@ -24,7 +24,9 @@ export default function Sidebar({ cards }: { cards: React.ReactNode[] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 关于我卡片
|
// 关于我卡片
|
||||||
export function SidebarAbout({ config }: { config: typeof configType }) {
|
export function SidebarAbout() {
|
||||||
|
const {siteInfo} = useSiteInfo();
|
||||||
|
if (!siteInfo) return null;
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@ -37,15 +39,15 @@ export function SidebarAbout({ config }: { config: typeof configType }) {
|
|||||||
<div className="text-center mb-4">
|
<div className="text-center mb-4">
|
||||||
<div className="w-20 h-20 mx-auto bg-gradient-to-br from-blue-400 to-purple-500 rounded-full flex items-center justify-center text-white text-2xl font-bold overflow-hidden">
|
<div className="w-20 h-20 mx-auto bg-gradient-to-br from-blue-400 to-purple-500 rounded-full flex items-center justify-center text-white text-2xl font-bold overflow-hidden">
|
||||||
<Avatar className="h-full w-full rounded-full">
|
<Avatar className="h-full w-full rounded-full">
|
||||||
<AvatarImage src={getGravatarUrl({email: config.owner.gravatarEmail, size: 256})} alt={config.owner.name} />
|
<AvatarImage src={getGravatarUrl({email: siteInfo?.owner?.gravatarEmail || "snowykami@outlook.com", size: 256})} alt={siteInfo?.owner?.name} />
|
||||||
<AvatarFallback className="rounded-full">{getFallbackAvatarFromUsername(config.owner.name)}</AvatarFallback>
|
<AvatarFallback className="rounded-full">{getFallbackAvatarFromUsername(siteInfo?.owner?.name || "Failed")}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-semibold text-lg">{config.owner.name}</h3>
|
<h3 className="font-semibold text-lg">{siteInfo?.owner?.name || "Failed H3"}</h3>
|
||||||
<p className="text-sm text-slate-600">{config.owner.motto}</p>
|
<p className="text-sm text-slate-600">{siteInfo?.owner?.motto || "Failed Motto"}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-slate-600 leading-relaxed">
|
<p className="text-sm text-slate-600 leading-relaxed">
|
||||||
{config.owner.description}
|
{siteInfo?.owner?.description || "Failed Description"}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
@ -10,8 +10,8 @@ import { Separator } from "@/components/ui/separator";
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { CommentInput } from "./comment-input";
|
import { CommentInput } from "./comment-input";
|
||||||
import { CommentItem } from "./comment-item";
|
import { CommentItem } from "./comment-item";
|
||||||
import config from "@/config";
|
|
||||||
import "./style.css";
|
import "./style.css";
|
||||||
|
import { useSiteInfo } from "@/contexts/site-info-context";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -26,6 +26,7 @@ export function CommentSection(
|
|||||||
totalCount?: number
|
totalCount?: number
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
const {siteInfo} = useSiteInfo();
|
||||||
const t = useTranslations('Comment')
|
const t = useTranslations('Comment')
|
||||||
const [comments, setComments] = useState<Comment[]>([]);
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
const [activeInput, setActiveInput] = useState<{ id: number; type: 'reply' | 'edit' } | null>(null);
|
const [activeInput, setActiveInput] = useState<{ id: number; type: 'reply' | 'edit' } | null>(null);
|
||||||
@ -45,12 +46,12 @@ export function CommentSection(
|
|||||||
orderBy: OrderBy.CreatedAt,
|
orderBy: OrderBy.CreatedAt,
|
||||||
desc: true,
|
desc: true,
|
||||||
page: 1,
|
page: 1,
|
||||||
size: config.commentsPerPage,
|
size: siteInfo.commentsPerPage || 8,
|
||||||
commentId: 0
|
commentId: 0
|
||||||
}).then(response => {
|
}).then(response => {
|
||||||
setComments(response.data.comments);
|
setComments(response.data.comments);
|
||||||
// If we got fewer comments than requested, no more pages
|
// If we got fewer comments than requested, no more pages
|
||||||
if (response.data.comments.length < config.commentsPerPage) {
|
if (response.data.comments.length < (siteInfo.commentsPerPage || 8)) {
|
||||||
setNeedLoadMore(false);
|
setNeedLoadMore(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -97,10 +98,10 @@ export function CommentSection(
|
|||||||
orderBy: OrderBy.CreatedAt,
|
orderBy: OrderBy.CreatedAt,
|
||||||
desc: true,
|
desc: true,
|
||||||
page: nextPage,
|
page: nextPage,
|
||||||
size: config.commentsPerPage,
|
size: siteInfo.commentsPerPage || 8,
|
||||||
commentId: 0
|
commentId: 0
|
||||||
}).then(response => {
|
}).then(response => {
|
||||||
if (response.data.comments.length < config.commentsPerPage) {
|
if (response.data.comments.length < (siteInfo.commentsPerPage || 8)) {
|
||||||
setNeedLoadMore(false);
|
setNeedLoadMore(false);
|
||||||
}
|
}
|
||||||
setComments(prevComments => [...prevComments, ...response.data.comments]);
|
setComments(prevComments => [...prevComments, ...response.data.comments]);
|
||||||
|
@ -15,16 +15,17 @@ import {
|
|||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
import config from "@/config"
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { sidebarData } from "./data"
|
import { sidebarData } from "./data"
|
||||||
import { ThemeModeToggle } from "../common/theme-toggle"
|
import { ThemeModeToggle } from "../common/theme-toggle"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
|
import { useSiteInfo } from "@/contexts/site-info-context"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
|
const {siteInfo} = useSiteInfo();
|
||||||
const [activeId, setActiveId] = useState("dashboard")
|
const [activeId, setActiveId] = useState("dashboard")
|
||||||
const consoleT = useTranslations("Console")
|
const consoleT = useTranslations("Console")
|
||||||
return (
|
return (
|
||||||
@ -38,7 +39,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
>
|
>
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<IconInnerShadowTop className="!size-5" />
|
<IconInnerShadowTop className="!size-5" />
|
||||||
<span className="text-base font-semibold">{config.metadata.name}</span>
|
<span className="text-base font-semibold">{siteInfo?.metadata?.name}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
84
web/src/components/console/common/color-scheme-selector.tsx
Normal file
84
web/src/components/console/common/color-scheme-selector.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useDevice } from "@/contexts/device-context";
|
||||||
|
import { fallbackSiteInfo, useSiteInfo } from "@/contexts/site-info-context";
|
||||||
|
|
||||||
|
export function ColorScheme(
|
||||||
|
{ className, color, selectedColor, setSelectedColor }:
|
||||||
|
{ className?: string, color: string, selectedColor: string, setSelectedColor: (color: string) => void }) {
|
||||||
|
return (
|
||||||
|
<div className={`w-full rounded-lg border p-3 shadow-sm box-border ${className ?? ""} ${selectedColor === color ? "border-primary bg-primary/10" : "border-border"} cursor-pointer hover:border-primary transition-colors`} onClick={() => setSelectedColor(color)}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Checkbox checked={selectedColor === color} onCheckedChange={(checked) => { if (checked) setSelectedColor(color); }} className="pointer-events-none" />
|
||||||
|
<div className="font-bold text-primary">{color.toUpperCase()}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="h-8 w-full rounded-md border border-border bg-card" />
|
||||||
|
<div className="text-xs text-muted-foreground"><Skeleton className="h-3 w-12" /></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="h-8 w-full rounded-md border border-border bg-ring" />
|
||||||
|
<div className="text-xs text-ring-foreground"><Skeleton className="h-3 w-12" /></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="h-8 w-full rounded-md border border-border bg-destructive" />
|
||||||
|
<div className="text-xs text-destructive-foreground"><Skeleton className="h-3 w-12" /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<div className="rounded-md border border-border p-2">
|
||||||
|
<div className="h-8 w-full rounded flex items-center justify-between px-3 bg-primary/10">
|
||||||
|
<Skeleton className="h-4 w-28" />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="h-6 w-14" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-16 w-full rounded p-2 bg-card">
|
||||||
|
<Skeleton className="h-3 w-full" />
|
||||||
|
<div className="mt-2">
|
||||||
|
<Skeleton className="h-3 w-3/4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex-1 h-8 rounded border border-border bg-primary" />
|
||||||
|
<div className="flex-1 h-8 rounded border border-border bg-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColorSchemeSelector({color, onColorChange }: { color: string | null, onColorChange?: (color: string) => void }) {
|
||||||
|
const {siteInfo} = useSiteInfo();
|
||||||
|
const colorSchemes = siteInfo?.colorSchemes ? siteInfo.colorSchemes : fallbackSiteInfo.colorSchemes;
|
||||||
|
const [selectedColor, setSelectedColor] = useState<string | null>(colorSchemes.includes(color || "") ? color : colorSchemes[0]);
|
||||||
|
const { isDark } = useDevice();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onColorChange?.(selectedColor!);
|
||||||
|
if (!selectedColor) return;
|
||||||
|
}, [selectedColor]);
|
||||||
|
|
||||||
|
if (!selectedColor) return null;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||||
|
{colorSchemes.map(color => (
|
||||||
|
<div key={color} data-user-color={color} className={`${isDark ? 'dark' : ''} p-2 min-w-0`}>
|
||||||
|
<ColorScheme color={color} selectedColor={selectedColor} setSelectedColor={setSelectedColor} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
45
web/src/components/console/user-preference/index.tsx
Normal file
45
web/src/components/console/user-preference/index.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import { ColorSchemeSelector } from "../common/color-scheme-selector"
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { updateUser } from "@/api/user";
|
||||||
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export function UserPreferencePage() {
|
||||||
|
const t = useTranslations("Console.user_preference")
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [color, setColor] = useState<string | null>(user?.preferredColor || null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!color) return;
|
||||||
|
const previousColor = user?.preferredColor;
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
document.documentElement.setAttribute("data-user-color", color);
|
||||||
|
}
|
||||||
|
updateUser({ id: user?.id, preferredColor: color })
|
||||||
|
.catch((error) => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
document.documentElement.setAttribute("data-user-color", previousColor || "blue");
|
||||||
|
}
|
||||||
|
setColor(previousColor || null);
|
||||||
|
toast.error("Failed to update color scheme", { description: error.message });
|
||||||
|
});
|
||||||
|
}, [color])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="grid w-full items-center gap-4">
|
||||||
|
<h1 className="text-2xl font-bold">
|
||||||
|
{t("title")}
|
||||||
|
</h1>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<h2 className="text">{t("color_scheme")}</h2>
|
||||||
|
<ColorSchemeSelector color={color} onColorChange={setColor} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -185,11 +185,10 @@ export function UserProfilePage() {
|
|||||||
if (!user) return null
|
if (!user) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="grid w-full max-w-sm items-center gap-4">
|
||||||
<h1 className="text-2xl font-bold">
|
<h1 className="text-2xl font-bold">
|
||||||
{t("public_profile")}
|
{t("public_profile")}
|
||||||
</h1>
|
</h1>
|
||||||
<Separator className="my-2" />
|
|
||||||
<div className="grid w-full max-w-sm items-center gap-4">
|
<div className="grid w-full max-w-sm items-center gap-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="picture">{t("picture")}</Label>
|
<Label htmlFor="picture">{t("picture")}</Label>
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
import config from "@/config";
|
import { useSiteInfo } from "@/contexts/site-info-context";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
|
const { siteInfo } = useSiteInfo();
|
||||||
|
if (!siteInfo) return null;
|
||||||
return (
|
return (
|
||||||
<footer className="w-full py-6 text-center text-sm text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700">
|
<footer className="w-full py-6 text-center text-sm text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700">
|
||||||
© {new Date().getFullYear()} {config.metadata.name} · Powered by {config.owner.name} · {config.footer.text}
|
© {new Date().getFullYear()} {siteInfo?.metadata?.name} · Powered by {siteInfo?.owner?.name} · {siteInfo?.footer?.text}
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -9,14 +9,17 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useToLogin } from "@/hooks/use-route";
|
import { consolePath, useToLogin } from "@/hooks/use-route";
|
||||||
import { CircleUser } from "lucide-react";
|
import { CircleUser } from "lucide-react";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { getGravatarFromUser } from "@/utils/common/gravatar";
|
import { getGravatarFromUser } from "@/utils/common/gravatar";
|
||||||
import { formatDisplayName, getFallbackAvatarFromUsername } from "@/utils/common/username";
|
import { formatDisplayName, getFallbackAvatarFromUsername } from "@/utils/common/username";
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Role } from "@/models/user";
|
||||||
|
|
||||||
export function AvatarWithDropdownMenu() {
|
export function AvatarWithDropdownMenu() {
|
||||||
|
const routeT = useTranslations("Route");
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const toLogin = useToLogin();
|
const toLogin = useToLogin();
|
||||||
|
|
||||||
@ -26,7 +29,6 @@ export function AvatarWithDropdownMenu() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button className="rounded-full overflow-hidden">
|
<button className="rounded-full overflow-hidden">
|
||||||
{user ? <Avatar className="h-8 w-8 rounded-full">
|
{user ? <Avatar className="h-8 w-8 rounded-full">
|
||||||
@ -55,10 +57,10 @@ export function AvatarWithDropdownMenu() {
|
|||||||
<>
|
<>
|
||||||
<DropdownMenuGroup className="p-0">
|
<DropdownMenuGroup className="p-0">
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href={`/u/${user?.username}`}>Profile</Link>
|
<Link href={`/u/${user?.username}`}>{routeT("profile")}</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href="/console">Console</Link>
|
<Link href={user.role === Role.ADMIN ? consolePath.dashboard : consolePath.userProfile}>{routeT("console")}</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
@ -13,13 +13,13 @@ import {
|
|||||||
navigationMenuTriggerStyle,
|
navigationMenuTriggerStyle,
|
||||||
} from "@/components/ui/navigation-menu"
|
} from "@/components/ui/navigation-menu"
|
||||||
import { useDevice } from "@/contexts/device-context"
|
import { useDevice } from "@/contexts/device-context"
|
||||||
import config from "@/config"
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
|
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
|
||||||
import { Menu } from "lucide-react"
|
import { Menu } from "lucide-react"
|
||||||
import { ThemeModeToggle } from "@/components/common/theme-toggle"
|
import { ThemeModeToggle } from "@/components/common/theme-toggle"
|
||||||
import { AvatarWithDropdownMenu } from "@/components/layout/nav/avatar-with-dropdown-menu"
|
import { AvatarWithDropdownMenu } from "@/components/layout/nav/avatar-with-dropdown-menu"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useSiteInfo } from "@/contexts/site-info-context"
|
||||||
|
|
||||||
const navbarMenuComponents = [
|
const navbarMenuComponents = [
|
||||||
{
|
{
|
||||||
@ -46,11 +46,12 @@ const navbarMenuComponents = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const { navbarAdditionalClassName} = useDevice()
|
const { navbarAdditionalClassName } = useDevice()
|
||||||
|
const { siteInfo } = useSiteInfo();
|
||||||
return (
|
return (
|
||||||
<nav className={`grid grid-cols-[1fr_auto_1fr] items-center gap-4 h-full px-4 w-full ${navbarAdditionalClassName}`}>
|
<nav className={`grid grid-cols-[1fr_auto_1fr] items-center gap-4 h-full px-4 w-full ${navbarAdditionalClassName}`}>
|
||||||
<div className="flex items-center justify-start">
|
<div className="flex items-center justify-start">
|
||||||
<span className="font-bold truncate"><Link href="/">{config.metadata.name}</Link></span>
|
<span className="font-bold truncate"><Link href="/">{siteInfo.metadata.name}</Link></span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<NavMenuCenter />
|
<NavMenuCenter />
|
||||||
|
@ -1,27 +0,0 @@
|
|||||||
const config = {
|
|
||||||
metadata: {
|
|
||||||
name: "Snowykami's Blog",
|
|
||||||
icon: "https://cdn.liteyuki.org/snowykami/avatar.jpg",
|
|
||||||
description: "分享一些好玩的东西"
|
|
||||||
},
|
|
||||||
defaultCover: "https://cdn.liteyuki.org/blog/background.png",
|
|
||||||
owner: {
|
|
||||||
name: "Snowykami",
|
|
||||||
description: "全栈开发工程师,喜欢分享技术心得和生活感悟。",
|
|
||||||
motto: "And now that story unfolds into a journey that, alone, I set out to",
|
|
||||||
avatar: "https://cdn.liteyuki.org/snowykami/avatar.jpg",
|
|
||||||
gravatarEmail: "snowykami@outlook.com"
|
|
||||||
},
|
|
||||||
bodyWidth: "80vw",
|
|
||||||
bodyWidthMobile: "100vw",
|
|
||||||
postsPerPage: 9,
|
|
||||||
commentsPerPage: 8,
|
|
||||||
verifyCodeCoolDown: 60,
|
|
||||||
animationDurationSecond: 0.618,
|
|
||||||
footer: {
|
|
||||||
text: "Liteyuki ICP备 1145141919810",
|
|
||||||
links: []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default config
|
|
@ -7,6 +7,7 @@ type Mode = "light" | "dark" | "system";
|
|||||||
interface DeviceContextProps {
|
interface DeviceContextProps {
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
mode: Mode;
|
mode: Mode;
|
||||||
|
isDark: boolean;
|
||||||
setMode: (mode: Mode) => void;
|
setMode: (mode: Mode) => void;
|
||||||
toggleMode: () => void;
|
toggleMode: () => void;
|
||||||
viewport: {
|
viewport: {
|
||||||
@ -20,6 +21,7 @@ interface DeviceContextProps {
|
|||||||
const DeviceContext = createContext<DeviceContextProps>({
|
const DeviceContext = createContext<DeviceContextProps>({
|
||||||
isMobile: false,
|
isMobile: false,
|
||||||
mode: "system",
|
mode: "system",
|
||||||
|
isDark: false,
|
||||||
setMode: () => {},
|
setMode: () => {},
|
||||||
toggleMode: () => {},
|
toggleMode: () => {},
|
||||||
viewport: {
|
viewport: {
|
||||||
@ -32,6 +34,7 @@ const DeviceContext = createContext<DeviceContextProps>({
|
|||||||
|
|
||||||
export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
const [isDark, setIsDark] = useState(false);
|
||||||
const [mode, setModeState] = useState<Mode>("system");
|
const [mode, setModeState] = useState<Mode>("system");
|
||||||
const [viewport, setViewport] = useState({
|
const [viewport, setViewport] = useState({
|
||||||
width: typeof window !== "undefined" ? window.innerWidth : 0,
|
width: typeof window !== "undefined" ? window.innerWidth : 0,
|
||||||
@ -48,6 +51,8 @@ export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
|||||||
// 应用主题到 document
|
// 应用主题到 document
|
||||||
const applyTheme = useCallback(
|
const applyTheme = useCallback(
|
||||||
(theme: Mode) => {
|
(theme: Mode) => {
|
||||||
|
const isDarkMode = theme === "dark" || (theme === "system" && getSystemTheme() === "dark");
|
||||||
|
setIsDark(isDarkMode);
|
||||||
let effectiveTheme = theme;
|
let effectiveTheme = theme;
|
||||||
if (theme === "system") {
|
if (theme === "system") {
|
||||||
effectiveTheme = getSystemTheme();
|
effectiveTheme = getSystemTheme();
|
||||||
@ -127,7 +132,7 @@ export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DeviceContext.Provider
|
<DeviceContext.Provider
|
||||||
value={{ isMobile, mode, setMode, toggleMode, viewport, navbarAdditionalClassName, setNavbarAdditionalClassName }}
|
value={{ isMobile, isDark, mode, setMode, toggleMode, viewport, navbarAdditionalClassName, setNavbarAdditionalClassName }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</DeviceContext.Provider>
|
</DeviceContext.Provider>
|
||||||
|
160
web/src/contexts/site-info-context.tsx
Normal file
160
web/src/contexts/site-info-context.tsx
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export type SiteInfo = {
|
||||||
|
colorSchemes: string[];
|
||||||
|
metadata: {
|
||||||
|
name?: string;
|
||||||
|
icon?: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
defaultCover: string;
|
||||||
|
owner: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
motto?: string;
|
||||||
|
avatar?: string;
|
||||||
|
gravatarEmail?: string;
|
||||||
|
};
|
||||||
|
postsPerPage: number;
|
||||||
|
commentsPerPage: number;
|
||||||
|
verifyCodeCoolDown: number;
|
||||||
|
animationDurationSecond: number;
|
||||||
|
footer: {
|
||||||
|
text?: string;
|
||||||
|
links?: {
|
||||||
|
text: string;
|
||||||
|
href: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 这里不写类型定义,是让编辑器根据实际内容推断类型
|
||||||
|
export const fallbackSiteInfo: SiteInfo = {
|
||||||
|
colorSchemes: ["blue", "green", "orange", "red", "rose", "violet", "yellow"],
|
||||||
|
metadata: {
|
||||||
|
name: "Failed to Fetch Name",
|
||||||
|
icon: "",
|
||||||
|
description: "Failed to fetch site info from server.",
|
||||||
|
},
|
||||||
|
defaultCover: "https://cdn.liteyuki.org/blog/background.png",
|
||||||
|
owner: {
|
||||||
|
name: "Site Owner",
|
||||||
|
description: "The owner of this site",
|
||||||
|
motto: "This is a default motto.",
|
||||||
|
avatar: "",
|
||||||
|
gravatarEmail: "",
|
||||||
|
},
|
||||||
|
postsPerPage: 10,
|
||||||
|
commentsPerPage: 10,
|
||||||
|
verifyCodeCoolDown: 60,
|
||||||
|
animationDurationSecond: 0.3,
|
||||||
|
footer: {
|
||||||
|
text: "Default footer text",
|
||||||
|
links: [
|
||||||
|
{ text: "Home", href: "/" },
|
||||||
|
{ text: "About", href: "/about" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type SiteInfoContextValue = {
|
||||||
|
siteInfo: SiteInfo;
|
||||||
|
setSiteInfo: (info: SiteInfo) => void;
|
||||||
|
isLoaded: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SiteInfoContext = createContext<SiteInfoContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 深度合并两个对象,用 fallback 填补 initial 中缺失的字段
|
||||||
|
*/
|
||||||
|
function mergeWithFallback<T extends Record<string, unknown>>(initial: T, fallback: T): T {
|
||||||
|
const result = { ...initial } as T;
|
||||||
|
|
||||||
|
for (const key in fallback) {
|
||||||
|
if (fallback.hasOwnProperty(key)) {
|
||||||
|
const initialValue = initial[key];
|
||||||
|
const fallbackValue = fallback[key];
|
||||||
|
|
||||||
|
if (initialValue === undefined || initialValue === null) {
|
||||||
|
// 如果 initial 中没有这个字段,直接使用 fallback
|
||||||
|
result[key] = fallbackValue;
|
||||||
|
} else if (
|
||||||
|
typeof initialValue === 'object' &&
|
||||||
|
!Array.isArray(initialValue) &&
|
||||||
|
typeof fallbackValue === 'object' &&
|
||||||
|
!Array.isArray(fallbackValue)
|
||||||
|
) {
|
||||||
|
// 如果都是对象(非数组),也不要递归合并
|
||||||
|
result[key] = { ...initialValue, ...fallbackValue };
|
||||||
|
}
|
||||||
|
// 否则保持 initial 的值不变
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SiteInfoProvider: React.FC<{ initialData?: SiteInfo; children: React.ReactNode }> = ({ initialData, children }) => {
|
||||||
|
// 合并初始数据和 fallback,确保所有字段都有值
|
||||||
|
const mergedInitialData = initialData ? mergeWithFallback(initialData, fallbackSiteInfo) : fallbackSiteInfo;
|
||||||
|
|
||||||
|
const [siteInfo, setSiteInfo] = useState<SiteInfo>(mergedInitialData);
|
||||||
|
const [isLoaded, setIsLoaded] = useState<boolean>(Boolean(initialData));
|
||||||
|
|
||||||
|
// If initialData is not provided (rare), you can fetch on client as fallback.
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialData) return;
|
||||||
|
let mounted = true;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/siteinfo');
|
||||||
|
if (!mounted) return;
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
// 同样对客户端获取的数据进行合并
|
||||||
|
const mergedData = mergeWithFallback(data, fallbackSiteInfo);
|
||||||
|
setSiteInfo(mergedData);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// swallow — siteInfo stays with fallback
|
||||||
|
console.error('fetch siteinfo failed', e);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setIsLoaded(true);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { mounted = false };
|
||||||
|
}, [initialData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialData) {
|
||||||
|
const mergedData = mergeWithFallback(initialData, fallbackSiteInfo);
|
||||||
|
setSiteInfo(mergedData);
|
||||||
|
setIsLoaded(true);
|
||||||
|
}
|
||||||
|
}, [initialData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SiteInfoContext.Provider value={{
|
||||||
|
siteInfo,
|
||||||
|
setSiteInfo: (info: SiteInfo) => {
|
||||||
|
// 当手动设置 siteInfo 时也进行合并
|
||||||
|
const mergedInfo = mergeWithFallback(info, fallbackSiteInfo);
|
||||||
|
setSiteInfo(mergedInfo);
|
||||||
|
},
|
||||||
|
isLoaded
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</SiteInfoContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useSiteInfo() {
|
||||||
|
const ctx = useContext(SiteInfoContext);
|
||||||
|
if (!ctx) throw new Error('useSiteInfo must be used within SiteInfoProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SiteInfoContext;
|
@ -134,7 +134,8 @@
|
|||||||
"verify_code": "验证码"
|
"verify_code": "验证码"
|
||||||
},
|
},
|
||||||
"user_preference": {
|
"user_preference": {
|
||||||
"title": "个性化"
|
"title": "个性化",
|
||||||
|
"color_scheme": "颜色风格"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Login": {
|
"Login": {
|
||||||
@ -192,6 +193,7 @@
|
|||||||
"updated_at": "更新时间",
|
"updated_at": "更新时间",
|
||||||
"view_count": "浏览数"
|
"view_count": "浏览数"
|
||||||
},
|
},
|
||||||
|
|
||||||
"Register": {
|
"Register": {
|
||||||
"title": "注册",
|
"title": "注册",
|
||||||
"already_have_account": "已经有账号?",
|
"already_have_account": "已经有账号?",
|
||||||
@ -214,7 +216,8 @@
|
|||||||
"verify_code": "验证码"
|
"verify_code": "验证码"
|
||||||
},
|
},
|
||||||
"Route": {
|
"Route": {
|
||||||
"profile": "个人资料"
|
"profile": "个人资料",
|
||||||
|
"console": "控制台"
|
||||||
},
|
},
|
||||||
"State": {
|
"State": {
|
||||||
"private": "私密",
|
"private": "私密",
|
||||||
|
@ -4,6 +4,7 @@ export interface User {
|
|||||||
nickname?: string;
|
nickname?: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
backgroundUrl?: string;
|
backgroundUrl?: string;
|
||||||
|
preferredColor?: string;
|
||||||
email: string;
|
email: string;
|
||||||
gender?: string;
|
gender?: string;
|
||||||
role: string;
|
role: string;
|
||||||
|
Reference in New Issue
Block a user