Merge pull request #37 from snowykami/fix/29

Fix/29
This commit is contained in:
2025-09-26 00:26:23 +08:00
committed by GitHub
44 changed files with 779 additions and 223 deletions

View File

@ -1,7 +0,0 @@
package v1
type ConfigController struct{}
func NewConfigController() *ConfigController {
return &ConfigController{}
}

View 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)
}

View File

@ -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"`
}

84
internal/model/json.go Normal file
View 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
View File

@ -0,0 +1,6 @@
package model
type KV struct {
Key string `gorm:"primaryKey;type:varchar(64);not null;comment:键"`
Value JSONMap
}

View File

@ -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,
}
}

View File

@ -129,6 +129,7 @@ func migrate() error {
&model.Label{},
&model.Like{},
&model.File{},
&model.KV{},
&model.OidcConfig{},
&model.Post{},
&model.Session{},

50
internal/repo/kv.go Normal file
View 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
View 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")
}
}

View File

@ -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)
}
}

View 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)
}
}

View File

@ -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)
}
}

View File

@ -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
View 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;
}

View File

@ -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 },

View File

@ -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>

View File

@ -1,3 +1,5 @@
import { UserPreferencePage } from "@/components/console/user-preference";
export default function Page() {
return <div></div>
return <UserPreferencePage />
}

View File

@ -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>

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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>
)

View File

@ -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}

View File

@ -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>

View File

@ -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" />,

View File

@ -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>

View File

@ -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>

View File

@ -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]);

View File

@ -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>

View 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>
);
}

View 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>
)
}

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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 />

View File

@ -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>
)

View File

@ -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

View File

@ -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>

View 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;

View File

@ -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": "私密",

View File

@ -4,6 +4,7 @@ export interface User {
nickname?: string;
avatarUrl?: string;
backgroundUrl?: string;
preferredColor?: string;
email: string;
gender?: string;
role: string;