mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-26 11:06:23 +00:00
Refactor site configuration and color scheme management
- Replaced static config with dynamic site info context. - Updated color scheme handling in various components to use site info. - Removed deprecated config file and integrated site info fetching. - Enhanced user preference page to allow color scheme selection. - Adjusted blog and console components to reflect new site info structure. - Improved error handling and fallback mechanisms for site info retrieval.
This commit is contained in:
@ -1 +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
|
||||
|
||||
type UserDto struct {
|
||||
ID uint `json:"id"` // 用户ID
|
||||
Username string `json:"username"` // 用户名
|
||||
Nickname string `json:"nickname"`
|
||||
AvatarUrl string `json:"avatar_url"` // 头像URL
|
||||
BackgroundUrl string `json:"background_url"`
|
||||
Email string `json:"email"` // 邮箱
|
||||
Gender string `json:"gender"`
|
||||
Role string `json:"role"`
|
||||
Language string `json:"language"` // 语言
|
||||
ID uint `json:"id"` // 用户ID
|
||||
Username string `json:"username"` // 用户名
|
||||
Nickname string `json:"nickname"`
|
||||
AvatarUrl string `json:"avatar_url"` // 头像URL
|
||||
BackgroundUrl string `json:"background_url"`
|
||||
PreferredColor string `json:"preferred_color"` // 主题色
|
||||
Email string `json:"email"` // 邮箱
|
||||
Gender string `json:"gender"`
|
||||
Role string `json:"role"`
|
||||
Language string `json:"language"` // 语言
|
||||
}
|
||||
|
||||
type UserOidcConfigDto struct {
|
||||
Name string `json:"name"` // OIDC配置名称
|
||||
DisplayName string `json:"display_name"` // OIDC配置显示名称
|
||||
Icon string `json:"icon"` // OIDC配置图标URL
|
||||
LoginUrl string `json:"login_url"` // OIDC登录URL
|
||||
Name string `json:"name"` // OIDC配置名称
|
||||
DisplayName string `json:"display_name"` // OIDC配置显示名称
|
||||
Icon string `json:"icon"` // OIDC配置图标URL
|
||||
LoginUrl string `json:"login_url"` // OIDC登录URL
|
||||
}
|
||||
type UserLoginReq struct {
|
||||
Username string `json:"username"` // username or email
|
||||
Password string `json:"password"`
|
||||
Username string `json:"username"` // username or email
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type UserLoginResp struct {
|
||||
Token string `json:"token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
User UserDto `json:"user"`
|
||||
Token string `json:"token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
User UserDto `json:"user"`
|
||||
}
|
||||
|
||||
type UserRegisterReq struct {
|
||||
Username string `json:"username"` // 用户名
|
||||
Nickname string `json:"nickname"` // 昵称
|
||||
Password string `json:"password"` // 密码
|
||||
Email string `json:"-" binding:"-"`
|
||||
Username string `json:"username"` // 用户名
|
||||
Nickname string `json:"nickname"` // 昵称
|
||||
Password string `json:"password"` // 密码
|
||||
Email string `json:"-" binding:"-"`
|
||||
}
|
||||
|
||||
type UserRegisterResp struct {
|
||||
Token string `json:"token"` // 访问令牌
|
||||
RefreshToken string `json:"refresh_token"` // 刷新令牌
|
||||
User UserDto `json:"user"` // 用户信息
|
||||
Token string `json:"token"` // 访问令牌
|
||||
RefreshToken string `json:"refresh_token"` // 刷新令牌
|
||||
User UserDto `json:"user"` // 用户信息
|
||||
}
|
||||
|
||||
type VerifyEmailReq struct {
|
||||
Email string `json:"email"` // 邮箱地址
|
||||
Email string `json:"email"` // 邮箱地址
|
||||
}
|
||||
|
||||
type VerifyEmailResp struct {
|
||||
Success bool `json:"success"` // 验证码发送成功与否
|
||||
Success bool `json:"success"` // 验证码发送成功与否
|
||||
}
|
||||
|
||||
type OidcLoginReq struct {
|
||||
Name string `json:"name"` // OIDC配置名称
|
||||
Code string `json:"code"` // OIDC授权码
|
||||
State string `json:"state"`
|
||||
Name string `json:"name"` // OIDC配置名称
|
||||
Code string `json:"code"` // OIDC授权码
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
type OidcLoginResp struct {
|
||||
Token string `json:"token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
User UserDto `json:"user"`
|
||||
Token string `json:"token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
User UserDto `json:"user"`
|
||||
}
|
||||
|
||||
type ListOidcConfigResp struct {
|
||||
OidcConfigs []UserOidcConfigDto `json:"oidc_configs"` // OIDC配置列表
|
||||
OidcConfigs []UserOidcConfigDto `json:"oidc_configs"` // OIDC配置列表
|
||||
}
|
||||
|
||||
type GetUserReq struct {
|
||||
UserID uint `json:"user_id"`
|
||||
UserID uint `json:"user_id"`
|
||||
}
|
||||
|
||||
type GetUserByUsernameReq struct {
|
||||
Username string `json:"username"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type GetUserResp struct {
|
||||
User UserDto `json:"user"` // 用户信息
|
||||
User UserDto `json:"user"` // 用户信息
|
||||
}
|
||||
|
||||
type UpdateUserReq struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
AvatarUrl string `json:"avatar_url"`
|
||||
BackgroundUrl string `json:"background_url"`
|
||||
Gender string `json:"gender"`
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
AvatarUrl string `json:"avatar_url"`
|
||||
BackgroundUrl string `json:"background_url"`
|
||||
PreferredColor string `json:"preferred_color"`
|
||||
Gender string `json:"gender"`
|
||||
}
|
||||
|
||||
type UpdateUserResp struct {
|
||||
User *UserDto `json:"user"` // 更新后的用户信息
|
||||
User *UserDto `json:"user"` // 更新后的用户信息
|
||||
}
|
||||
|
||||
type UpdatePasswordReq struct {
|
||||
OldPassword string `json:"old_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
OldPassword string `json:"old_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
type ResetPasswordReq struct {
|
||||
Email string `json:"-" binding:"-"`
|
||||
NewPassword string `json:"new_password"`
|
||||
Email string `json:"-" binding:"-"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
@ -1 +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
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
package main
|
||||
|
||||
func main() {
|
||||
package model
|
||||
|
||||
type KV struct {
|
||||
Key string `gorm:"primaryKey;type:varchar(64);not null;comment:键"`
|
||||
Value JSONMap
|
||||
}
|
||||
|
@ -1,41 +1,43 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/snowykami/neo-blog/internal/dto"
|
||||
"gorm.io/gorm"
|
||||
"github.com/snowykami/neo-blog/internal/dto"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
gorm.Model
|
||||
Username string `gorm:"uniqueIndex;not null"` // 用户名,唯一
|
||||
Nickname string `gorm:"default:''"` // 昵称
|
||||
AvatarUrl string
|
||||
BackgroundUrl string
|
||||
Email string `gorm:"uniqueIndex"`
|
||||
Gender string `gorm:"default:''"`
|
||||
Role string `gorm:"default:'user'"` // user editor admin
|
||||
Language string `gorm:"default:'en'"`
|
||||
Password string // 密码,存储加密后的值
|
||||
gorm.Model
|
||||
Username string `gorm:"uniqueIndex;not null"` // 用户名,唯一
|
||||
Nickname string `gorm:"default:''"` // 昵称
|
||||
AvatarUrl string
|
||||
BackgroundUrl string
|
||||
PreferredColor string `gorm:"default:'global'"` // 主题色
|
||||
Email string `gorm:"uniqueIndex"`
|
||||
Gender string `gorm:"default:''"`
|
||||
Role string `gorm:"default:'user'"` // user editor admin
|
||||
Language string `gorm:"default:'en'"`
|
||||
Password string // 密码,存储加密后的值
|
||||
}
|
||||
|
||||
type UserOpenID struct {
|
||||
gorm.Model
|
||||
UserID uint `gorm:"index"`
|
||||
User User `gorm:"foreignKey:UserID;references:ID"`
|
||||
Issuer string `gorm:"index"` // OIDC Issuer
|
||||
Sub string `gorm:"index"` // OIDC Sub openid
|
||||
gorm.Model
|
||||
UserID uint `gorm:"index"`
|
||||
User User `gorm:"foreignKey:UserID;references:ID"`
|
||||
Issuer string `gorm:"index"` // OIDC Issuer
|
||||
Sub string `gorm:"index"` // OIDC Sub openid
|
||||
}
|
||||
|
||||
func (user *User) ToDto() dto.UserDto {
|
||||
return dto.UserDto{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
AvatarUrl: user.AvatarUrl,
|
||||
BackgroundUrl: user.BackgroundUrl,
|
||||
Email: user.Email,
|
||||
Gender: user.Gender,
|
||||
Role: user.Role,
|
||||
Language: user.Language,
|
||||
}
|
||||
return dto.UserDto{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
AvatarUrl: user.AvatarUrl,
|
||||
BackgroundUrl: user.BackgroundUrl,
|
||||
PreferredColor: user.PreferredColor,
|
||||
Email: user.Email,
|
||||
Gender: user.Gender,
|
||||
Role: user.Role,
|
||||
Language: user.Language,
|
||||
}
|
||||
}
|
||||
|
@ -129,6 +129,7 @@ func migrate() error {
|
||||
&model.Label{},
|
||||
&model.Like{},
|
||||
&model.File{},
|
||||
&model.KV{},
|
||||
&model.OidcConfig{},
|
||||
&model.Post{},
|
||||
&model.Session{},
|
||||
|
@ -1,5 +1,50 @@
|
||||
package main
|
||||
package repo
|
||||
|
||||
func main() {
|
||||
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
|
||||
}
|
||||
|
@ -1 +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 +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"
|
||||
|
||||
func RegisterRoutes(h *server.Hertz) {
|
||||
apiV1Group := h.Group("/api/v1")
|
||||
{
|
||||
registerCommentRoutes(apiV1Group)
|
||||
registerAdminRoutes(apiV1Group)
|
||||
registerFileRoutes(apiV1Group)
|
||||
registerLabelRoutes(apiV1Group)
|
||||
registerLikeRoutes(apiV1Group)
|
||||
registerPageRoutes(apiV1Group)
|
||||
registerPostRoutes(apiV1Group)
|
||||
registerUserRoutes(apiV1Group)
|
||||
}
|
||||
apiV1Group := h.Group("/api/v1")
|
||||
{
|
||||
registerCommentRoutes(apiV1Group)
|
||||
registerAdminRoutes(apiV1Group)
|
||||
registerFileRoutes(apiV1Group)
|
||||
registerLabelRoutes(apiV1Group)
|
||||
registerLikeRoutes(apiV1Group)
|
||||
registerMiscRoutes(apiV1Group)
|
||||
registerPageRoutes(apiV1Group)
|
||||
registerPostRoutes(apiV1Group)
|
||||
registerUserRoutes(apiV1Group)
|
||||
}
|
||||
}
|
||||
|
@ -355,10 +355,12 @@ func (s *UserService) UpdateUser(req *dto.UpdateUserReq) (*dto.UpdateUserResp, e
|
||||
Model: gorm.Model{
|
||||
ID: req.ID,
|
||||
},
|
||||
Username: req.Username,
|
||||
Nickname: req.Nickname,
|
||||
Gender: req.Gender,
|
||||
AvatarUrl: req.AvatarUrl,
|
||||
Username: req.Username,
|
||||
Nickname: req.Nickname,
|
||||
Gender: req.Gender,
|
||||
AvatarUrl: req.AvatarUrl,
|
||||
BackgroundUrl: req.BackgroundUrl,
|
||||
PreferredColor: req.PreferredColor,
|
||||
}
|
||||
err := repo.User.UpdateUser(user)
|
||||
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,
|
||||
captcha?: string,
|
||||
}): Promise<BaseResponse<{ token: string, user: User }>> {
|
||||
console.log("Logging in with captcha:", captcha)
|
||||
const res = await axiosClient.post<BaseResponse<{ token: string, user: User }>>(
|
||||
'/user/login',
|
||||
{ username, password, rememberMe },
|
||||
|
@ -4,19 +4,22 @@ import { motion } from 'motion/react'
|
||||
import { Navbar } from '@/components/layout/nav/navbar-or-side'
|
||||
import { BackgroundProvider } from '@/contexts/background-context'
|
||||
import Footer from '@/components/layout/footer'
|
||||
import config from '@/config'
|
||||
import { useSiteInfo } from '@/contexts/site-info-context'
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
|
||||
const { siteInfo } = useSiteInfo();
|
||||
if (!siteInfo) return null;
|
||||
return (
|
||||
<>
|
||||
<motion.nav
|
||||
initial={{ y: -64, opacity: 0 }}
|
||||
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">
|
||||
<Navbar />
|
||||
</header>
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { UserPreferencePage } from "@/components/console/user-preference";
|
||||
|
||||
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 { NextIntlClientProvider } from 'next-intl';
|
||||
import { AuthProvider } from "@/contexts/auth-context";
|
||||
import config from "@/config";
|
||||
import { getFirstLocale } from '@/i18n/request';
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { getLoginUser } from "@/api/user";
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app'
|
||||
import "./globals.css";
|
||||
|
||||
import { fallbackSiteInfo, SiteInfoProvider } from "@/contexts/site-info-context";
|
||||
import { getSiteInfo } from "@/api/misc";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@ -22,23 +22,58 @@ const geistMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: config.metadata.name,
|
||||
description: config.metadata.description,
|
||||
};
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const siteInfo = await getSiteInfo().then(res => res.data).catch(() => fallbackSiteInfo);
|
||||
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({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
|
||||
|
||||
const token = (await cookies()).get("token")?.value || "";
|
||||
const refreshToken = (await cookies()).get("refresh_token")?.value || "";
|
||||
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 (
|
||||
<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
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
@ -47,7 +82,9 @@ export default async function RootLayout({
|
||||
<DeviceProvider>
|
||||
<NextIntlClientProvider>
|
||||
<AuthProvider initialUser={user}>
|
||||
{children}
|
||||
<SiteInfoProvider initialData={siteInfo!}>
|
||||
{children}
|
||||
</SiteInfoProvider>
|
||||
</AuthProvider>
|
||||
</NextIntlClientProvider>
|
||||
</DeviceProvider>
|
||||
|
@ -1,4 +1,4 @@
|
||||
:root[user-color="blue"] {
|
||||
[data-user-color="blue"] {
|
||||
--radius: 0.65rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
@ -33,7 +33,7 @@
|
||||
--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);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
|
@ -1,4 +1,4 @@
|
||||
:root[user-color="green"] {
|
||||
[data-user-color="green"] {
|
||||
--radius: 0.65rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
@ -33,7 +33,7 @@
|
||||
--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);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
|
@ -1,4 +1,4 @@
|
||||
:root[user-color="orange"] {
|
||||
[data-user-color="orange"] {
|
||||
--radius: 0.65rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
@ -33,7 +33,7 @@
|
||||
--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);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
|
@ -1,4 +1,4 @@
|
||||
:root[user-color="red"] {
|
||||
[data-user-color="red"] {
|
||||
--radius: 0.65rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
@ -33,7 +33,7 @@
|
||||
--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);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
|
@ -1,4 +1,4 @@
|
||||
:root[user-color="rose"] {
|
||||
[data-user-color="rose"] {
|
||||
--radius: 0.65rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
@ -33,7 +33,7 @@
|
||||
--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);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
|
@ -1,4 +1,4 @@
|
||||
:root[user-color="violet"] {
|
||||
[data-user-color="violet"] {
|
||||
--radius: 0.65rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
@ -33,7 +33,7 @@
|
||||
--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);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
|
@ -1,4 +1,4 @@
|
||||
:root[user-color="violet"] {
|
||||
[data-user-color="yellow"] {
|
||||
--radius: 0.65rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
@ -33,7 +33,7 @@
|
||||
--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);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--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 Link from "next/link";
|
||||
|
||||
export function AuthHeader() {
|
||||
const { siteInfo } = useSiteInfo();
|
||||
|
||||
if (!siteInfo) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-2 self-center font-bold text-2xl">
|
||||
<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">
|
||||
<Image
|
||||
src={config.metadata.icon}
|
||||
src={siteInfo.metadata?.icon || ''}
|
||||
alt="Logo"
|
||||
width={40}
|
||||
height={40}
|
||||
className="rounded-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span className="font-bold text-2xl">{config.metadata.name}</span>
|
||||
<span className="font-bold text-2xl">{siteInfo.metadata?.name || ''}</span>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
|
@ -103,7 +103,7 @@ export function LoginForm({
|
||||
<CardContent>
|
||||
{user && <CurrentLogged />}
|
||||
<SectionDivider className="mb-6">{t("with_oidc")}</SectionDivider>
|
||||
<form>
|
||||
<form onSubmit={handleLogin}>
|
||||
<div className="grid gap-4">
|
||||
{/* OIDC 登录选项 */}
|
||||
{oidcConfigs.length > 0 && (
|
||||
@ -168,9 +168,8 @@ export function LoginForm({
|
||||
</div>
|
||||
}
|
||||
<Button
|
||||
type="button"
|
||||
type="submit"
|
||||
className="w-full"
|
||||
onClick={handleLogin}
|
||||
disabled={!captchaToken || isLogging}
|
||||
>
|
||||
{isLogging ? t("logging") : t("login")}
|
||||
@ -215,6 +214,7 @@ function LoginWithOidc({
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={handleOidcLogin}
|
||||
|
@ -4,18 +4,19 @@ import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import config from '@/config'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getPostHref } from '@/utils/common/post'
|
||||
import { motion } from 'motion/react'
|
||||
import { deceleration } from '@/motion/curve'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useSiteInfo } from '@/contexts/site-info-context'
|
||||
|
||||
|
||||
export function BlogCard({ post, className }: {
|
||||
post: Post
|
||||
className?: string
|
||||
}) {
|
||||
const {siteInfo} = useSiteInfo();
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
@ -39,14 +40,14 @@ export function BlogCard({ post, className }: {
|
||||
initial={{ scale: 1.2, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{
|
||||
duration: config.animationDurationSecond,
|
||||
duration: siteInfo.animationDurationSecond,
|
||||
ease: deceleration,
|
||||
}}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
>
|
||||
{(post.cover || config.defaultCover) ? (
|
||||
{(post.cover || siteInfo.defaultCover) ? (
|
||||
<Image
|
||||
src={post.cover || config.defaultCover}
|
||||
src={post.cover || siteInfo.defaultCover || "https://cdn.liteyuki.org/blog/background.png"}
|
||||
alt={post.title}
|
||||
fill
|
||||
className="object-cover w-full h-full transition-transform duration-300 group-hover:scale-105"
|
||||
@ -226,12 +227,13 @@ export function BlogCardGrid({
|
||||
isLoading?: boolean
|
||||
showPrivate?: boolean
|
||||
}) {
|
||||
const {siteInfo} = useSiteInfo();
|
||||
const filteredPosts = showPrivate ? posts : posts.filter(post => !post.isPrivate)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<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} />
|
||||
))}
|
||||
</div>
|
||||
|
@ -4,7 +4,6 @@ import { BlogCardGrid } from "@/components/blog-home/blog-home-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TrendingUp, Clock, } from "lucide-react";
|
||||
import Sidebar, { SidebarAbout, SidebarHotPosts, SidebarMisskeyIframe, SidebarTags } from "../blog/blog-sidebar-card";
|
||||
import config from '@/config';
|
||||
import type { Post } from "@/models/post";
|
||||
import { listPosts } from "@/api/post";
|
||||
|
||||
@ -17,6 +16,7 @@ import { PaginationController } from "@/components/common/pagination";
|
||||
import { QueryKey } from "@/constant";
|
||||
import { useStoredState } from "@/hooks/use-storage-state";
|
||||
import { parseAsInteger, useQueryState } from "nuqs";
|
||||
import { useSiteInfo } from "@/contexts/site-info-context";
|
||||
|
||||
// 定义排序类型
|
||||
enum SortBy {
|
||||
@ -29,6 +29,7 @@ const DEFAULT_SORTBY: SortBy = SortBy.Latest;
|
||||
export default function BlogHome() {
|
||||
// 从路由查询参数中获取页码和标签们
|
||||
const t = useTranslations("BlogHome");
|
||||
const {siteInfo} = useSiteInfo();
|
||||
const [labels, setLabels] = useState<string[]>([]);
|
||||
const [keywords, setKeywords] = useState<string[]>([]);
|
||||
const [page, setPage] = useQueryState("page", parseAsInteger.withDefault(1).withOptions({ history: "replace", clearOnDefault: true }));
|
||||
@ -43,7 +44,7 @@ export default function BlogHome() {
|
||||
listPosts(
|
||||
{
|
||||
page,
|
||||
size: config.postsPerPage,
|
||||
size: siteInfo.postsPerPage || 9,
|
||||
orderBy: sortBy === SortBy.Latest ? OrderBy.CreatedAt : OrderBy.Heat,
|
||||
desc: true,
|
||||
keywords: keywords.join(",") || undefined,
|
||||
@ -83,7 +84,7 @@ export default function BlogHome() {
|
||||
className="lg:col-span-3 self-start"
|
||||
initial={{ y: 40, opacity: 0 }}
|
||||
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">
|
||||
<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">
|
||||
{totalPosts > 0 && <PaginationController
|
||||
className="pt-4 flex justify-center"
|
||||
pageSize={config.postsPerPage}
|
||||
pageSize={siteInfo.postsPerPage}
|
||||
initialPage={page}
|
||||
total={totalPosts}
|
||||
onPageChange={handlePageChange}
|
||||
@ -144,11 +145,11 @@ export default function BlogHome() {
|
||||
<motion.div
|
||||
initial={{ x: 80, opacity: 0 }}
|
||||
animate={{ x: 0, y: 0, opacity: 1 }}
|
||||
transition={{ duration: config.animationDurationSecond, ease: "easeOut" }}
|
||||
transition={{ duration: siteInfo.animationDurationSecond, ease: "easeOut" }}
|
||||
>
|
||||
<Sidebar
|
||||
cards={[
|
||||
<SidebarAbout key="about" config={config} />,
|
||||
<SidebarAbout key="about" />,
|
||||
posts.length > 0 ? <SidebarHotPosts key="hot" posts={posts} sortType={sortBy} /> : null,
|
||||
<SidebarTags key="tags" labels={[]} />,
|
||||
<SidebarMisskeyIframe key="misskey" />,
|
||||
|
@ -7,7 +7,8 @@ import { calculateReadingTime } from "@/utils/common/post";
|
||||
import { CommentSection } from "@/components/comment";
|
||||
import { TargetType } from '@/models/types';
|
||||
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 }) {
|
||||
return (
|
||||
@ -141,6 +142,7 @@ async function PostContent({ post }: { post: Post }) {
|
||||
|
||||
|
||||
async function BlogPost({ post }: { post: Post}) {
|
||||
const siteInfo = await getSiteInfo().then(res => res.data).catch(() => fallbackSiteInfo);
|
||||
return (
|
||||
<div className="h-full"
|
||||
>
|
||||
@ -148,14 +150,14 @@ async function BlogPost({ post }: { post: Post}) {
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: config.animationDurationSecond, ease: "easeOut" }}>
|
||||
transition={{ duration: siteInfo.animationDurationSecond, ease: "easeOut" }}>
|
||||
<PostHeader post={post} />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: config.animationDurationSecond, ease: "easeOut" }}>
|
||||
transition={{ duration: siteInfo.animationDurationSecond, ease: "easeOut" }}>
|
||||
<PostContent post={post} />
|
||||
</motion.div>
|
||||
|
||||
|
@ -3,7 +3,6 @@ import { Heart, TrendingUp, Eye } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { Label } from "@/models/label";
|
||||
import type { Post } from "@/models/post";
|
||||
import type configType from '@/config';
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
@ -11,6 +10,7 @@ import { getPostHref } from "@/utils/common/post";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { getGravatarUrl } from "@/utils/common/gravatar";
|
||||
import { getFallbackAvatarFromUsername } from "@/utils/common/username";
|
||||
import { useSiteInfo } from "@/contexts/site-info-context";
|
||||
|
||||
// 侧边栏父组件,接收卡片组件列表
|
||||
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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@ -37,15 +39,15 @@ export function SidebarAbout({ config }: { config: typeof configType }) {
|
||||
<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">
|
||||
<Avatar className="h-full w-full rounded-full">
|
||||
<AvatarImage src={getGravatarUrl({email: config.owner.gravatarEmail, size: 256})} alt={config.owner.name} />
|
||||
<AvatarFallback className="rounded-full">{getFallbackAvatarFromUsername(config.owner.name)}</AvatarFallback>
|
||||
<AvatarImage src={getGravatarUrl({email: siteInfo?.owner?.gravatarEmail || "snowykami@outlook.com", size: 256})} alt={siteInfo?.owner?.name} />
|
||||
<AvatarFallback className="rounded-full">{getFallbackAvatarFromUsername(siteInfo?.owner?.name || "Failed")}</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg">{config.owner.name}</h3>
|
||||
<p className="text-sm text-slate-600">{config.owner.motto}</p>
|
||||
<h3 className="font-semibold text-lg">{siteInfo?.owner?.name || "Failed H3"}</h3>
|
||||
<p className="text-sm text-slate-600">{siteInfo?.owner?.motto || "Failed Motto"}</p>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 leading-relaxed">
|
||||
{config.owner.description}
|
||||
{siteInfo?.owner?.description || "Failed Description"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
@ -10,8 +10,8 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { CommentInput } from "./comment-input";
|
||||
import { CommentItem } from "./comment-item";
|
||||
import config from "@/config";
|
||||
import "./style.css";
|
||||
import { useSiteInfo } from "@/contexts/site-info-context";
|
||||
|
||||
|
||||
|
||||
@ -26,6 +26,7 @@ export function CommentSection(
|
||||
totalCount?: number
|
||||
}
|
||||
) {
|
||||
const {siteInfo} = useSiteInfo();
|
||||
const t = useTranslations('Comment')
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [activeInput, setActiveInput] = useState<{ id: number; type: 'reply' | 'edit' } | null>(null);
|
||||
@ -45,12 +46,12 @@ export function CommentSection(
|
||||
orderBy: OrderBy.CreatedAt,
|
||||
desc: true,
|
||||
page: 1,
|
||||
size: config.commentsPerPage,
|
||||
size: siteInfo.commentsPerPage || 8,
|
||||
commentId: 0
|
||||
}).then(response => {
|
||||
setComments(response.data.comments);
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
@ -97,10 +98,10 @@ export function CommentSection(
|
||||
orderBy: OrderBy.CreatedAt,
|
||||
desc: true,
|
||||
page: nextPage,
|
||||
size: config.commentsPerPage,
|
||||
size: siteInfo.commentsPerPage || 8,
|
||||
commentId: 0
|
||||
}).then(response => {
|
||||
if (response.data.comments.length < config.commentsPerPage) {
|
||||
if (response.data.comments.length < (siteInfo.commentsPerPage || 8)) {
|
||||
setNeedLoadMore(false);
|
||||
}
|
||||
setComments(prevComments => [...prevComments, ...response.data.comments]);
|
||||
|
@ -15,16 +15,17 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar"
|
||||
import config from "@/config"
|
||||
import Link from "next/link"
|
||||
import { sidebarData } from "./data"
|
||||
import { ThemeModeToggle } from "../common/theme-toggle"
|
||||
import { useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useSiteInfo } from "@/contexts/site-info-context"
|
||||
|
||||
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const {siteInfo} = useSiteInfo();
|
||||
const [activeId, setActiveId] = useState("dashboard")
|
||||
const consoleT = useTranslations("Console")
|
||||
return (
|
||||
@ -38,7 +39,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
>
|
||||
<Link href="/">
|
||||
<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>
|
||||
</SidebarMenuButton>
|
||||
</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
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="grid w-full max-w-sm items-center gap-4">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{t("public_profile")}
|
||||
</h1>
|
||||
<Separator className="my-2" />
|
||||
<div className="grid w-full max-w-sm items-center gap-4">
|
||||
<div className="grid gap-2">
|
||||
<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";
|
||||
|
||||
export default function Footer() {
|
||||
const { siteInfo } = useSiteInfo();
|
||||
if (!siteInfo) return null;
|
||||
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">
|
||||
© {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>
|
||||
);
|
||||
}
|
@ -9,14 +9,17 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import Link from "next/link";
|
||||
import { useToLogin } from "@/hooks/use-route";
|
||||
import { consolePath, useToLogin } from "@/hooks/use-route";
|
||||
import { CircleUser } from "lucide-react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { getGravatarFromUser } from "@/utils/common/gravatar";
|
||||
import { formatDisplayName, getFallbackAvatarFromUsername } from "@/utils/common/username";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Role } from "@/models/user";
|
||||
|
||||
export function AvatarWithDropdownMenu() {
|
||||
const routeT = useTranslations("Route");
|
||||
const { user, logout } = useAuth();
|
||||
const toLogin = useToLogin();
|
||||
|
||||
@ -26,7 +29,6 @@ export function AvatarWithDropdownMenu() {
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="rounded-full overflow-hidden">
|
||||
{user ? <Avatar className="h-8 w-8 rounded-full">
|
||||
@ -55,10 +57,10 @@ export function AvatarWithDropdownMenu() {
|
||||
<>
|
||||
<DropdownMenuGroup className="p-0">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/u/${user?.username}`}>Profile</Link>
|
||||
<Link href={`/u/${user?.username}`}>{routeT("profile")}</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/console">Console</Link>
|
||||
<Link href={user.role === Role.ADMIN ? consolePath.dashboard : consolePath.userProfile}>{routeT("console")}</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
|
@ -13,13 +13,13 @@ import {
|
||||
navigationMenuTriggerStyle,
|
||||
} from "@/components/ui/navigation-menu"
|
||||
import { useDevice } from "@/contexts/device-context"
|
||||
import config from "@/config"
|
||||
import { useState } from "react"
|
||||
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
|
||||
import { Menu } from "lucide-react"
|
||||
import { ThemeModeToggle } from "@/components/common/theme-toggle"
|
||||
import { AvatarWithDropdownMenu } from "@/components/layout/nav/avatar-with-dropdown-menu"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useSiteInfo } from "@/contexts/site-info-context"
|
||||
|
||||
const navbarMenuComponents = [
|
||||
{
|
||||
@ -46,11 +46,12 @@ const navbarMenuComponents = [
|
||||
]
|
||||
|
||||
export function Navbar() {
|
||||
const { navbarAdditionalClassName} = useDevice()
|
||||
const { navbarAdditionalClassName } = useDevice()
|
||||
const { siteInfo } = useSiteInfo();
|
||||
return (
|
||||
<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">
|
||||
<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 className="flex items-center justify-center">
|
||||
<NavMenuCenter />
|
||||
@ -173,7 +174,7 @@ function SidebarMenu() {
|
||||
<div className="flex items-center justify-center p-4 border-t border-border">
|
||||
<ThemeModeToggle showSegmented={true} />
|
||||
</div>
|
||||
|
||||
|
||||
</SheetContent>
|
||||
</Sheet></div>
|
||||
)
|
||||
|
@ -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 {
|
||||
isMobile: boolean;
|
||||
mode: Mode;
|
||||
isDark: boolean;
|
||||
setMode: (mode: Mode) => void;
|
||||
toggleMode: () => void;
|
||||
viewport: {
|
||||
@ -20,6 +21,7 @@ interface DeviceContextProps {
|
||||
const DeviceContext = createContext<DeviceContextProps>({
|
||||
isMobile: false,
|
||||
mode: "system",
|
||||
isDark: false,
|
||||
setMode: () => {},
|
||||
toggleMode: () => {},
|
||||
viewport: {
|
||||
@ -32,6 +34,7 @@ const DeviceContext = createContext<DeviceContextProps>({
|
||||
|
||||
export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
const [mode, setModeState] = useState<Mode>("system");
|
||||
const [viewport, setViewport] = useState({
|
||||
width: typeof window !== "undefined" ? window.innerWidth : 0,
|
||||
@ -48,6 +51,8 @@ export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
// 应用主题到 document
|
||||
const applyTheme = useCallback(
|
||||
(theme: Mode) => {
|
||||
const isDarkMode = theme === "dark" || (theme === "system" && getSystemTheme() === "dark");
|
||||
setIsDark(isDarkMode);
|
||||
let effectiveTheme = theme;
|
||||
if (theme === "system") {
|
||||
effectiveTheme = getSystemTheme();
|
||||
@ -127,7 +132,7 @@ export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
|
||||
return (
|
||||
<DeviceContext.Provider
|
||||
value={{ isMobile, mode, setMode, toggleMode, viewport, navbarAdditionalClassName, setNavbarAdditionalClassName }}
|
||||
value={{ isMobile, isDark, mode, setMode, toggleMode, viewport, navbarAdditionalClassName, setNavbarAdditionalClassName }}
|
||||
>
|
||||
{children}
|
||||
</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": "验证码"
|
||||
},
|
||||
"user_preference": {
|
||||
"title": "个性化"
|
||||
"title": "个性化",
|
||||
"color_scheme": "颜色风格"
|
||||
}
|
||||
},
|
||||
"Login": {
|
||||
@ -192,6 +193,7 @@
|
||||
"updated_at": "更新时间",
|
||||
"view_count": "浏览数"
|
||||
},
|
||||
|
||||
"Register": {
|
||||
"title": "注册",
|
||||
"already_have_account": "已经有账号?",
|
||||
@ -214,7 +216,8 @@
|
||||
"verify_code": "验证码"
|
||||
},
|
||||
"Route": {
|
||||
"profile": "个人资料"
|
||||
"profile": "个人资料",
|
||||
"console": "控制台"
|
||||
},
|
||||
"State": {
|
||||
"private": "私密",
|
||||
|
@ -4,6 +4,7 @@ export interface User {
|
||||
nickname?: string;
|
||||
avatarUrl?: string;
|
||||
backgroundUrl?: string;
|
||||
preferredColor?: string;
|
||||
email: string;
|
||||
gender?: string;
|
||||
role: string;
|
||||
|
Reference in New Issue
Block a user