mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-26 02:56:22 +00:00
feat: add new color themes and styles for rose, violet, and yellow
- Introduced new CSS files for rose, violet, and yellow themes with custom color variables. - Implemented dark mode styles for each theme. - Created a color data structure to manage theme colors in the console settings. feat: implement image cropper component - Added an image cropper component for user profile picture editing. - Integrated the image cropper into the user profile page. feat: enhance console sidebar with user permissions - Defined sidebar items with permission checks for admin and editor roles. - Updated user center navigation to reflect user permissions. feat: add user profile and security settings - Developed user profile page with avatar upload and editing functionality. - Implemented user security settings for password and email verification. feat: create reusable dialog and OTP input components - Built a dialog component for modal interactions. - Developed an OTP input component for email verification. fix: improve file handling utilities - Added utility functions for file URI generation. - Implemented permission checks for user roles in the common utilities.
This commit is contained in:
1
go.mod
1
go.mod
@ -8,6 +8,7 @@ require (
|
|||||||
github.com/golang-jwt/jwt/v5 v5.2.3
|
github.com/golang-jwt/jwt/v5 v5.2.3
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/sirupsen/logrus v1.9.3
|
github.com/sirupsen/logrus v1.9.3
|
||||||
|
github.com/studio-b12/gowebdav v0.11.0
|
||||||
golang.org/x/crypto v0.31.0
|
golang.org/x/crypto v0.31.0
|
||||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
|
2
go.sum
2
go.sum
@ -82,6 +82,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
|
|||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/studio-b12/gowebdav v0.11.0 h1:qbQzq4USxY28ZYsGJUfO5jR+xkFtcnwWgitp4Zp1irU=
|
||||||
|
github.com/studio-b12/gowebdav v0.11.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
|
||||||
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
|
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
|
||||||
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||||
|
@ -1 +1,129 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/cloudwego/hertz/pkg/app"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/snowykami/neo-blog/internal/ctxutils"
|
||||||
|
"github.com/snowykami/neo-blog/internal/model"
|
||||||
|
"github.com/snowykami/neo-blog/internal/repo"
|
||||||
|
"github.com/snowykami/neo-blog/pkg/filedriver"
|
||||||
|
"github.com/snowykami/neo-blog/pkg/resps"
|
||||||
|
"github.com/snowykami/neo-blog/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FileController struct{}
|
||||||
|
|
||||||
|
func NewFileController() *FileController {
|
||||||
|
return &FileController{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FileController) UploadFileStream(ctx context.Context, c *app.RequestContext) {
|
||||||
|
// 获取文件信息
|
||||||
|
file, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
logrus.Error("无法读取文件: ", err)
|
||||||
|
resps.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
group := string(c.FormValue("group"))
|
||||||
|
name := string(c.FormValue("name"))
|
||||||
|
|
||||||
|
// 初始化文件驱动
|
||||||
|
driver, err := filedriver.GetFileDriver(filedriver.GetWebdavDriverConfig())
|
||||||
|
if err != nil {
|
||||||
|
logrus.Error("获取文件驱动失败: ", err)
|
||||||
|
resps.InternalServerError(c, "获取文件驱动失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验文件哈希
|
||||||
|
if hashForm := string(c.FormValue("hash")); hashForm != "" {
|
||||||
|
dir, fileName := utils.FilePath(hashForm)
|
||||||
|
storagePath := filepath.Join(dir, fileName)
|
||||||
|
if _, err := driver.Stat(c, storagePath); err == nil {
|
||||||
|
resps.Ok(c, "文件已存在", map[string]any{"hash": hashForm})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开文件
|
||||||
|
src, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
logrus.Error("无法打开文件: ", err)
|
||||||
|
resps.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
// 计算文件哈希值
|
||||||
|
hash, err := utils.FileHashFromStream(src)
|
||||||
|
if err != nil {
|
||||||
|
logrus.Error("计算文件哈希失败: ", err)
|
||||||
|
resps.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据哈希值生成存储路径
|
||||||
|
dir, fileName := utils.FilePath(hash)
|
||||||
|
storagePath := filepath.Join(dir, fileName)
|
||||||
|
// 保存文件
|
||||||
|
if _, err := src.Seek(0, io.SeekStart); err != nil {
|
||||||
|
logrus.Error("无法重置文件流位置: ", err)
|
||||||
|
resps.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := driver.Save(c, storagePath, src); err != nil {
|
||||||
|
logrus.Error("保存文件失败: ", err)
|
||||||
|
resps.InternalServerError(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 数据库索引建立
|
||||||
|
currentUser, ok := ctxutils.GetCurrentUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
resps.InternalServerError(c, "获取当前用户失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fileModel := &model.File{
|
||||||
|
Hash: hash,
|
||||||
|
UserID: currentUser.ID,
|
||||||
|
Group: group,
|
||||||
|
Name: name,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.File.Create(fileModel); err != nil {
|
||||||
|
logrus.Error("数据库索引建立失败: ", err)
|
||||||
|
resps.InternalServerError(c, "数据库索引建立失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resps.Ok(c, "文件上传成功", map[string]any{"hash": hash, "id": fileModel.ID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FileController) GetFile(ctx context.Context, c *app.RequestContext) {
|
||||||
|
fileIdString := c.Param("id")
|
||||||
|
fileId, err := strconv.ParseUint(fileIdString, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
logrus.Error("无效的文件ID: ", err)
|
||||||
|
resps.BadRequest(c, "无效的文件ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fileModel, err := repo.File.GetByID(uint(fileId))
|
||||||
|
if err != nil {
|
||||||
|
logrus.Error("获取文件信息失败: ", err)
|
||||||
|
resps.InternalServerError(c, "获取文件信息失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
driver, err := filedriver.GetFileDriver(filedriver.GetWebdavDriverConfig())
|
||||||
|
if err != nil {
|
||||||
|
logrus.Error("获取文件驱动失败: ", err)
|
||||||
|
resps.InternalServerError(c, "获取文件驱动失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filePath := filepath.Join(utils.FilePath(fileModel.Hash))
|
||||||
|
driver.Get(c, filePath)
|
||||||
|
}
|
||||||
|
@ -172,7 +172,7 @@ func (u *UserController) UpdateUser(ctx context.Context, c *app.RequestContext)
|
|||||||
resp, err := u.service.UpdateUser(&updateUserReq)
|
resp, err := u.service.UpdateUser(&updateUserReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serviceErr := errs.AsServiceError(err)
|
serviceErr := errs.AsServiceError(err)
|
||||||
resps.Custom(c, serviceErr.Code, serviceErr.Message, nil)
|
resps.Custom(c, serviceErr.Code, err.Error(), nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resps.Ok(c, resps.Success, resp)
|
resps.Ok(c, resps.Success, resp)
|
||||||
|
@ -1,32 +1,33 @@
|
|||||||
package ctxutils
|
package ctxutils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"github.com/snowykami/neo-blog/internal/model"
|
|
||||||
"github.com/snowykami/neo-blog/internal/repo"
|
"github.com/snowykami/neo-blog/internal/model"
|
||||||
"github.com/snowykami/neo-blog/pkg/constant"
|
"github.com/snowykami/neo-blog/internal/repo"
|
||||||
|
"github.com/snowykami/neo-blog/pkg/constant"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetCurrentUser 从上下文中获取当前用户
|
// GetCurrentUser 从上下文中获取当前用户
|
||||||
func GetCurrentUser(ctx context.Context) (*model.User, bool) {
|
func GetCurrentUser(ctx context.Context) (*model.User, bool) {
|
||||||
val := ctx.Value(constant.ContextKeyUserID)
|
val := ctx.Value(constant.ContextKeyUserID)
|
||||||
if val == nil {
|
if val == nil {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
user, err := repo.User.GetUserByID(val.(uint))
|
user, err := repo.User.GetUserByID(val.(uint))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
return user, true
|
return user, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCurrentUserID 从上下文中获取当前用户ID
|
// GetCurrentUserID 从上下文中获取当前用户ID
|
||||||
func GetCurrentUserID(ctx context.Context) (uint, bool) {
|
func GetCurrentUserID(ctx context.Context) (uint, bool) {
|
||||||
user, ok := GetCurrentUser(ctx)
|
user, ok := GetCurrentUser(ctx)
|
||||||
if !ok || user == nil {
|
if !ok || user == nil {
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|
||||||
return user.ID, true
|
return user.ID, true
|
||||||
}
|
}
|
||||||
|
@ -1 +1,12 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
|
import "gorm.io/gorm"
|
||||||
|
|
||||||
|
type File struct {
|
||||||
|
gorm.Model
|
||||||
|
ID uint `gorm:"primaryKey"` // 文件ID File ID
|
||||||
|
Hash string `gorm:"not null"` // 文件哈希值 File hash
|
||||||
|
UserID uint `gorm:"not null"` // 上传者ID Uploader ID
|
||||||
|
Group string // 分组名称
|
||||||
|
Name string // 文件名,为空显示未hash
|
||||||
|
}
|
||||||
|
@ -8,10 +8,10 @@ import (
|
|||||||
type User struct {
|
type User struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
Username string `gorm:"uniqueIndex;not null"` // 用户名,唯一
|
Username string `gorm:"uniqueIndex;not null"` // 用户名,唯一
|
||||||
Nickname string
|
Nickname string `gorm:"default:''"` // 昵称
|
||||||
AvatarUrl string
|
AvatarUrl string
|
||||||
Email string `gorm:"uniqueIndex"`
|
Email string `gorm:"uniqueIndex"`
|
||||||
Gender string
|
Gender string `gorm:"default:''"`
|
||||||
Role string `gorm:"default:'user'"` // user editor admin
|
Role string `gorm:"default:'user'"` // user editor admin
|
||||||
Language string `gorm:"default:'en'"`
|
Language string `gorm:"default:'en'"`
|
||||||
Password string // 密码,存储加密后的值
|
Password string // 密码,存储加密后的值
|
||||||
|
@ -1 +1,21 @@
|
|||||||
package repo
|
package repo
|
||||||
|
|
||||||
|
import "github.com/snowykami/neo-blog/internal/model"
|
||||||
|
|
||||||
|
type FileRepo struct{}
|
||||||
|
|
||||||
|
var File = &FileRepo{}
|
||||||
|
|
||||||
|
func (f *FileRepo) Create(file *model.File) (err error) {
|
||||||
|
return GetDB().Create(file).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FileRepo) GetByHash(hash string) (file model.File, err error) {
|
||||||
|
err = GetDB().Where("hash = ?", hash).First(&file).Error
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FileRepo) GetByID(id uint) (file model.File, err error) {
|
||||||
|
err = GetDB().Where("id = ?", id).First(&file).Error
|
||||||
|
return
|
||||||
|
}
|
||||||
|
@ -127,6 +127,7 @@ func migrate() error {
|
|||||||
&model.Comment{},
|
&model.Comment{},
|
||||||
&model.Label{},
|
&model.Label{},
|
||||||
&model.Like{},
|
&model.Like{},
|
||||||
|
&model.File{},
|
||||||
&model.OidcConfig{},
|
&model.OidcConfig{},
|
||||||
&model.Post{},
|
&model.Post{},
|
||||||
&model.Session{},
|
&model.Session{},
|
||||||
|
@ -1,7 +1,18 @@
|
|||||||
package apiv1
|
package apiv1
|
||||||
|
|
||||||
import "github.com/cloudwego/hertz/pkg/route"
|
import (
|
||||||
|
"github.com/cloudwego/hertz/pkg/route"
|
||||||
|
v1 "github.com/snowykami/neo-blog/internal/controller/v1"
|
||||||
|
"github.com/snowykami/neo-blog/internal/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
func registerFileRoutes(group *route.RouterGroup) {
|
func registerFileRoutes(group *route.RouterGroup) {
|
||||||
// TODO: Impl file routes
|
fileController := v1.NewFileController()
|
||||||
|
fileGroup := group.Group("/file").Use(middleware.UseAuth(true))
|
||||||
|
fileGroupWithoutAuth := group.Group("/file")
|
||||||
|
{
|
||||||
|
fileGroup.POST("/f", fileController.UploadFileStream) // 上传文件 Upload file
|
||||||
|
fileGroup.DELETE("/f/:id") // TODO: 删除文件 Delete file
|
||||||
|
fileGroupWithoutAuth.GET("/f/:id", fileController.GetFile) // 下载文件 Download file
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,196 +1,196 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/snowykami/neo-blog/pkg/constant"
|
"github.com/snowykami/neo-blog/pkg/constant"
|
||||||
"github.com/snowykami/neo-blog/pkg/utils"
|
"github.com/snowykami/neo-blog/pkg/utils"
|
||||||
|
|
||||||
"github.com/snowykami/neo-blog/internal/ctxutils"
|
"github.com/snowykami/neo-blog/internal/ctxutils"
|
||||||
"github.com/snowykami/neo-blog/internal/dto"
|
"github.com/snowykami/neo-blog/internal/dto"
|
||||||
"github.com/snowykami/neo-blog/internal/model"
|
"github.com/snowykami/neo-blog/internal/model"
|
||||||
"github.com/snowykami/neo-blog/internal/repo"
|
"github.com/snowykami/neo-blog/internal/repo"
|
||||||
"github.com/snowykami/neo-blog/pkg/errs"
|
"github.com/snowykami/neo-blog/pkg/errs"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CommentService struct{}
|
type CommentService struct{}
|
||||||
|
|
||||||
func NewCommentService() *CommentService {
|
func NewCommentService() *CommentService {
|
||||||
return &CommentService{}
|
return &CommentService{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cs *CommentService) CreateComment(ctx context.Context, req *dto.CreateCommentReq) (uint, error) {
|
func (cs *CommentService) CreateComment(ctx context.Context, req *dto.CreateCommentReq) (uint, error) {
|
||||||
currentUser, ok := ctxutils.GetCurrentUser(ctx)
|
currentUser, ok := ctxutils.GetCurrentUser(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
return 0, errs.ErrUnauthorized
|
return 0, errs.ErrUnauthorized
|
||||||
}
|
}
|
||||||
|
|
||||||
if ok, err := cs.checkTargetExists(req.TargetID, req.TargetType); !ok {
|
if ok, err := cs.checkTargetExists(req.TargetID, req.TargetType); !ok {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, errs.New(errs.ErrBadRequest.Code, "target not found", err)
|
return 0, errs.New(errs.ErrBadRequest.Code, "target not found", err)
|
||||||
}
|
}
|
||||||
return 0, errs.ErrBadRequest
|
return 0, errs.ErrBadRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
comment := &model.Comment{
|
comment := &model.Comment{
|
||||||
Content: req.Content,
|
Content: req.Content,
|
||||||
ReplyID: req.ReplyID,
|
ReplyID: req.ReplyID,
|
||||||
TargetID: req.TargetID,
|
TargetID: req.TargetID,
|
||||||
TargetType: req.TargetType,
|
TargetType: req.TargetType,
|
||||||
UserID: currentUser.ID,
|
UserID: currentUser.ID,
|
||||||
IsPrivate: req.IsPrivate,
|
IsPrivate: req.IsPrivate,
|
||||||
RemoteAddr: req.RemoteAddr,
|
RemoteAddr: req.RemoteAddr,
|
||||||
UserAgent: req.UserAgent,
|
UserAgent: req.UserAgent,
|
||||||
ShowClientInfo: req.ShowClientInfo,
|
ShowClientInfo: req.ShowClientInfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
commentID, err := repo.Comment.CreateComment(comment)
|
commentID, err := repo.Comment.CreateComment(comment)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return commentID, nil
|
return commentID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cs *CommentService) UpdateComment(ctx context.Context, req *dto.UpdateCommentReq) error {
|
func (cs *CommentService) UpdateComment(ctx context.Context, req *dto.UpdateCommentReq) error {
|
||||||
currentUser, ok := ctxutils.GetCurrentUser(ctx)
|
currentUser, ok := ctxutils.GetCurrentUser(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
return errs.ErrUnauthorized
|
return errs.ErrUnauthorized
|
||||||
}
|
}
|
||||||
logrus.Infof("UpdateComment: currentUser ID %d, req.CommentID %d", currentUser.ID, req.CommentID)
|
logrus.Infof("UpdateComment: currentUser ID %d, req.CommentID %d", currentUser.ID, req.CommentID)
|
||||||
|
|
||||||
comment, err := repo.Comment.GetComment(strconv.Itoa(int(req.CommentID)))
|
comment, err := repo.Comment.GetComment(strconv.Itoa(int(req.CommentID)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if currentUser.ID != comment.UserID {
|
if currentUser.ID != comment.UserID {
|
||||||
return errs.ErrForbidden
|
return errs.ErrForbidden
|
||||||
}
|
}
|
||||||
|
|
||||||
comment.Content = req.Content
|
comment.Content = req.Content
|
||||||
comment.IsPrivate = req.IsPrivate
|
comment.IsPrivate = req.IsPrivate
|
||||||
comment.ShowClientInfo = req.ShowClientInfo
|
comment.ShowClientInfo = req.ShowClientInfo
|
||||||
err = repo.Comment.UpdateComment(comment)
|
err = repo.Comment.UpdateComment(comment)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cs *CommentService) DeleteComment(ctx context.Context, commentID string) error {
|
func (cs *CommentService) DeleteComment(ctx context.Context, commentID string) error {
|
||||||
currentUser, ok := ctxutils.GetCurrentUser(ctx)
|
currentUser, ok := ctxutils.GetCurrentUser(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
return errs.ErrUnauthorized
|
return errs.ErrUnauthorized
|
||||||
}
|
}
|
||||||
if commentID == "" {
|
if commentID == "" {
|
||||||
return errs.ErrBadRequest
|
return errs.ErrBadRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
comment, err := repo.Comment.GetComment(commentID)
|
comment, err := repo.Comment.GetComment(commentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errs.New(errs.ErrNotFound.Code, "comment not found", err)
|
return errs.New(errs.ErrNotFound.Code, "comment not found", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
isTargetOwner := false
|
isTargetOwner := false
|
||||||
if comment.TargetType == constant.TargetTypePost {
|
if comment.TargetType == constant.TargetTypePost {
|
||||||
post, err := repo.Post.GetPostByID(strconv.Itoa(int(comment.TargetID)))
|
post, err := repo.Post.GetPostByID(strconv.Itoa(int(comment.TargetID)))
|
||||||
if err == nil && post.UserID == currentUser.ID {
|
if err == nil && post.UserID == currentUser.ID {
|
||||||
isTargetOwner = true
|
isTargetOwner = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if comment.UserID != currentUser.ID && isTargetOwner {
|
if comment.UserID != currentUser.ID && isTargetOwner {
|
||||||
return errs.ErrForbidden
|
return errs.ErrForbidden
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := repo.Comment.DeleteComment(commentID); err != nil {
|
if err := repo.Comment.DeleteComment(commentID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cs *CommentService) GetComment(ctx context.Context, commentID string) (*dto.CommentDto, error) {
|
func (cs *CommentService) GetComment(ctx context.Context, commentID string) (*dto.CommentDto, error) {
|
||||||
comment, err := repo.Comment.GetComment(commentID)
|
comment, err := repo.Comment.GetComment(commentID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.New(errs.ErrNotFound.Code, "comment not found", err)
|
return nil, errs.New(errs.ErrNotFound.Code, "comment not found", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
currentUserID := uint(0)
|
currentUserID := uint(0)
|
||||||
if currentUser, ok := ctxutils.GetCurrentUser(ctx); ok {
|
if currentUser, ok := ctxutils.GetCurrentUser(ctx); ok {
|
||||||
currentUserID = currentUser.ID
|
currentUserID = currentUser.ID
|
||||||
}
|
}
|
||||||
if comment.IsPrivate && currentUserID != comment.UserID {
|
if comment.IsPrivate && currentUserID != comment.UserID {
|
||||||
return nil, errs.ErrForbidden
|
return nil, errs.ErrForbidden
|
||||||
}
|
}
|
||||||
commentDto := cs.toGetCommentDto(comment, currentUserID)
|
commentDto := cs.toGetCommentDto(comment, currentUserID)
|
||||||
return &commentDto, err
|
return &commentDto, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cs *CommentService) GetCommentList(ctx context.Context, req *dto.GetCommentListReq) ([]dto.CommentDto, error) {
|
func (cs *CommentService) GetCommentList(ctx context.Context, req *dto.GetCommentListReq) ([]dto.CommentDto, error) {
|
||||||
currentUserID := uint(0)
|
currentUserID := uint(0)
|
||||||
if currentUser, ok := ctxutils.GetCurrentUser(ctx); ok {
|
if currentUser, ok := ctxutils.GetCurrentUser(ctx); ok {
|
||||||
currentUserID = currentUser.ID
|
currentUserID = currentUser.ID
|
||||||
}
|
}
|
||||||
comments, err := repo.Comment.ListComments(currentUserID, req.TargetID, req.CommentID, req.TargetType, req.Page, req.Size, req.OrderBy, req.Desc, req.Depth)
|
comments, err := repo.Comment.ListComments(currentUserID, req.TargetID, req.CommentID, req.TargetType, req.Page, req.Size, req.OrderBy, req.Desc, req.Depth)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.New(errs.ErrInternalServer.Code, "failed to list comments", err)
|
return nil, errs.New(errs.ErrInternalServer.Code, "failed to list comments", err)
|
||||||
}
|
}
|
||||||
commentDtos := make([]dto.CommentDto, 0)
|
commentDtos := make([]dto.CommentDto, 0)
|
||||||
for _, comment := range comments {
|
for _, comment := range comments {
|
||||||
//replyCount, _ := repo.Comment.CountReplyComments(currentUserID, comment.ID)
|
//replyCount, _ := repo.Comment.CountReplyComments(currentUserID, comment.ID)
|
||||||
commentDto := cs.toGetCommentDto(&comment, currentUserID)
|
commentDto := cs.toGetCommentDto(&comment, currentUserID)
|
||||||
commentDtos = append(commentDtos, commentDto)
|
commentDtos = append(commentDtos, commentDto)
|
||||||
}
|
}
|
||||||
return commentDtos, nil
|
return commentDtos, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cs *CommentService) toGetCommentDto(comment *model.Comment, currentUserID uint) dto.CommentDto {
|
func (cs *CommentService) toGetCommentDto(comment *model.Comment, currentUserID uint) dto.CommentDto {
|
||||||
isLiked := false
|
isLiked := false
|
||||||
if currentUserID != 0 {
|
if currentUserID != 0 {
|
||||||
isLiked, _ = repo.Like.IsLiked(currentUserID, comment.ID, constant.TargetTypeComment)
|
isLiked, _ = repo.Like.IsLiked(currentUserID, comment.ID, constant.TargetTypeComment)
|
||||||
}
|
}
|
||||||
ua := utils.ParseUA(comment.UserAgent)
|
ua := utils.ParseUA(comment.UserAgent)
|
||||||
if !comment.ShowClientInfo {
|
if !comment.ShowClientInfo {
|
||||||
comment.Location = ""
|
comment.Location = ""
|
||||||
ua.OS = ""
|
ua.OS = ""
|
||||||
ua.OSVersion = ""
|
ua.OSVersion = ""
|
||||||
ua.Browser = ""
|
ua.Browser = ""
|
||||||
ua.BrowserVer = ""
|
ua.BrowserVer = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return dto.CommentDto{
|
return dto.CommentDto{
|
||||||
ID: comment.ID,
|
ID: comment.ID,
|
||||||
Content: comment.Content,
|
Content: comment.Content,
|
||||||
TargetID: comment.TargetID,
|
TargetID: comment.TargetID,
|
||||||
TargetType: comment.TargetType,
|
TargetType: comment.TargetType,
|
||||||
ReplyID: comment.ReplyID,
|
ReplyID: comment.ReplyID,
|
||||||
CreatedAt: comment.CreatedAt.String(),
|
CreatedAt: comment.CreatedAt.String(),
|
||||||
UpdatedAt: comment.UpdatedAt.String(),
|
UpdatedAt: comment.UpdatedAt.String(),
|
||||||
Depth: comment.Depth,
|
Depth: comment.Depth,
|
||||||
User: comment.User.ToDto(),
|
User: comment.User.ToDto(),
|
||||||
ReplyCount: comment.CommentCount,
|
ReplyCount: comment.CommentCount,
|
||||||
LikeCount: comment.LikeCount,
|
LikeCount: comment.LikeCount,
|
||||||
IsLiked: isLiked,
|
IsLiked: isLiked,
|
||||||
IsPrivate: comment.IsPrivate,
|
IsPrivate: comment.IsPrivate,
|
||||||
OS: ua.OS + " " + ua.OSVersion,
|
OS: ua.OS + " " + ua.OSVersion,
|
||||||
Browser: ua.Browser + " " + ua.BrowserVer,
|
Browser: ua.Browser + " " + ua.BrowserVer,
|
||||||
Location: comment.Location,
|
Location: comment.Location,
|
||||||
ShowClientInfo: comment.ShowClientInfo,
|
ShowClientInfo: comment.ShowClientInfo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
func (cs *CommentService) checkTargetExists(targetID uint, targetType string) (bool, error) {
|
func (cs *CommentService) checkTargetExists(targetID uint, targetType string) (bool, error) {
|
||||||
switch targetType {
|
switch targetType {
|
||||||
case constant.TargetTypePost:
|
case constant.TargetTypePost:
|
||||||
if _, err := repo.Post.GetPostByID(strconv.Itoa(int(targetID))); err != nil {
|
if _, err := repo.Post.GetPostByID(strconv.Itoa(int(targetID))); err != nil {
|
||||||
return false, errs.New(errs.ErrNotFound.Code, "post not found", err)
|
return false, errs.New(errs.ErrNotFound.Code, "post not found", err)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return false, errs.New(errs.ErrBadRequest.Code, "invalid target type", nil)
|
return false, errs.New(errs.ErrBadRequest.Code, "invalid target type", nil)
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
@ -368,7 +368,7 @@ func (s *UserService) UpdateUser(req *dto.UpdateUserReq) (*dto.UpdateUserResp, e
|
|||||||
return nil, errs.ErrNotFound
|
return nil, errs.ErrNotFound
|
||||||
}
|
}
|
||||||
logrus.Errorln("Failed to update user:", err)
|
logrus.Errorln("Failed to update user:", err)
|
||||||
return nil, errs.ErrInternalServer
|
return nil, err
|
||||||
}
|
}
|
||||||
return &dto.UpdateUserResp{}, nil
|
return &dto.UpdateUserResp{}, nil
|
||||||
}
|
}
|
||||||
|
@ -1,57 +1,69 @@
|
|||||||
package constant
|
package constant
|
||||||
|
|
||||||
const (
|
const (
|
||||||
CaptchaTypeDisable = "disable" // 禁用验证码
|
CaptchaTypeDisable = "disable" // 禁用验证码
|
||||||
CaptchaTypeHCaptcha = "hcaptcha" // HCaptcha验证码
|
CaptchaTypeHCaptcha = "hcaptcha" // HCaptcha验证码
|
||||||
CaptchaTypeTurnstile = "turnstile" // Turnstile验证码
|
CaptchaTypeTurnstile = "turnstile" // Turnstile验证码
|
||||||
CaptchaTypeReCaptcha = "recaptcha" // ReCaptcha验证码
|
CaptchaTypeReCaptcha = "recaptcha" // ReCaptcha验证码
|
||||||
ContextKeyUserID = "user_id" // 上下文键:用户ID
|
ContextKeyUserID = "user_id" // 上下文键:用户ID
|
||||||
ContextKeyRemoteAddr = "remote_addr" // 上下文键:远程地址
|
ContextKeyRemoteAddr = "remote_addr" // 上下文键:远程地址
|
||||||
ContextKeyUserAgent = "user_agent" // 上下文键:用户代理
|
ContextKeyUserAgent = "user_agent" // 上下文键:用户代理
|
||||||
ModeDev = "dev"
|
ModeDev = "dev"
|
||||||
ModeProd = "prod"
|
ModeProd = "prod"
|
||||||
RoleUser = "user" // 普通用户 仅有阅读和评论权限
|
RoleUser = "user" // 普通用户 仅有阅读和评论权限
|
||||||
RoleEditor = "editor" // 能够发布和管理自己内容的用户
|
RoleEditor = "editor" // 能够发布和管理自己内容的用户
|
||||||
RoleAdmin = "admin"
|
RoleAdmin = "admin"
|
||||||
EnvKeyBaseUrl = "BASE_URL" // 环境变量:基础URL
|
DefaultFileBasePath = "./data/uploads"
|
||||||
EnvKeyCaptchaProvider = "CAPTCHA_PROVIDER" // captcha提供者
|
EnvKeyBaseUrl = "BASE_URL" // 环境变量:基础URL
|
||||||
EnvKeyCaptchaSecreteKey = "CAPTCHA_SECRET_KEY" // captcha站点密钥
|
EnvKeyCaptchaProvider = "CAPTCHA_PROVIDER" // captcha提供者
|
||||||
EnvKeyCaptchaUrl = "CAPTCHA_URL" // 某些自托管的captcha的url
|
EnvKeyCaptchaSecreteKey = "CAPTCHA_SECRET_KEY" // captcha站点密钥
|
||||||
EnvKeyCaptchaSiteKey = "CAPTCHA_SITE_KEY" // captcha密钥key
|
EnvKeyCaptchaUrl = "CAPTCHA_URL" // 某些自托管的captcha的url
|
||||||
EnvKeyLocationFormat = "LOCATION_FORMAT" // 环境变量:时区格式
|
EnvKeyCaptchaSiteKey = "CAPTCHA_SITE_KEY" // captcha密钥key
|
||||||
EnvKeyLogLevel = "LOG_LEVEL" // 环境变量:日志级别
|
EnvKeyFileDriverType = "FILE_DRIVER_TYPE"
|
||||||
EnvKeyMode = "MODE" // 环境变量:运行模式
|
EnvKeyFileBasepath = "FILE_BASEPATH"
|
||||||
EnvKeyJwtSecrete = "JWT_SECRET" // 环境变量:JWT密钥
|
EnvKeyFileWebdavUrl = "FILE_WEBDAV_URL"
|
||||||
EnvKeyPasswordSalt = "PASSWORD_SALT" // 环境变量:密码盐
|
EnvKeyFileWebdavPassword = "FILE_WEBDAV_PASSWORD"
|
||||||
EnvKeyTokenDuration = "TOKEN_DURATION" // 环境变量:令牌有效期
|
EnvKeyFileWebdavPolicy = "FILE_WEBDAV_POLICY"
|
||||||
EnvKeyMaxReplyDepth = "MAX_REPLY_DEPTH" // 环境变量:最大回复深度
|
EnvKeyFileWebdavUser = "FILE_WEBDAV_USER"
|
||||||
EnvKeyTokenDurationDefault = 30 // Token有效时长
|
EnvKeyLocationFormat = "LOCATION_FORMAT" // 环境变量:时区格式
|
||||||
EnvKeyRefreshTokenDurationDefault = 6000000 // refresh token有效时长
|
EnvKeyLogLevel = "LOG_LEVEL" // 环境变量:日志级别
|
||||||
EnvKeyRefreshTokenDuration = "REFRESH_TOKEN_DURATION" // 环境变量:刷新令牌有效期
|
EnvKeyMode = "MODE" // 环境变量:运行模式
|
||||||
EnvKeyRefreshTokenDurationWithRemember = "REFRESH_TOKEN_DURATION_WITH_REMEMBER" // 环境变量:记住我刷新令牌有效期
|
EnvKeyJwtSecrete = "JWT_SECRET" // 环境变量:JWT密钥
|
||||||
KVKeyEmailVerificationCode = "email_verification_code:" // KV存储:邮箱验证码
|
EnvKeyPasswordSalt = "PASSWORD_SALT" // 环境变量:密码盐
|
||||||
KVKeyOidcState = "oidc_state:" // KV存储:OIDC状态
|
EnvKeyTokenDuration = "TOKEN_DURATION" // 环境变量:令牌有效期
|
||||||
ApiSuffix = "/api/v1" // API版本前缀
|
EnvKeyMaxReplyDepth = "MAX_REPLY_DEPTH" // 环境变量:最大回复深度
|
||||||
OidcUri = "/user/oidc/login" // OIDC登录URI
|
EnvKeyTokenDurationDefault = 500 // Token有效时长
|
||||||
OidcProviderTypeMisskey = "misskey" // OIDC提供者类型:Misskey
|
EnvKeyRefreshTokenDurationDefault = 6000000 // refresh token有效时长
|
||||||
OidcProviderTypeOauth2 = "oauth2" // OIDC提供者类型:GitHub
|
EnvKeyRefreshTokenDuration = "REFRESH_TOKEN_DURATION" // 环境变量:刷新令牌有效期
|
||||||
DefaultBaseUrl = "http://localhost:3000" // 默认BaseUrl
|
EnvKeyRefreshTokenDurationWithRemember = "REFRESH_TOKEN_DURATION_WITH_REMEMBER" // 环境变量:记住我刷新令牌有效期
|
||||||
TargetTypePost = "post"
|
FileDriverTypeLocal = "local"
|
||||||
TargetTypeComment = "comment"
|
FileDriverTypeWebdav = "webdav"
|
||||||
OrderByCreatedAt = "created_at" // 按创建时间排序
|
FileDriverTypeS3 = "s3"
|
||||||
OrderByUpdatedAt = "updated_at" // 按更新时间排序
|
KVKeyEmailVerificationCode = "email_verification_code:" // KV存储:邮箱验证码
|
||||||
OrderByLikeCount = "like_count" // 按点赞数排序
|
KVKeyOidcState = "oidc_state:" // KV存储:OIDC状态
|
||||||
OrderByCommentCount = "comment_count" // 按评论数排序
|
ApiSuffix = "/api/v1" // API版本前缀
|
||||||
OrderByViewCount = "view_count" // 按浏览量排序
|
OidcUri = "/user/oidc/login" // OIDC登录URI
|
||||||
OrderByHeat = "heat"
|
OidcProviderTypeMisskey = "misskey" // OIDC提供者类型:Misskey
|
||||||
MaxReplyDepthDefault = 3 // 默认最大回复深度
|
OidcProviderTypeOauth2 = "oauth2" // OIDC提供者类型:GitHub
|
||||||
HeatFactorViewWeight = 1 // 热度因子:浏览量权重
|
DefaultBaseUrl = "http://localhost:3000" // 默认BaseUrl
|
||||||
HeatFactorLikeWeight = 5 // 热度因子:点赞权重
|
TargetTypePost = "post"
|
||||||
HeatFactorCommentWeight = 10 // 热度因子:评论权重
|
TargetTypeComment = "comment"
|
||||||
PageLimitDefault = 20 // 默认分页大小
|
WebdavPolicyProxy = "proxy"
|
||||||
|
WebdavPolicyRedirect = "redirect"
|
||||||
|
OrderByCreatedAt = "created_at" // 按创建时间排序
|
||||||
|
OrderByUpdatedAt = "updated_at" // 按更新时间排序
|
||||||
|
OrderByLikeCount = "like_count" // 按点赞数排序
|
||||||
|
OrderByCommentCount = "comment_count" // 按评论数排序
|
||||||
|
OrderByViewCount = "view_count" // 按浏览量排序
|
||||||
|
OrderByHeat = "heat"
|
||||||
|
MaxReplyDepthDefault = 3 // 默认最大回复深度
|
||||||
|
HeatFactorViewWeight = 1 // 热度因子:浏览量权重
|
||||||
|
HeatFactorLikeWeight = 5 // 热度因子:点赞权重
|
||||||
|
HeatFactorCommentWeight = 10 // 热度因子:评论权重
|
||||||
|
PageLimitDefault = 20 // 默认分页大小
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
OrderByEnumPost = []string{OrderByCreatedAt, OrderByUpdatedAt, OrderByLikeCount, OrderByCommentCount, OrderByViewCount, OrderByHeat} // 帖子可用的排序方式
|
OrderByEnumPost = []string{OrderByCreatedAt, OrderByUpdatedAt, OrderByLikeCount, OrderByCommentCount, OrderByViewCount, OrderByHeat} // 帖子可用的排序方式
|
||||||
OrderByEnumComment = []string{OrderByCreatedAt, OrderByUpdatedAt, OrderByCommentCount} // 评论可用的排序方式
|
OrderByEnumComment = []string{OrderByCreatedAt, OrderByUpdatedAt, OrderByCommentCount} // 评论可用的排序方式
|
||||||
)
|
)
|
||||||
|
@ -2,8 +2,9 @@ package errs
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"github.com/cloudwego/hertz/pkg/app"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/cloudwego/hertz/pkg/app"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ServiceError 业务错误结构
|
// ServiceError 业务错误结构
|
||||||
|
@ -2,10 +2,13 @@ package filedriver
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/LiteyukiStudio/spage/pkg/constants"
|
|
||||||
"github.com/cloudwego/hertz/pkg/app"
|
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"github.com/cloudwego/hertz/pkg/app"
|
||||||
|
"github.com/snowykami/neo-blog/pkg/constant"
|
||||||
|
"github.com/snowykami/neo-blog/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FileDriver interface {
|
type FileDriver interface {
|
||||||
@ -18,19 +21,30 @@ type FileDriver interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DriverConfig struct {
|
type DriverConfig struct {
|
||||||
Type string `mapstructure:"file.driver.type"`
|
Type string
|
||||||
BasePath string `mapstructure:"file.driver.base_path"`
|
BasePath string
|
||||||
WebDavUrl string `mapstructure:"file.driver.webdav.url"`
|
WebDavUrl string
|
||||||
WebDavUser string `mapstructure:"file.driver.webdav.user"`
|
WebDavUser string
|
||||||
WebDavPassword string `mapstructure:"file.driver.webdav.password"`
|
WebDavPassword string
|
||||||
WebDavPolicy string `mapstructure:"file.driver.webdav.policy"` // proxy|redirect
|
WebDavPolicy string
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetWebdavDriverConfig() *DriverConfig {
|
||||||
|
return &DriverConfig{
|
||||||
|
Type: utils.Env.Get(constant.EnvKeyFileDriverType, constant.FileDriverTypeLocal),
|
||||||
|
BasePath: utils.Env.Get(constant.EnvKeyFileBasepath, constant.DefaultFileBasePath),
|
||||||
|
WebDavUrl: utils.Env.Get(constant.EnvKeyFileWebdavUrl),
|
||||||
|
WebDavUser: utils.Env.Get(constant.EnvKeyFileWebdavUser),
|
||||||
|
WebDavPassword: utils.Env.Get(constant.EnvKeyFileWebdavPassword),
|
||||||
|
WebDavPolicy: utils.Env.Get(constant.EnvKeyFileWebdavPolicy),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetFileDriver(driverConfig *DriverConfig) (FileDriver, error) {
|
func GetFileDriver(driverConfig *DriverConfig) (FileDriver, error) {
|
||||||
switch driverConfig.Type {
|
switch driverConfig.Type {
|
||||||
case constants.FileDriverLocal:
|
case constant.FileDriverTypeLocal:
|
||||||
return NewLocalDriver(driverConfig), nil
|
return NewLocalDriver(driverConfig), nil
|
||||||
case constants.FileDriverWebdav:
|
case constant.FileDriverTypeWebdav:
|
||||||
return NewWebDAVClientDriver(driverConfig), nil
|
return NewWebDAVClientDriver(driverConfig), nil
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported file driver type: %s", driverConfig.Type)
|
return nil, fmt.Errorf("unsupported file driver type: %s", driverConfig.Type)
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
package filedriver
|
package filedriver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/cloudwego/hertz/pkg/app"
|
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/cloudwego/hertz/pkg/app"
|
||||||
)
|
)
|
||||||
|
|
||||||
type LocalDriver struct {
|
type LocalDriver struct {
|
||||||
|
@ -3,9 +3,11 @@ package filedriver
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/LiteyukiStudio/spage/pkg/constants"
|
|
||||||
"github.com/LiteyukiStudio/spage/pkg/resps"
|
|
||||||
"github.com/cloudwego/hertz/pkg/app"
|
"github.com/cloudwego/hertz/pkg/app"
|
||||||
|
"github.com/snowykami/neo-blog/pkg/constant"
|
||||||
|
"github.com/snowykami/neo-blog/pkg/resps"
|
||||||
|
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
@ -48,7 +50,7 @@ func (d *WebDAVClientDriver) Open(ctx *app.RequestContext, p string) (io.ReadClo
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *WebDAVClientDriver) Get(ctx *app.RequestContext, p string) {
|
func (d *WebDAVClientDriver) Get(ctx *app.RequestContext, p string) {
|
||||||
if d.config.WebDavPolicy == constants.WebDavPolicyRedirect {
|
if d.config.WebDavPolicy == constant.WebdavPolicyRedirect {
|
||||||
ctx.Redirect(302, []byte(d.config.WebDavUrl+d.fullPath(p)))
|
ctx.Redirect(302, []byte(d.config.WebDavUrl+d.fullPath(p)))
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
|
@ -1 +1,31 @@
|
|||||||
package utils
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FilePath 根据哈希值生成文件路径,前4位为目录位hash[0:4]/hash
|
||||||
|
func FilePath(hash string) (dir, file string) {
|
||||||
|
dir = hash[0:4]
|
||||||
|
file = hash
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func FileHashFromStream(file multipart.File) (string, error) {
|
||||||
|
// 创建哈希计算器
|
||||||
|
hash := sha256.New()
|
||||||
|
|
||||||
|
// 将文件流内容拷贝到哈希计算器
|
||||||
|
if _, err := io.Copy(hash, file); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算哈希值并转换为十六进制字符串
|
||||||
|
hashInBytes := hash.Sum(nil)
|
||||||
|
hashString := hex.EncodeToString(hashInBytes)
|
||||||
|
|
||||||
|
return hashString, nil
|
||||||
|
}
|
||||||
|
@ -37,6 +37,7 @@
|
|||||||
"deepmerge": "^4.3.1",
|
"deepmerge": "^4.3.1",
|
||||||
"field-conv": "^1.0.9",
|
"field-conv": "^1.0.9",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"md5": "^2.3.0",
|
"md5": "^2.3.0",
|
||||||
"motion": "^12.23.12",
|
"motion": "^12.23.12",
|
||||||
@ -48,6 +49,7 @@
|
|||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-google-recaptcha-v3": "^1.11.0",
|
"react-google-recaptcha-v3": "^1.11.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
|
"react-image-crop": "^11.0.10",
|
||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
"rehype-highlight": "^7.0.2",
|
"rehype-highlight": "^7.0.2",
|
||||||
"sonner": "^2.0.6",
|
"sonner": "^2.0.6",
|
||||||
|
26
web/pnpm-lock.yaml
generated
26
web/pnpm-lock.yaml
generated
@ -92,6 +92,9 @@ importers:
|
|||||||
highlight.js:
|
highlight.js:
|
||||||
specifier: ^11.11.1
|
specifier: ^11.11.1
|
||||||
version: 11.11.1
|
version: 11.11.1
|
||||||
|
input-otp:
|
||||||
|
specifier: ^1.4.2
|
||||||
|
version: 1.4.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.525.0
|
specifier: ^0.525.0
|
||||||
version: 0.525.0(react@19.1.0)
|
version: 0.525.0(react@19.1.0)
|
||||||
@ -125,6 +128,9 @@ importers:
|
|||||||
react-icons:
|
react-icons:
|
||||||
specifier: ^5.5.0
|
specifier: ^5.5.0
|
||||||
version: 5.5.0(react@19.1.0)
|
version: 5.5.0(react@19.1.0)
|
||||||
|
react-image-crop:
|
||||||
|
specifier: ^11.0.10
|
||||||
|
version: 11.0.10(react@19.1.0)
|
||||||
recharts:
|
recharts:
|
||||||
specifier: 2.15.4
|
specifier: 2.15.4
|
||||||
version: 2.15.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
version: 2.15.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
@ -2137,6 +2143,12 @@ packages:
|
|||||||
inline-style-parser@0.2.4:
|
inline-style-parser@0.2.4:
|
||||||
resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==}
|
resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==}
|
||||||
|
|
||||||
|
input-otp@1.4.2:
|
||||||
|
resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
|
||||||
internal-slot@1.1.0:
|
internal-slot@1.1.0:
|
||||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@ -2787,6 +2799,11 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: '*'
|
react: '*'
|
||||||
|
|
||||||
|
react-image-crop@11.0.10:
|
||||||
|
resolution: {integrity: sha512-+5FfDXUgYLLqBh1Y/uQhIycpHCbXkI50a+nbfkB1C0xXXUTwkisHDo2QCB1SQJyHCqIuia4FeyReqXuMDKWQTQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.13.1'
|
||||||
|
|
||||||
react-is@16.13.1:
|
react-is@16.13.1:
|
||||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||||
|
|
||||||
@ -5408,6 +5425,11 @@ snapshots:
|
|||||||
|
|
||||||
inline-style-parser@0.2.4: {}
|
inline-style-parser@0.2.4: {}
|
||||||
|
|
||||||
|
input-otp@1.4.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||||
|
dependencies:
|
||||||
|
react: 19.1.0
|
||||||
|
react-dom: 19.1.0(react@19.1.0)
|
||||||
|
|
||||||
internal-slot@1.1.0:
|
internal-slot@1.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
@ -6239,6 +6261,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.1.0
|
react: 19.1.0
|
||||||
|
|
||||||
|
react-image-crop@11.0.10(react@19.1.0):
|
||||||
|
dependencies:
|
||||||
|
react: 19.1.0
|
||||||
|
|
||||||
react-is@16.13.1: {}
|
react-is@16.13.1: {}
|
||||||
|
|
||||||
react-is@18.3.1: {}
|
react-is@18.3.1: {}
|
||||||
|
@ -14,9 +14,26 @@ const axiosClient = axios.create({
|
|||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function isBrowserFormData(v: any) {
|
||||||
|
return typeof FormData !== 'undefined' && v instanceof FormData
|
||||||
|
}
|
||||||
|
// node form-data (form-data package) heuristic
|
||||||
|
function isNodeFormData(v: any) {
|
||||||
|
return v && typeof v.getHeaders === 'function' && typeof v.pipe === 'function'
|
||||||
|
}
|
||||||
|
|
||||||
axiosClient.interceptors.request.use((config) => {
|
axiosClient.interceptors.request.use((config) => {
|
||||||
if (config.data && typeof config.data === 'object') {
|
// 如果是 FormData(浏览器)或 node form-data,跳过对象转换
|
||||||
|
if (config.data && typeof config.data === 'object' && !isBrowserFormData(config.data) && !isNodeFormData(config.data)) {
|
||||||
config.data = camelToSnakeObj(config.data)
|
config.data = camelToSnakeObj(config.data)
|
||||||
|
} else if (isBrowserFormData(config.data)) {
|
||||||
|
// 只处理键
|
||||||
|
const formData = config.data as FormData
|
||||||
|
const newFormData = new FormData()
|
||||||
|
for (const [key, value] of formData.entries()) {
|
||||||
|
newFormData.append(camelToSnakeObj(key), value)
|
||||||
|
}
|
||||||
|
config.data = newFormData
|
||||||
}
|
}
|
||||||
if (config.params && typeof config.params === 'object') {
|
if (config.params && typeof config.params === 'object') {
|
||||||
config.params = camelToSnakeObj(config.params)
|
config.params = camelToSnakeObj(config.params)
|
||||||
|
25
web/src/api/file.ts
Normal file
25
web/src/api/file.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { BaseResponse } from '@/models/resp'
|
||||||
|
import axiosClient from './client'
|
||||||
|
|
||||||
|
export async function uploadFile({ file, name, group }: { file: File, name?: string, group?: string }): Promise<BaseResponse<{
|
||||||
|
hash: string,
|
||||||
|
id: number,
|
||||||
|
}>> {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
throw new Error('uploadFile can only be used in the browser')
|
||||||
|
}
|
||||||
|
if (!file) {
|
||||||
|
throw new Error('No file provided')
|
||||||
|
}
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
formData.append('name', name || file.name)
|
||||||
|
formData.append('group', group || '')
|
||||||
|
const res = await axiosClient.post<BaseResponse<{
|
||||||
|
hash: string,
|
||||||
|
id: number,
|
||||||
|
}>>('/file/f', formData, {
|
||||||
|
withCredentials: true,
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
}
|
@ -1,8 +1,8 @@
|
|||||||
import type { OidcConfig } from '@/models/oidc-config'
|
import type { OidcConfig } from '@/models/oidc-config'
|
||||||
import type { BaseResponse } from '@/models/resp'
|
import type { BaseResponse } from '@/models/resp'
|
||||||
import type { RegisterRequest, User } from '@/models/user'
|
import type { RegisterRequest, User } from '@/models/user'
|
||||||
import axiosClient from './client'
|
|
||||||
import { CaptchaProvider } from '@/models/captcha'
|
import { CaptchaProvider } from '@/models/captcha'
|
||||||
|
import axiosClient from './client'
|
||||||
|
|
||||||
export async function userLogin(
|
export async function userLogin(
|
||||||
{
|
{
|
||||||
@ -84,3 +84,8 @@ export async function getCaptchaConfig(): Promise<BaseResponse<{
|
|||||||
}>>('/user/captcha')
|
}>>('/user/captcha')
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateUser(data: Partial<User>): Promise<BaseResponse<User>> {
|
||||||
|
const res = await axiosClient.put<BaseResponse<User>>(`/user/u/${data.id}`, data)
|
||||||
|
return res.data
|
||||||
|
}
|
@ -7,8 +7,10 @@ import {
|
|||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
|
|
||||||
import { useToLogin } from "@/hooks/use-route"
|
import { useToLogin } from "@/hooks/use-route"
|
||||||
import { useEffect } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useAuth } from "@/contexts/auth-context"
|
import { useAuth } from "@/contexts/auth-context"
|
||||||
|
import { sidebarData, SidebarItem } from "@/components/console/data"
|
||||||
|
import { usePathname } from "next/navigation"
|
||||||
|
|
||||||
export default function ConsoleLayout({
|
export default function ConsoleLayout({
|
||||||
children,
|
children,
|
||||||
@ -16,7 +18,21 @@ export default function ConsoleLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const [title, setTitle] = useState("Title");
|
||||||
const toLogin = useToLogin();
|
const toLogin = useToLogin();
|
||||||
|
const pathname = usePathname() ?? "/"
|
||||||
|
|
||||||
|
const sideBarItems: SidebarItem[] = sidebarData.navMain.concat(sidebarData.navUserCenter);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentItem = sideBarItems.find(item => item.url === pathname);
|
||||||
|
if (currentItem) {
|
||||||
|
setTitle(currentItem.title);
|
||||||
|
document.title = `${currentItem.title} - 控制台`;
|
||||||
|
} else {
|
||||||
|
setTitle("Title");
|
||||||
|
}
|
||||||
|
}, [pathname])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@ -35,8 +51,10 @@ export default function ConsoleLayout({
|
|||||||
>
|
>
|
||||||
<AppSidebar variant="inset" />
|
<AppSidebar variant="inset" />
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<SiteHeader />
|
<SiteHeader title={title} />
|
||||||
{children}
|
<div className="p-5 md:p-8">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
)
|
)
|
||||||
|
5
web/src/app/console/setting/page.tsx
Normal file
5
web/src/app/console/setting/page.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import SettingPage from "@/components/console/setting";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <SettingPage />;
|
||||||
|
}
|
3
web/src/app/console/user-preference/page.tsx
Normal file
3
web/src/app/console/user-preference/page.tsx
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <div>个性化设置</div>
|
||||||
|
}
|
5
web/src/app/console/user-profile/page.tsx
Normal file
5
web/src/app/console/user-profile/page.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { UserProfilePage } from "@/components/console/user-profile";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <UserProfilePage />;
|
||||||
|
}
|
5
web/src/app/console/user-security/page.tsx
Normal file
5
web/src/app/console/user-security/page.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { UserSecurityPage } from "@/components/console/user-security";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <UserSecurityPage />;
|
||||||
|
}
|
@ -1,5 +1,6 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
|
@import "./styles/violet.css";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@ -43,75 +44,6 @@
|
|||||||
--radius-xl: calc(var(--radius) + 4px);
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
|
||||||
--radius: 0.65rem;
|
|
||||||
--background: oklch(1 0 0);
|
|
||||||
--foreground: oklch(0.141 0.005 285.823);
|
|
||||||
--card: oklch(1 0 0);
|
|
||||||
--card-foreground: oklch(0.141 0.005 285.823);
|
|
||||||
--popover: oklch(1 0 0);
|
|
||||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
|
||||||
--primary: oklch(0.623 0.214 259.815);
|
|
||||||
--primary-foreground: oklch(0.97 0.014 254.604);
|
|
||||||
--secondary: oklch(0.967 0.001 286.375);
|
|
||||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
|
||||||
--muted: oklch(0.967 0.001 286.375);
|
|
||||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
|
||||||
--accent: oklch(0.967 0.001 286.375);
|
|
||||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
|
||||||
--border: oklch(0.92 0.004 286.32);
|
|
||||||
--input: oklch(0.92 0.004 286.32);
|
|
||||||
--ring: oklch(0.623 0.214 259.815);
|
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
|
||||||
--sidebar: oklch(0.985 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
|
||||||
--sidebar-primary: oklch(0.623 0.214 259.815);
|
|
||||||
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
|
|
||||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
|
||||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
|
||||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
|
||||||
--sidebar-ring: oklch(0.623 0.214 259.815);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--background: oklch(0.141 0.005 285.823);
|
|
||||||
--foreground: oklch(0.985 0 0);
|
|
||||||
--card: oklch(0.21 0.006 285.885);
|
|
||||||
--card-foreground: oklch(0.985 0 0);
|
|
||||||
--popover: oklch(0.21 0.006 285.885);
|
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
|
||||||
--primary: oklch(0.546 0.245 262.881);
|
|
||||||
--primary-foreground: oklch(0.379 0.146 265.522);
|
|
||||||
--secondary: oklch(0.274 0.006 286.033);
|
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
|
||||||
--muted: oklch(0.274 0.006 286.033);
|
|
||||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
|
||||||
--accent: oklch(0.274 0.006 286.033);
|
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
|
||||||
--border: oklch(1 0 0 / 10%);
|
|
||||||
--input: oklch(1 0 0 / 15%);
|
|
||||||
--ring: oklch(0.488 0.243 264.376);
|
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
|
||||||
--sidebar: oklch(0.21 0.006 285.885);
|
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-primary: oklch(0.546 0.245 262.881);
|
|
||||||
--sidebar-primary-foreground: oklch(0.379 0.146 265.522);
|
|
||||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
|
||||||
--sidebar-ring: oklch(0.488 0.243 264.376);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--animation-duration: 0.6s;
|
--animation-duration: 0.6s;
|
||||||
}
|
}
|
||||||
|
68
web/src/app/styles/blue.css
Normal file
68
web/src/app/styles/blue.css
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
:root {
|
||||||
|
--radius: 0.65rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--primary: oklch(0.623 0.214 259.815);
|
||||||
|
--primary-foreground: oklch(0.97 0.014 254.604);
|
||||||
|
--secondary: oklch(0.967 0.001 286.375);
|
||||||
|
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--muted: oklch(0.967 0.001 286.375);
|
||||||
|
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||||
|
--accent: oklch(0.967 0.001 286.375);
|
||||||
|
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.92 0.004 286.32);
|
||||||
|
--input: oklch(0.92 0.004 286.32);
|
||||||
|
--ring: oklch(0.623 0.214 259.815);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--sidebar-primary: oklch(0.623 0.214 259.815);
|
||||||
|
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
|
||||||
|
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||||
|
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||||
|
--sidebar-ring: oklch(0.623 0.214 259.815);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.141 0.005 285.823);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.21 0.006 285.885);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.21 0.006 285.885);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.546 0.245 262.881);
|
||||||
|
--primary-foreground: oklch(0.379 0.146 265.522);
|
||||||
|
--secondary: oklch(0.274 0.006 286.033);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.274 0.006 286.033);
|
||||||
|
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||||
|
--accent: oklch(0.274 0.006 286.033);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.21 0.006 285.885);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.546 0.245 262.881);
|
||||||
|
--sidebar-primary-foreground: oklch(0.379 0.146 265.522);
|
||||||
|
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||||
|
}
|
68
web/src/app/styles/green.css
Normal file
68
web/src/app/styles/green.css
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
:root {
|
||||||
|
--radius: 0.65rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--primary: oklch(0.723 0.219 149.579);
|
||||||
|
--primary-foreground: oklch(0.982 0.018 155.826);
|
||||||
|
--secondary: oklch(0.967 0.001 286.375);
|
||||||
|
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--muted: oklch(0.967 0.001 286.375);
|
||||||
|
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||||
|
--accent: oklch(0.967 0.001 286.375);
|
||||||
|
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.92 0.004 286.32);
|
||||||
|
--input: oklch(0.92 0.004 286.32);
|
||||||
|
--ring: oklch(0.723 0.219 149.579);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--sidebar-primary: oklch(0.723 0.219 149.579);
|
||||||
|
--sidebar-primary-foreground: oklch(0.982 0.018 155.826);
|
||||||
|
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||||
|
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||||
|
--sidebar-ring: oklch(0.723 0.219 149.579);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.141 0.005 285.823);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.21 0.006 285.885);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.21 0.006 285.885);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.696 0.17 162.48);
|
||||||
|
--primary-foreground: oklch(0.393 0.095 152.535);
|
||||||
|
--secondary: oklch(0.274 0.006 286.033);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.274 0.006 286.033);
|
||||||
|
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||||
|
--accent: oklch(0.274 0.006 286.033);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.527 0.154 150.069);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.21 0.006 285.885);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.696 0.17 162.48);
|
||||||
|
--sidebar-primary-foreground: oklch(0.393 0.095 152.535);
|
||||||
|
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.527 0.154 150.069);
|
||||||
|
}
|
68
web/src/app/styles/orange.css
Normal file
68
web/src/app/styles/orange.css
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
:root {
|
||||||
|
--radius: 0.65rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--primary: oklch(0.705 0.213 47.604);
|
||||||
|
--primary-foreground: oklch(0.98 0.016 73.684);
|
||||||
|
--secondary: oklch(0.967 0.001 286.375);
|
||||||
|
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--muted: oklch(0.967 0.001 286.375);
|
||||||
|
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||||
|
--accent: oklch(0.967 0.001 286.375);
|
||||||
|
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.92 0.004 286.32);
|
||||||
|
--input: oklch(0.92 0.004 286.32);
|
||||||
|
--ring: oklch(0.705 0.213 47.604);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--sidebar-primary: oklch(0.705 0.213 47.604);
|
||||||
|
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
|
||||||
|
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||||
|
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||||
|
--sidebar-ring: oklch(0.705 0.213 47.604);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.141 0.005 285.823);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.21 0.006 285.885);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.21 0.006 285.885);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.646 0.222 41.116);
|
||||||
|
--primary-foreground: oklch(0.98 0.016 73.684);
|
||||||
|
--secondary: oklch(0.274 0.006 286.033);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.274 0.006 286.033);
|
||||||
|
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||||
|
--accent: oklch(0.274 0.006 286.033);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.21 0.006 285.885);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.646 0.222 41.116);
|
||||||
|
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
|
||||||
|
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.646 0.222 41.116);
|
||||||
|
}
|
68
web/src/app/styles/red.css
Normal file
68
web/src/app/styles/red.css
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
:root {
|
||||||
|
--radius: 0.65rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--primary: oklch(0.637 0.237 25.331);
|
||||||
|
--primary-foreground: oklch(0.971 0.013 17.38);
|
||||||
|
--secondary: oklch(0.967 0.001 286.375);
|
||||||
|
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--muted: oklch(0.967 0.001 286.375);
|
||||||
|
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||||
|
--accent: oklch(0.967 0.001 286.375);
|
||||||
|
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.92 0.004 286.32);
|
||||||
|
--input: oklch(0.92 0.004 286.32);
|
||||||
|
--ring: oklch(0.637 0.237 25.331);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--sidebar-primary: oklch(0.637 0.237 25.331);
|
||||||
|
--sidebar-primary-foreground: oklch(0.971 0.013 17.38);
|
||||||
|
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||||
|
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||||
|
--sidebar-ring: oklch(0.637 0.237 25.331);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.141 0.005 285.823);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.21 0.006 285.885);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.21 0.006 285.885);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.637 0.237 25.331);
|
||||||
|
--primary-foreground: oklch(0.971 0.013 17.38);
|
||||||
|
--secondary: oklch(0.274 0.006 286.033);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.274 0.006 286.033);
|
||||||
|
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||||
|
--accent: oklch(0.274 0.006 286.033);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.637 0.237 25.331);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.21 0.006 285.885);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.637 0.237 25.331);
|
||||||
|
--sidebar-primary-foreground: oklch(0.971 0.013 17.38);
|
||||||
|
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.637 0.237 25.331);
|
||||||
|
}
|
68
web/src/app/styles/rose.css
Normal file
68
web/src/app/styles/rose.css
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
:root {
|
||||||
|
--radius: 0.65rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--primary: oklch(0.645 0.246 16.439);
|
||||||
|
--primary-foreground: oklch(0.969 0.015 12.422);
|
||||||
|
--secondary: oklch(0.967 0.001 286.375);
|
||||||
|
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--muted: oklch(0.967 0.001 286.375);
|
||||||
|
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||||
|
--accent: oklch(0.967 0.001 286.375);
|
||||||
|
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.92 0.004 286.32);
|
||||||
|
--input: oklch(0.92 0.004 286.32);
|
||||||
|
--ring: oklch(0.645 0.246 16.439);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--sidebar-primary: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar-primary-foreground: oklch(0.969 0.015 12.422);
|
||||||
|
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||||
|
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||||
|
--sidebar-ring: oklch(0.645 0.246 16.439);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.141 0.005 285.823);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.21 0.006 285.885);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.21 0.006 285.885);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.645 0.246 16.439);
|
||||||
|
--primary-foreground: oklch(0.969 0.015 12.422);
|
||||||
|
--secondary: oklch(0.274 0.006 286.033);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.274 0.006 286.033);
|
||||||
|
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||||
|
--accent: oklch(0.274 0.006 286.033);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.645 0.246 16.439);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.21 0.006 285.885);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar-primary-foreground: oklch(0.969 0.015 12.422);
|
||||||
|
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.645 0.246 16.439);
|
||||||
|
}
|
68
web/src/app/styles/violet.css
Normal file
68
web/src/app/styles/violet.css
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
:root {
|
||||||
|
--radius: 0.65rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--primary: oklch(0.606 0.25 292.717);
|
||||||
|
--primary-foreground: oklch(0.969 0.016 293.756);
|
||||||
|
--secondary: oklch(0.967 0.001 286.375);
|
||||||
|
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--muted: oklch(0.967 0.001 286.375);
|
||||||
|
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||||
|
--accent: oklch(0.967 0.001 286.375);
|
||||||
|
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.92 0.004 286.32);
|
||||||
|
--input: oklch(0.92 0.004 286.32);
|
||||||
|
--ring: oklch(0.606 0.25 292.717);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--sidebar-primary: oklch(0.606 0.25 292.717);
|
||||||
|
--sidebar-primary-foreground: oklch(0.969 0.016 293.756);
|
||||||
|
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||||
|
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||||
|
--sidebar-ring: oklch(0.606 0.25 292.717);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.141 0.005 285.823);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.21 0.006 285.885);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.21 0.006 285.885);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.541 0.281 293.009);
|
||||||
|
--primary-foreground: oklch(0.969 0.016 293.756);
|
||||||
|
--secondary: oklch(0.274 0.006 286.033);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.274 0.006 286.033);
|
||||||
|
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||||
|
--accent: oklch(0.274 0.006 286.033);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.541 0.281 293.009);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.21 0.006 285.885);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.541 0.281 293.009);
|
||||||
|
--sidebar-primary-foreground: oklch(0.969 0.016 293.756);
|
||||||
|
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.541 0.281 293.009);
|
||||||
|
}
|
68
web/src/app/styles/yellow.css
Normal file
68
web/src/app/styles/yellow.css
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
:root {
|
||||||
|
--radius: 0.65rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--primary: oklch(0.795 0.184 86.047);
|
||||||
|
--primary-foreground: oklch(0.421 0.095 57.708);
|
||||||
|
--secondary: oklch(0.967 0.001 286.375);
|
||||||
|
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--muted: oklch(0.967 0.001 286.375);
|
||||||
|
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||||
|
--accent: oklch(0.967 0.001 286.375);
|
||||||
|
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.92 0.004 286.32);
|
||||||
|
--input: oklch(0.92 0.004 286.32);
|
||||||
|
--ring: oklch(0.795 0.184 86.047);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--sidebar-primary: oklch(0.795 0.184 86.047);
|
||||||
|
--sidebar-primary-foreground: oklch(0.421 0.095 57.708);
|
||||||
|
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||||
|
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||||
|
--sidebar-ring: oklch(0.795 0.184 86.047);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.141 0.005 285.823);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.21 0.006 285.885);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.21 0.006 285.885);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.795 0.184 86.047);
|
||||||
|
--primary-foreground: oklch(0.421 0.095 57.708);
|
||||||
|
--secondary: oklch(0.274 0.006 286.033);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.274 0.006 286.033);
|
||||||
|
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||||
|
--accent: oklch(0.274 0.006 286.033);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.554 0.135 66.442);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.21 0.006 285.885);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.795 0.184 86.047);
|
||||||
|
--sidebar-primary-foreground: oklch(0.421 0.095 57.708);
|
||||||
|
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.554 0.135 66.442);
|
||||||
|
}
|
@ -9,6 +9,7 @@ import { cn } from '@/lib/utils'
|
|||||||
import { getPostHref } from '@/utils/common/post'
|
import { getPostHref } from '@/utils/common/post'
|
||||||
import { motion } from 'motion/react'
|
import { motion } from 'motion/react'
|
||||||
import { deceleration } from '@/motion/curve'
|
import { deceleration } from '@/motion/curve'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
|
||||||
|
|
||||||
export function BlogCard({ post, className }: {
|
export function BlogCard({ post, className }: {
|
||||||
@ -158,37 +159,58 @@ export function BlogCard({ post, className }: {
|
|||||||
// 骨架屏加载组件 - 使用 shadcn Card 结构
|
// 骨架屏加载组件 - 使用 shadcn Card 结构
|
||||||
export function BlogCardSkeleton() {
|
export function BlogCardSkeleton() {
|
||||||
return (
|
return (
|
||||||
<Card className="overflow-hidden h-full flex flex-col">
|
<Card className="group overflow-hidden hover:shadow-xl transition-all duration-300 h-full flex flex-col cursor-default pt-0 pb-4">
|
||||||
{/* 封面图片骨架 */}
|
{/* 封面骨架 */}
|
||||||
<div className="aspect-[16/9] bg-muted animate-pulse" />
|
<div className="relative aspect-[16/9] overflow-hidden">
|
||||||
|
<Skeleton className="absolute inset-0" />
|
||||||
|
|
||||||
{/* Header 骨架 */}
|
{/* 覆盖层(模拟暗色遮罩) */}
|
||||||
<CardHeader className="pb-3">
|
<div className="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-transparent" />
|
||||||
<div className="h-6 bg-muted rounded animate-pulse mb-2" />
|
|
||||||
<div className="space-y-2">
|
{/* 私有标识骨架 */}
|
||||||
<div className="h-4 bg-muted rounded animate-pulse" />
|
<div className="absolute top-2 left-2">
|
||||||
<div className="h-4 bg-muted rounded w-3/4 animate-pulse" />
|
<Skeleton className="h-6 w-14 rounded-md" />
|
||||||
<div className="h-4 bg-muted rounded w-1/2 animate-pulse" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 统计信息骨架 */}
|
||||||
|
<div className="absolute bottom-2 left-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="h-3 w-6 rounded" />
|
||||||
|
<Skeleton className="h-3 w-6 rounded" />
|
||||||
|
<Skeleton className="h-3 w-6 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 热度骨架 */}
|
||||||
|
<div className="absolute bottom-2 right-2">
|
||||||
|
<Skeleton className="h-6 w-12 rounded-md" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 标题骨架 */}
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle>
|
||||||
|
<Skeleton className="h-5 w-3/4 rounded" />
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{/* Content 骨架 */}
|
{/* 内容骨架 */}
|
||||||
<CardContent className="flex-1 pb-3">
|
<CardContent className="flex-1">
|
||||||
<div className="flex gap-2 mb-4">
|
<CardDescription>
|
||||||
<div className="h-6 w-16 bg-muted rounded animate-pulse" />
|
<div className="space-y-2">
|
||||||
<div className="h-6 w-20 bg-muted rounded animate-pulse" />
|
<Skeleton className="h-4 rounded" />
|
||||||
<div className="h-6 w-14 bg-muted rounded animate-pulse" />
|
<Skeleton className="h-4 w-5/6 rounded" />
|
||||||
</div>
|
<Skeleton className="h-4 w-2/3 rounded" />
|
||||||
<div className="grid grid-cols-2 gap-4">
|
</div>
|
||||||
<div className="h-4 bg-muted rounded animate-pulse" />
|
</CardDescription>
|
||||||
<div className="h-4 bg-muted rounded animate-pulse" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
{/* Footer 骨架 */}
|
{/* 底部骨架 */}
|
||||||
<CardFooter className="pt-3 border-t">
|
<CardFooter className="pb-0 border-t border-border/50 flex items-center justify-between">
|
||||||
<div className="h-4 w-24 bg-muted rounded animate-pulse" />
|
<div className="flex items-center gap-2">
|
||||||
<div className="h-4 w-20 bg-muted rounded animate-pulse ml-auto" />
|
<Skeleton className="h-4 w-24 rounded" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-4 w-20 rounded" />
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
@ -59,7 +59,7 @@ export function SidebarHotPosts({ posts, sortType }: { posts: Post[], sortType:
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<TrendingUp className="w-5 h-5 text-orange-500" />
|
<TrendingUp className="w-5 h-5 text-orange-500" />
|
||||||
{sortType === 'latest' ? '热门文章' : '最新文章'}
|
{sortType === 'latest' ? '最新文章' : '热门文章'}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
@ -8,7 +8,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { getGravatarUrl } from "@/utils/common/gravatar";
|
import { getGravatarFromUser, getGravatarUrl } from "@/utils/common/gravatar";
|
||||||
import { getFirstCharFromUser } from "@/utils/common/username";
|
import { getFirstCharFromUser } from "@/utils/common/username";
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
|
||||||
@ -68,7 +68,7 @@ export function CommentInput(
|
|||||||
<div className="flex py-4 fade-in">
|
<div className="flex py-4 fade-in">
|
||||||
<div onClick={user ? () => clickToUserProfile(user.username) : clickToLogin} className="cursor-pointer flex-shrink-0 w-10 h-10 fade-in">
|
<div onClick={user ? () => clickToUserProfile(user.username) : clickToLogin} className="cursor-pointer flex-shrink-0 w-10 h-10 fade-in">
|
||||||
{user && <Avatar className="h-full w-full rounded-full">
|
{user && <Avatar className="h-full w-full rounded-full">
|
||||||
<AvatarImage src={getGravatarUrl({ email: user.email, size: 120 })} alt={user.nickname} />
|
<AvatarImage src={getGravatarFromUser({ user, size: 120 })} alt={user.nickname} />
|
||||||
<AvatarFallback className="rounded-full">{getFirstCharFromUser(user)}</AvatarFallback>
|
<AvatarFallback className="rounded-full">{getFirstCharFromUser(user)}</AvatarFallback>
|
||||||
</Avatar>}
|
</Avatar>}
|
||||||
{!user && <CircleUser className="w-full h-full fade-in" />}
|
{!user && <CircleUser className="w-full h-full fade-in" />}
|
||||||
|
@ -13,7 +13,7 @@ import { createComment, deleteComment, getComment, listComments, updateComment }
|
|||||||
import { OrderBy } from "@/models/common";
|
import { OrderBy } from "@/models/common";
|
||||||
import { formatDateTime } from "@/utils/common/datetime";
|
import { formatDateTime } from "@/utils/common/datetime";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { getGravatarUrl } from "@/utils/common/gravatar";
|
import { getGravatarFromUser, getGravatarUrl } from "@/utils/common/gravatar";
|
||||||
import { getFirstCharFromUser } from "@/utils/common/username";
|
import { getFirstCharFromUser } from "@/utils/common/username";
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
|
||||||
@ -35,7 +35,7 @@ export function CommentItem(
|
|||||||
onReplySubmitted: ({ commentContent, isPrivate }: { commentContent: string, isPrivate: boolean }) => void,
|
onReplySubmitted: ({ commentContent, isPrivate }: { commentContent: string, isPrivate: boolean }) => void,
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const {user} = useAuth();
|
const { user } = useAuth();
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const t = useTranslations("Comment");
|
const t = useTranslations("Comment");
|
||||||
const commonT = useTranslations("Common");
|
const commonT = useTranslations("Common");
|
||||||
@ -160,9 +160,9 @@ export function CommentItem(
|
|||||||
<div className="flex">
|
<div className="flex">
|
||||||
<div onClick={() => clickToUserProfile(commentState.user.username)} className="cursor-pointer fade-in w-12 h-12">
|
<div onClick={() => clickToUserProfile(commentState.user.username)} className="cursor-pointer fade-in w-12 h-12">
|
||||||
<Avatar className="h-full w-full rounded-full">
|
<Avatar className="h-full w-full rounded-full">
|
||||||
<AvatarImage src={getGravatarUrl({email: commentState.user.email, size: 120})} alt={commentState.user.nickname} />
|
<AvatarImage src={getGravatarFromUser({ user: commentState.user, size: 120 })} alt={commentState.user.nickname} />
|
||||||
<AvatarFallback className="rounded-full">{getFirstCharFromUser(commentState.user)}</AvatarFallback>
|
<AvatarFallback className="rounded-full">{getFirstCharFromUser(commentState.user)}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 pl-2 fade-in-up">
|
<div className="flex-1 pl-2 fade-in-up">
|
||||||
<div className="flex gap-2 md:gap-4 items-center">
|
<div className="flex gap-2 md:gap-4 items-center">
|
||||||
|
28
web/src/components/common/image-cropper.tsx
Normal file
28
web/src/components/common/image-cropper.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
||||||
|
export function ImageCropper({ image, onCropped, onCancel }: { image: File, onCropped: (blob: Blob) => void, onCancel: () => void }) {
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<form>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline">Edit</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
|
||||||
|
</DialogContent>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
@ -17,37 +17,10 @@ import {
|
|||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
import config from "@/config"
|
import config from "@/config"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Folder, Gauge, MessageCircle, Newspaper, Users } from "lucide-react"
|
import { NavUserCenter } from "./nav-ucenter"
|
||||||
|
import { sidebarData } from "./data"
|
||||||
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
navMain: [
|
|
||||||
{
|
|
||||||
title: "大石坝",
|
|
||||||
url: "/console",
|
|
||||||
icon: Gauge,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "文章管理",
|
|
||||||
url: "/console/post",
|
|
||||||
icon: Newspaper,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "评论管理",
|
|
||||||
url: "/console/comment",
|
|
||||||
icon: MessageCircle,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "文件管理",
|
|
||||||
url: "/console/file",
|
|
||||||
icon: Folder,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "用户管理",
|
|
||||||
url: "/console/user",
|
|
||||||
icon: Users,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
return (
|
return (
|
||||||
@ -68,7 +41,8 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<NavMain items={data.navMain} />
|
<NavMain items={sidebarData.navMain} />
|
||||||
|
<NavUserCenter items={sidebarData.navUserCenter} />
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<NavUser />
|
<NavUser />
|
||||||
|
71
web/src/components/console/data.ts
Normal file
71
web/src/components/console/data.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import type { User } from "@/models/user";
|
||||||
|
import { isAdmin, isEditor } from "@/utils/common/permission";
|
||||||
|
import { Folder, Gauge, MessageCircle, Newspaper, Palette, Settings, ShieldCheck, UserPen, Users } from "lucide-react";
|
||||||
|
|
||||||
|
export interface SidebarItem {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
icon: React.ComponentType<any>;
|
||||||
|
permission: ({ user }: { user: User }) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sidebarData: { navMain: SidebarItem[]; navUserCenter: SidebarItem[] } = {
|
||||||
|
navMain: [
|
||||||
|
{
|
||||||
|
title: "大石坝",
|
||||||
|
url: "/console",
|
||||||
|
icon: Gauge,
|
||||||
|
permission: isAdmin
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "文章管理",
|
||||||
|
url: "/console/post",
|
||||||
|
icon: Newspaper,
|
||||||
|
permission: isEditor
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "评论管理",
|
||||||
|
url: "/console/comment",
|
||||||
|
icon: MessageCircle,
|
||||||
|
permission: isEditor
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "文件管理",
|
||||||
|
url: "/console/file",
|
||||||
|
icon: Folder,
|
||||||
|
permission: () => true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "用户管理",
|
||||||
|
url: "/console/user",
|
||||||
|
icon: Users,
|
||||||
|
permission: isAdmin
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "全局设置",
|
||||||
|
url: "/console/setting",
|
||||||
|
icon: Settings,
|
||||||
|
permission: isAdmin
|
||||||
|
},
|
||||||
|
],
|
||||||
|
navUserCenter: [
|
||||||
|
{
|
||||||
|
title: "个人资料",
|
||||||
|
url: "/console/user-profile",
|
||||||
|
icon: UserPen,
|
||||||
|
permission: () => true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "安全设置",
|
||||||
|
url: "/console/user-security",
|
||||||
|
icon: ShieldCheck,
|
||||||
|
permission: () => true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "个性化",
|
||||||
|
url: "/console/user-preference",
|
||||||
|
icon: Palette,
|
||||||
|
permission: () => true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
@ -1,92 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import {
|
|
||||||
IconDots,
|
|
||||||
IconFolder,
|
|
||||||
IconShare3,
|
|
||||||
IconTrash,
|
|
||||||
type Icon,
|
|
||||||
} from "@tabler/icons-react"
|
|
||||||
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu"
|
|
||||||
import {
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuAction,
|
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem,
|
|
||||||
useSidebar,
|
|
||||||
} from "@/components/ui/sidebar"
|
|
||||||
|
|
||||||
export function NavDocuments({
|
|
||||||
items,
|
|
||||||
}: {
|
|
||||||
items: {
|
|
||||||
name: string
|
|
||||||
url: string
|
|
||||||
icon: Icon
|
|
||||||
}[]
|
|
||||||
}) {
|
|
||||||
const { isMobile } = useSidebar()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
|
||||||
<SidebarGroupLabel>Documents</SidebarGroupLabel>
|
|
||||||
<SidebarMenu>
|
|
||||||
{items.map((item) => (
|
|
||||||
<SidebarMenuItem key={item.name}>
|
|
||||||
<SidebarMenuButton asChild>
|
|
||||||
<a href={item.url}>
|
|
||||||
<item.icon />
|
|
||||||
<span>{item.name}</span>
|
|
||||||
</a>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<SidebarMenuAction
|
|
||||||
showOnHover
|
|
||||||
className="data-[state=open]:bg-accent rounded-sm"
|
|
||||||
>
|
|
||||||
<IconDots />
|
|
||||||
<span className="sr-only">More</span>
|
|
||||||
</SidebarMenuAction>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
className="w-24 rounded-lg"
|
|
||||||
side={isMobile ? "bottom" : "right"}
|
|
||||||
align={isMobile ? "end" : "start"}
|
|
||||||
>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<IconFolder />
|
|
||||||
<span>Open</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<IconShare3 />
|
|
||||||
<span>Share</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem variant="destructive">
|
|
||||||
<IconTrash />
|
|
||||||
<span>Delete</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
))}
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton className="text-sidebar-foreground/70">
|
|
||||||
<IconDots className="text-sidebar-foreground/70" />
|
|
||||||
<span>More</span>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroup>
|
|
||||||
)
|
|
||||||
}
|
|
@ -3,6 +3,7 @@
|
|||||||
import {
|
import {
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupContent,
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
@ -11,6 +12,8 @@ import Link from "next/link"
|
|||||||
import type { LucideProps } from "lucide-react";
|
import type { LucideProps } from "lucide-react";
|
||||||
import { ComponentType, SVGProps } from "react"
|
import { ComponentType, SVGProps } from "react"
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { User } from "@/models/user";
|
||||||
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
|
||||||
export function NavMain({
|
export function NavMain({
|
||||||
items,
|
items,
|
||||||
@ -19,19 +22,23 @@ export function NavMain({
|
|||||||
title: string
|
title: string
|
||||||
url: string
|
url: string
|
||||||
icon?: ComponentType<SVGProps<SVGSVGElement> & LucideProps>;
|
icon?: ComponentType<SVGProps<SVGSVGElement> & LucideProps>;
|
||||||
|
permission: ({ user }: { user: User }) => boolean
|
||||||
}[]
|
}[]
|
||||||
}) {
|
}) {
|
||||||
|
const { user } = useAuth();
|
||||||
const pathname = usePathname() ?? "/"
|
const pathname = usePathname() ?? "/"
|
||||||
console.log("pathname", pathname)
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupContent className="flex flex-col gap-2">
|
<SidebarGroupContent className="flex flex-col gap-2">
|
||||||
|
<SidebarGroupLabel>General</SidebarGroupLabel>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<SidebarMenuItem key={item.title}>
|
item.permission({ user }) && <SidebarMenuItem key={item.title}>
|
||||||
<Link href={item.url}>
|
<Link href={item.url}>
|
||||||
<SidebarMenuButton tooltip={item.title} isActive={pathname===item.url}>
|
<SidebarMenuButton tooltip={item.title} isActive={pathname === item.url}>
|
||||||
{item.icon && <item.icon />}
|
{item.icon && <item.icon />}
|
||||||
<span>{item.title}</span>
|
<span>{item.title}</span>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
|
67
web/src/components/console/nav-ucenter.tsx
Normal file
67
web/src/components/console/nav-ucenter.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
IconDots,
|
||||||
|
IconFolder,
|
||||||
|
IconShare3,
|
||||||
|
IconTrash,
|
||||||
|
type Icon,
|
||||||
|
} from "@tabler/icons-react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import {
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuAction,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
useSidebar,
|
||||||
|
} from "@/components/ui/sidebar"
|
||||||
|
import { ComponentType, SVGProps } from "react"
|
||||||
|
import { LucideProps } from "lucide-react"
|
||||||
|
import { User } from "@/models/user"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { usePathname } from "next/navigation"
|
||||||
|
import { useAuth } from "@/contexts/auth-context"
|
||||||
|
|
||||||
|
export function NavUserCenter({
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
items: {
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
icon?: ComponentType<SVGProps<SVGSVGElement> & LucideProps>;
|
||||||
|
permission: ({ user }: { user: User }) => boolean
|
||||||
|
}[]
|
||||||
|
}) {
|
||||||
|
const { isMobile } = useSidebar()
|
||||||
|
const { user } = useAuth();
|
||||||
|
const pathname = usePathname() ?? "/"
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||||
|
<SidebarGroupLabel>Personal</SidebarGroupLabel>
|
||||||
|
<SidebarMenu>
|
||||||
|
{items.map((item) => (
|
||||||
|
item.permission({ user }) && <SidebarMenuItem key={item.title}>
|
||||||
|
<Link href={item.url}>
|
||||||
|
<SidebarMenuButton tooltip={item.title} isActive={pathname === item.url}>
|
||||||
|
{item.icon && <item.icon />}
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroup>
|
||||||
|
)
|
||||||
|
}
|
@ -28,14 +28,24 @@ import {
|
|||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
import { User } from "@/models/user"
|
|
||||||
import { getGravatarFromUser } from "@/utils/common/gravatar"
|
import { getGravatarFromUser } from "@/utils/common/gravatar"
|
||||||
import { getFallbackAvatarFromUsername } from "@/utils/common/username"
|
import { getFallbackAvatarFromUsername } from "@/utils/common/username"
|
||||||
import { useAuth } from "@/contexts/auth-context"
|
import { useAuth } from "@/contexts/auth-context"
|
||||||
|
import { userLogout } from "@/api/user"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
export function NavUser({}: {}) {
|
export function NavUser({ }: {}) {
|
||||||
const { isMobile } = useSidebar()
|
const { isMobile } = useSidebar()
|
||||||
const {user} = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
userLogout().then(() => {
|
||||||
|
toast.success("Logged out successfully");
|
||||||
|
window.location.reload();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (!user) return null
|
if (!user) return null
|
||||||
return (
|
return (
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
@ -95,7 +105,7 @@ export function NavUser({}: {}) {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem onClick={handleLogout}>
|
||||||
<IconLogout />
|
<IconLogout />
|
||||||
Log out
|
Log out
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
23
web/src/components/console/setting/colors.ts
Normal file
23
web/src/components/console/setting/colors.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
export const colorData = {
|
||||||
|
"red": {
|
||||||
|
"primary": "oklch(0.637 0.237 25.331)",
|
||||||
|
},
|
||||||
|
"rose": {
|
||||||
|
"primary": "oklch(0.645 0.246 16.439)",
|
||||||
|
},
|
||||||
|
"orange": {
|
||||||
|
"primary": "oklch(0.705 0.213 47.604)",
|
||||||
|
},
|
||||||
|
"green": {
|
||||||
|
"primary": "oklch(0.723 0.219 149.579)",
|
||||||
|
},
|
||||||
|
"blue": {
|
||||||
|
"primary": "oklch(0.623 0.214 259.815)",
|
||||||
|
},
|
||||||
|
"yellow": {
|
||||||
|
"primary": "oklch(0.795 0.184 86.047)",
|
||||||
|
},
|
||||||
|
"violet": {
|
||||||
|
"primary": "oklch(0.606 0.25 292.717)",
|
||||||
|
},
|
||||||
|
}
|
18
web/src/components/console/setting/index.tsx
Normal file
18
web/src/components/console/setting/index.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
export default function SettingPage() {
|
||||||
|
return <div>
|
||||||
|
<h2 className="text-2xl font-bold">
|
||||||
|
全局设置
|
||||||
|
</h2>
|
||||||
|
<div className="grid w-full max-w-sm items-center gap-3 mt-4">
|
||||||
|
<Label htmlFor="themeColor">配色方案</Label>
|
||||||
|
<Input type="color" id="themeColor" />
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColorPick() {
|
||||||
|
|
||||||
|
}
|
@ -2,7 +2,7 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { SidebarTrigger } from "@/components/ui/sidebar"
|
import { SidebarTrigger } from "@/components/ui/sidebar"
|
||||||
|
|
||||||
export function SiteHeader() {
|
export function SiteHeader({ title = "Title" }: { title?: string }) {
|
||||||
return (
|
return (
|
||||||
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
|
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
|
||||||
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
|
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
|
||||||
@ -11,7 +11,7 @@ export function SiteHeader() {
|
|||||||
orientation="vertical"
|
orientation="vertical"
|
||||||
className="mx-2 data-[orientation=vertical]:h-4"
|
className="mx-2 data-[orientation=vertical]:h-4"
|
||||||
/>
|
/>
|
||||||
<h1 className="text-base font-medium">Documents</h1>
|
<h1 className="text-base font-medium">{title}</h1>
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
<Button variant="ghost" asChild size="sm" className="hidden sm:flex">
|
<Button variant="ghost" asChild size="sm" className="hidden sm:flex">
|
||||||
<a
|
<a
|
||||||
|
135
web/src/components/console/user-profile/index.tsx
Normal file
135
web/src/components/console/user-profile/index.tsx
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
"use client"
|
||||||
|
import { uploadFile } from "@/api/file";
|
||||||
|
import { updateUser } from "@/api/user";
|
||||||
|
import { ImageCropper } from "@/components/common/image-cropper";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
import { getFileUri } from "@/utils/client/file";
|
||||||
|
import { getGravatarFromUser } from "@/utils/common/gravatar";
|
||||||
|
import { getFallbackAvatarFromUsername } from "@/utils/common/username";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface UploadConstraints {
|
||||||
|
allowedTypes: string[];
|
||||||
|
maxSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PictureInputChangeEvent {
|
||||||
|
target: HTMLInputElement & { files?: FileList | null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserProfilePage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
|
const [nickname, setNickname] = useState(user.nickname || '')
|
||||||
|
const [username, setUsername] = useState(user.username || '')
|
||||||
|
const [avatarUrl, setAvatarUrl] = useState(user.avatarUrl || '')
|
||||||
|
const [avatarFile, setAvatarFile] = useState<File | null>(null)
|
||||||
|
const [gender, setGender] = useState(user.gender || '')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// if (!avatarFile) return
|
||||||
|
// uploadFile({ file: avatarFile! }).then(res => {
|
||||||
|
// setAvatarUrl(getFileUri(res.data.id))
|
||||||
|
// toast.success('Avatar uploaded successfully')
|
||||||
|
// }).catch(err => {
|
||||||
|
// console.log(err)
|
||||||
|
// toast.error(`Error: ${err?.response?.data?.message || err.message || 'Failed to upload avatar'}`)
|
||||||
|
// })
|
||||||
|
}, [avatarFile])
|
||||||
|
|
||||||
|
const handlePictureSelected = (e: PictureInputChangeEvent): void => {
|
||||||
|
const file: File | null = e.target.files?.[0] ?? null;
|
||||||
|
if (!file) {
|
||||||
|
setAvatarFile(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const constraints: UploadConstraints = {
|
||||||
|
allowedTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
|
||||||
|
maxSize: 5 * 1024 * 1024, // 5 MB
|
||||||
|
};
|
||||||
|
if (!file.type || !file.type.startsWith('image/') || !constraints.allowedTypes.includes(file.type)) {
|
||||||
|
setAvatarFile(null);
|
||||||
|
toast.error('只允许上传 PNG / JPEG / WEBP / GIF 格式的图片');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > constraints.maxSize) {
|
||||||
|
setAvatarFile(null);
|
||||||
|
toast.error('图片大小不能超过 5MB');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAvatarFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (nickname.trim() === '' || username.trim() === '') {
|
||||||
|
toast.error('Nickname and Username cannot be empty')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ((username.length < 3 || username.length > 20) || (nickname.length < 1 || nickname.length > 20)) {
|
||||||
|
toast.error('Nickname and Username must be between 3 and 20 characters')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (username === user.username && nickname === user.nickname && avatarUrl === user.avatarUrl && gender === user.gender) {
|
||||||
|
toast.warning('No changes made')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateUser({ nickname, username, avatarUrl, gender, id: user.id }).then(res => {
|
||||||
|
toast.success('Profile updated successfully')
|
||||||
|
window.location.reload()
|
||||||
|
}).catch(err => {
|
||||||
|
console.log(err)
|
||||||
|
toast.error(`Error: ${err?.response.data?.message || err.message || 'Failed to update profile'}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">
|
||||||
|
Public Profile
|
||||||
|
</h1>
|
||||||
|
<Separator className="my-2" />
|
||||||
|
<div className="grid w-full max-w-sm items-center gap-3">
|
||||||
|
<Label htmlFor="picture">Picture</Label>
|
||||||
|
<Avatar className="h-40 w-40 rounded-xl border-2">
|
||||||
|
{!avatarFile && <AvatarImage src={getGravatarFromUser({ user })} alt={user.username} />}
|
||||||
|
{avatarFile && <AvatarImage src={URL.createObjectURL(avatarFile)} alt={user.username} />}
|
||||||
|
<AvatarFallback>{getFallbackAvatarFromUsername(nickname || username)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<div className="flex gap-3"><Input
|
||||||
|
id="picture"
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/gif,image/*"
|
||||||
|
onChange={handlePictureSelected}
|
||||||
|
/>
|
||||||
|
<ImageCropper />
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
id="picture-url"
|
||||||
|
type="url"
|
||||||
|
value={avatarUrl}
|
||||||
|
onChange={(e) => setAvatarUrl(e.target.value)}
|
||||||
|
placeholder="若要用外链图像,请直接填写,不支持裁剪"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="nickname">Nickname</Label>
|
||||||
|
<Input type="nickname" id="nickname" value={nickname} onChange={(e) => setNickname(e.target.value)} />
|
||||||
|
<Label htmlFor="username">Username</Label>
|
||||||
|
<Input type="username" id="username" value={username} onChange={(e) => setUsername(e.target.value)} />
|
||||||
|
<Label htmlFor="gender">Gender</Label>
|
||||||
|
<Input type="gender" id="gender" value={gender} onChange={(e) => setGender(e.target.value)} />
|
||||||
|
<Button className="max-w-1/3" onClick={handleSubmit}>Submit</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PictureEditor({}){
|
||||||
|
|
||||||
|
}
|
84
web/src/components/console/user-security/index.tsx
Normal file
84
web/src/components/console/user-security/index.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
"use client"
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import {
|
||||||
|
InputOTP,
|
||||||
|
InputOTPGroup,
|
||||||
|
InputOTPSlot,
|
||||||
|
} from "@/components/ui/input-otp"
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const VERIFY_CODE_COOL_DOWN = 60; // seconds
|
||||||
|
|
||||||
|
export function UserSecurityPage() {
|
||||||
|
const [email, setEmail] = useState("")
|
||||||
|
const [verifyCode, setVerifyCode] = useState("")
|
||||||
|
const [oldPassword, setOldPassword] = useState("")
|
||||||
|
const [newPassword, setNewPassword] = useState("")
|
||||||
|
const handleSubmitPassword = () => {
|
||||||
|
|
||||||
|
}
|
||||||
|
const handleSendVerifyCode = () => {
|
||||||
|
console.log("send verify code to ", email)
|
||||||
|
}
|
||||||
|
const handleSubmitEmail = () => {
|
||||||
|
console.log("submit email ", email, verifyCode)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="grid w-full max-w-sm items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold">
|
||||||
|
密码设置
|
||||||
|
</h1>
|
||||||
|
<Label htmlFor="password">Old Password</Label>
|
||||||
|
<Input id="password" type="password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} />
|
||||||
|
<Label htmlFor="password">New Password</Label>
|
||||||
|
<Input id="password" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
|
||||||
|
<Button className="max-w-1/3 border-2" onClick={handleSubmitPassword}>Submit</Button>
|
||||||
|
</div>
|
||||||
|
<Separator className="my-4" />
|
||||||
|
<div className="grid w-full max-w-sm items-center gap-3 py-4">
|
||||||
|
<h1 className="text-2xl font-bold">
|
||||||
|
邮箱设置
|
||||||
|
</h1>
|
||||||
|
<Label htmlFor="email">email</Label>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||||
|
<Button variant="outline" className="border-2" onClick={handleSendVerifyCode}>发送验证码</Button>
|
||||||
|
</div>
|
||||||
|
<Label htmlFor="verify-code">verify code</Label>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<InputOTPControlled onChange={(value) => setVerifyCode(value)} />
|
||||||
|
<Button className="border-2" onClick={handleSubmitEmail}>Submit</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPControlled({ onChange }: { onChange: (value: string) => void }) {
|
||||||
|
const [value, setValue] = useState("")
|
||||||
|
useEffect(() => {
|
||||||
|
onChange(value)
|
||||||
|
}, [value, onChange])
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<InputOTP
|
||||||
|
maxLength={6}
|
||||||
|
value={value}
|
||||||
|
onChange={(value) => setValue(value)}
|
||||||
|
>
|
||||||
|
<InputOTPGroup>
|
||||||
|
<InputOTPSlot index={0} />
|
||||||
|
<InputOTPSlot index={1} />
|
||||||
|
<InputOTPSlot index={2} />
|
||||||
|
<InputOTPSlot index={3} />
|
||||||
|
<InputOTPSlot index={4} />
|
||||||
|
<InputOTPSlot index={5} />
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -36,7 +36,7 @@ export function AvatarWithDropdownMenu() {
|
|||||||
{user ? <Avatar className="h-8 w-8 rounded-full">
|
{user ? <Avatar className="h-8 w-8 rounded-full">
|
||||||
<AvatarImage src={getGravatarFromUser({ user })} alt={user.username} />
|
<AvatarImage src={getGravatarFromUser({ user })} alt={user.username} />
|
||||||
<AvatarFallback className="rounded-full">{getFallbackAvatarFromUsername(user.nickname || user.username)}</AvatarFallback>
|
<AvatarFallback className="rounded-full">{getFallbackAvatarFromUsername(user.nickname || user.username)}</AvatarFallback>
|
||||||
</Avatar> : <CircleUser className="w-9 h-9" />}
|
</Avatar> : <CircleUser className="h-8 w-8" />}
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="w-56" align="start">
|
<DropdownMenuContent className="w-56" align="start">
|
||||||
|
@ -21,12 +21,14 @@ import { useTranslations } from "next-intl"
|
|||||||
import Captcha from "../common/captcha"
|
import Captcha from "../common/captcha"
|
||||||
import { CaptchaProvider } from "@/models/captcha"
|
import { CaptchaProvider } from "@/models/captcha"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { useAuth } from "@/contexts/auth-context"
|
||||||
|
|
||||||
export function LoginForm({
|
export function LoginForm({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div">) {
|
}: React.ComponentProps<"div">) {
|
||||||
const t = useTranslations('Login')
|
const t = useTranslations('Login')
|
||||||
|
const {user, setUser} = useAuth();
|
||||||
const [oidcConfigs, setOidcConfigs] = useState<OidcConfig[]>([])
|
const [oidcConfigs, setOidcConfigs] = useState<OidcConfig[]>([])
|
||||||
const [captchaProps, setCaptchaProps] = useState<{
|
const [captchaProps, setCaptchaProps] = useState<{
|
||||||
provider: CaptchaProvider
|
provider: CaptchaProvider
|
||||||
@ -41,14 +43,20 @@ export function LoginForm({
|
|||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const redirectBack = searchParams.get("redirect_back") || "/"
|
const redirectBack = searchParams.get("redirect_back") || "/"
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
router.push(redirectBack);
|
||||||
|
}
|
||||||
|
}, [user, router, redirectBack]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ListOidcConfigs()
|
ListOidcConfigs()
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setOidcConfigs(res.data || []) // 确保是数组
|
setOidcConfigs(res.data || [])
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
toast.error(t("fetch_oidc_configs_failed") + (error?.message ? `: ${error.message}` : ""))
|
toast.error(t("fetch_oidc_configs_failed") + (error?.message ? `: ${error.message}` : ""))
|
||||||
setOidcConfigs([]) // 错误时设置为空数组
|
setOidcConfigs([])
|
||||||
})
|
})
|
||||||
}, [t])
|
}, [t])
|
||||||
|
|
||||||
@ -69,6 +77,7 @@ export function LoginForm({
|
|||||||
userLogin({ username, password, captcha: captchaToken || "" })
|
userLogin({ username, password, captcha: captchaToken || "" })
|
||||||
.then(res => {
|
.then(res => {
|
||||||
toast.success(t("login_success") + ` ${res.data.user.nickname || res.data.user.username}`);
|
toast.success(t("login_success") + ` ${res.data.user.nickname || res.data.user.username}`);
|
||||||
|
setUser(res.data.user);
|
||||||
router.push(redirectBack)
|
router.push(redirectBack)
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
|
143
web/src/components/ui/dialog.tsx
Normal file
143
web/src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
77
web/src/components/ui/input-otp.tsx
Normal file
77
web/src/components/ui/input-otp.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { OTPInput, OTPInputContext } from "input-otp"
|
||||||
|
import { MinusIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function InputOTP({
|
||||||
|
className,
|
||||||
|
containerClassName,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof OTPInput> & {
|
||||||
|
containerClassName?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<OTPInput
|
||||||
|
data-slot="input-otp"
|
||||||
|
containerClassName={cn(
|
||||||
|
"flex items-center gap-2 has-disabled:opacity-50",
|
||||||
|
containerClassName
|
||||||
|
)}
|
||||||
|
className={cn("disabled:cursor-not-allowed", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-otp-group"
|
||||||
|
className={cn("flex items-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPSlot({
|
||||||
|
index,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
index: number
|
||||||
|
}) {
|
||||||
|
const inputOTPContext = React.useContext(OTPInputContext)
|
||||||
|
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-otp-slot"
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{char}
|
||||||
|
{hasFakeCaret && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||||
|
<MinusIcon />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
@ -2,7 +2,7 @@
|
|||||||
import { User } from "@/models/user";
|
import { User } from "@/models/user";
|
||||||
import { Mail, User as UserIcon, Shield } from 'lucide-react';
|
import { Mail, User as UserIcon, Shield } from 'lucide-react';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
|
||||||
import { getGravatarUrl } from "@/utils/common/gravatar";
|
import { getGravatarFromUser } from "@/utils/common/gravatar";
|
||||||
import { getFirstCharFromUser } from "@/utils/common/username";
|
import { getFirstCharFromUser } from "@/utils/common/username";
|
||||||
|
|
||||||
export function UserHeader({ user }: { user: User }) {
|
export function UserHeader({ user }: { user: User }) {
|
||||||
@ -13,7 +13,7 @@ export function UserHeader({ user }: { user: User }) {
|
|||||||
{/* wrapper 控制显示大小,父组件给具体 w/h */}
|
{/* wrapper 控制显示大小,父组件给具体 w/h */}
|
||||||
<div className="w-40 h-40 md:w-48 md:h-48 relative">
|
<div className="w-40 h-40 md:w-48 md:h-48 relative">
|
||||||
<Avatar className="h-full w-full rounded-full">
|
<Avatar className="h-full w-full rounded-full">
|
||||||
<AvatarImage src={getGravatarUrl({ email: user.email, size: 120 })} alt={user.nickname} />
|
<AvatarImage src={getGravatarFromUser({user})} alt={user.nickname} />
|
||||||
<AvatarFallback className="rounded-full">{getFirstCharFromUser(user)}</AvatarFallback>
|
<AvatarFallback className="rounded-full">{getFirstCharFromUser(user)}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { createContext, useContext, useState, useMemo } from "react";
|
import React, { createContext, useContext, useState, useMemo, useEffect } from "react";
|
||||||
import type { User } from "@/models/user";
|
import type { User } from "@/models/user";
|
||||||
import { userLogout } from "@/api/user";
|
import { getLoginUser, userLogout } from "@/api/user";
|
||||||
|
|
||||||
type AuthContextValue = {
|
type AuthContextValue = {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
@ -21,6 +21,16 @@ export function AuthProvider({
|
|||||||
}) {
|
}) {
|
||||||
const [user, setUser] = useState<User | null>(initialUser);
|
const [user, setUser] = useState<User | null>(initialUser);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user){
|
||||||
|
getLoginUser().then(res => {
|
||||||
|
setUser(res.data);
|
||||||
|
}).catch(() => {
|
||||||
|
setUser(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
await userLogout();
|
await userLogout();
|
||||||
|
@ -9,6 +9,11 @@ export interface User {
|
|||||||
language: string;
|
language: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum Role {
|
||||||
|
ADMIN = "admin",
|
||||||
|
USER = "user",
|
||||||
|
EDITOR = "editor",
|
||||||
|
}
|
||||||
|
|
||||||
export interface RegisterRequest {
|
export interface RegisterRequest {
|
||||||
username: string
|
username: string
|
||||||
|
3
web/src/utils/client/file.ts
Normal file
3
web/src/utils/client/file.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export function getFileUri(id: number){
|
||||||
|
return `/api/v1/file/f/${id}`
|
||||||
|
}
|
9
web/src/utils/common/permission.ts
Normal file
9
web/src/utils/common/permission.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Role, User } from "@/models/user";
|
||||||
|
|
||||||
|
export function isAdmin({ user }: { user: User}) {
|
||||||
|
return user.role === Role.ADMIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEditor({ user }: { user: User}) {
|
||||||
|
return user.role === Role.EDITOR || user.role === Role.ADMIN;
|
||||||
|
}
|
0
web/src/utils/server/file.ts
Normal file
0
web/src/utils/server/file.ts
Normal file
Reference in New Issue
Block a user