mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-25 18:46:23 +00:00
feat: enhance post management with pagination, search, and order functionality
All checks were successful
Push to Helm Chart Repository / build (push) Successful in 11s
All checks were successful
Push to Helm Chart Repository / build (push) Successful in 11s
- Added search input for filtering posts by keywords. - Implemented pagination controls for navigating through posts. - Introduced order selector for sorting posts based on various criteria. - Enhanced post item display with additional metrics (view count, like count, comment count). - Added dropdown menu for post actions (edit, view, toggle privacy, delete). - Integrated double confirmation for delete action. - Updated user profile to support background image upload. - Improved user security settings with better layout and validation. - Refactored auth context to use useCallback for logout function. - Added command palette component for improved command execution. - Introduced popover component for better UI interactions. - Implemented debounce hooks for optimized state updates. - Updated localization files with new keys for improved internationalization. - Added tailwind configuration for styling.
This commit is contained in:
@ -132,6 +132,12 @@ pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### 前端规范
|
||||
|
||||
表单元素使用`grid gap-4`作为容器,表单项使用`grid gap-2`作为容器
|
||||
|
||||
flex布局横向使用`flex gap-3`作为容器
|
||||
|
||||
### 联合调试
|
||||
|
||||
默认情况下,本机启动后端和前端服务器无须额外配置即可互联,若后端在不同的主机上,需要在.env.development(自己创建)中配置`BACKEND_URL`变量
|
||||
|
@ -1,5 +1,7 @@
|
||||
package main
|
||||
package v1
|
||||
|
||||
func main() {
|
||||
type ConfigController struct{}
|
||||
|
||||
func NewConfigController() *ConfigController {
|
||||
return &ConfigController{}
|
||||
}
|
||||
|
@ -1,100 +1,102 @@
|
||||
package dto
|
||||
|
||||
type UserDto struct {
|
||||
ID uint `json:"id"` // 用户ID
|
||||
Username string `json:"username"` // 用户名
|
||||
Nickname string `json:"nickname"`
|
||||
AvatarUrl string `json:"avatar_url"` // 头像URL
|
||||
Email string `json:"email"` // 邮箱
|
||||
Gender string `json:"gender"`
|
||||
Role string `json:"role"`
|
||||
Language string `json:"language"` // 语言
|
||||
ID uint `json:"id"` // 用户ID
|
||||
Username string `json:"username"` // 用户名
|
||||
Nickname string `json:"nickname"`
|
||||
AvatarUrl string `json:"avatar_url"` // 头像URL
|
||||
BackgroundUrl string `json:"background_url"`
|
||||
Email string `json:"email"` // 邮箱
|
||||
Gender string `json:"gender"`
|
||||
Role string `json:"role"`
|
||||
Language string `json:"language"` // 语言
|
||||
}
|
||||
|
||||
type UserOidcConfigDto struct {
|
||||
Name string `json:"name"` // OIDC配置名称
|
||||
DisplayName string `json:"display_name"` // OIDC配置显示名称
|
||||
Icon string `json:"icon"` // OIDC配置图标URL
|
||||
LoginUrl string `json:"login_url"` // OIDC登录URL
|
||||
Name string `json:"name"` // OIDC配置名称
|
||||
DisplayName string `json:"display_name"` // OIDC配置显示名称
|
||||
Icon string `json:"icon"` // OIDC配置图标URL
|
||||
LoginUrl string `json:"login_url"` // OIDC登录URL
|
||||
}
|
||||
type UserLoginReq struct {
|
||||
Username string `json:"username"` // username or email
|
||||
Password string `json:"password"`
|
||||
Username string `json:"username"` // username or email
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type UserLoginResp struct {
|
||||
Token string `json:"token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
User UserDto `json:"user"`
|
||||
Token string `json:"token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
User UserDto `json:"user"`
|
||||
}
|
||||
|
||||
type UserRegisterReq struct {
|
||||
Username string `json:"username"` // 用户名
|
||||
Nickname string `json:"nickname"` // 昵称
|
||||
Password string `json:"password"` // 密码
|
||||
Email string `json:"-" binding:"-"`
|
||||
Username string `json:"username"` // 用户名
|
||||
Nickname string `json:"nickname"` // 昵称
|
||||
Password string `json:"password"` // 密码
|
||||
Email string `json:"-" binding:"-"`
|
||||
}
|
||||
|
||||
type UserRegisterResp struct {
|
||||
Token string `json:"token"` // 访问令牌
|
||||
RefreshToken string `json:"refresh_token"` // 刷新令牌
|
||||
User UserDto `json:"user"` // 用户信息
|
||||
Token string `json:"token"` // 访问令牌
|
||||
RefreshToken string `json:"refresh_token"` // 刷新令牌
|
||||
User UserDto `json:"user"` // 用户信息
|
||||
}
|
||||
|
||||
type VerifyEmailReq struct {
|
||||
Email string `json:"email"` // 邮箱地址
|
||||
Email string `json:"email"` // 邮箱地址
|
||||
}
|
||||
|
||||
type VerifyEmailResp struct {
|
||||
Success bool `json:"success"` // 验证码发送成功与否
|
||||
Success bool `json:"success"` // 验证码发送成功与否
|
||||
}
|
||||
|
||||
type OidcLoginReq struct {
|
||||
Name string `json:"name"` // OIDC配置名称
|
||||
Code string `json:"code"` // OIDC授权码
|
||||
State string `json:"state"`
|
||||
Name string `json:"name"` // OIDC配置名称
|
||||
Code string `json:"code"` // OIDC授权码
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
type OidcLoginResp struct {
|
||||
Token string `json:"token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
User UserDto `json:"user"`
|
||||
Token string `json:"token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
User UserDto `json:"user"`
|
||||
}
|
||||
|
||||
type ListOidcConfigResp struct {
|
||||
OidcConfigs []UserOidcConfigDto `json:"oidc_configs"` // OIDC配置列表
|
||||
OidcConfigs []UserOidcConfigDto `json:"oidc_configs"` // OIDC配置列表
|
||||
}
|
||||
|
||||
type GetUserReq struct {
|
||||
UserID uint `json:"user_id"`
|
||||
UserID uint `json:"user_id"`
|
||||
}
|
||||
|
||||
type GetUserByUsernameReq struct {
|
||||
Username string `json:"username"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type GetUserResp struct {
|
||||
User UserDto `json:"user"` // 用户信息
|
||||
User UserDto `json:"user"` // 用户信息
|
||||
}
|
||||
|
||||
type UpdateUserReq struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
AvatarUrl string `json:"avatar_url"`
|
||||
Gender string `json:"gender"`
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
AvatarUrl string `json:"avatar_url"`
|
||||
BackgroundUrl string `json:"background_url"`
|
||||
Gender string `json:"gender"`
|
||||
}
|
||||
|
||||
type UpdateUserResp struct {
|
||||
User *UserDto `json:"user"` // 更新后的用户信息
|
||||
User *UserDto `json:"user"` // 更新后的用户信息
|
||||
}
|
||||
|
||||
type UpdatePasswordReq struct {
|
||||
OldPassword string `json:"old_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
OldPassword string `json:"old_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
type ResetPasswordReq struct {
|
||||
Email string `json:"-" binding:"-"`
|
||||
NewPassword string `json:"new_password"`
|
||||
Email string `json:"-" binding:"-"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
@ -7,14 +7,15 @@ import (
|
||||
|
||||
type User struct {
|
||||
gorm.Model
|
||||
Username string `gorm:"uniqueIndex;not null"` // 用户名,唯一
|
||||
Nickname string `gorm:"default:''"` // 昵称
|
||||
AvatarUrl string
|
||||
Email string `gorm:"uniqueIndex"`
|
||||
Gender string `gorm:"default:''"`
|
||||
Role string `gorm:"default:'user'"` // user editor admin
|
||||
Language string `gorm:"default:'en'"`
|
||||
Password string // 密码,存储加密后的值
|
||||
Username string `gorm:"uniqueIndex;not null"` // 用户名,唯一
|
||||
Nickname string `gorm:"default:''"` // 昵称
|
||||
AvatarUrl string
|
||||
BackgroundUrl string
|
||||
Email string `gorm:"uniqueIndex"`
|
||||
Gender string `gorm:"default:''"`
|
||||
Role string `gorm:"default:'user'"` // user editor admin
|
||||
Language string `gorm:"default:'en'"`
|
||||
Password string // 密码,存储加密后的值
|
||||
}
|
||||
|
||||
type UserOpenID struct {
|
||||
@ -27,13 +28,14 @@ type UserOpenID struct {
|
||||
|
||||
func (user *User) ToDto() dto.UserDto {
|
||||
return dto.UserDto{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
AvatarUrl: user.AvatarUrl,
|
||||
Email: user.Email,
|
||||
Gender: user.Gender,
|
||||
Role: user.Role,
|
||||
Language: user.Language,
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
AvatarUrl: user.AvatarUrl,
|
||||
BackgroundUrl: user.BackgroundUrl,
|
||||
Email: user.Email,
|
||||
Gender: user.Gender,
|
||||
Role: user.Role,
|
||||
Language: user.Language,
|
||||
}
|
||||
}
|
||||
|
@ -1 +1,22 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"github.com/cloudwego/hertz/pkg/route"
|
||||
"github.com/snowykami/neo-blog/internal/controller/v1"
|
||||
"github.com/snowykami/neo-blog/internal/middleware"
|
||||
"github.com/snowykami/neo-blog/pkg/constant"
|
||||
)
|
||||
|
||||
func registerConfigRoutes(group *route.RouterGroup) {
|
||||
// Need Admin Middleware
|
||||
adminController := v1.NewAdminController()
|
||||
consoleGroup := group.Group("/admin").Use(middleware.UseAuth(true)).Use(middleware.UseRole(constant.RoleAdmin))
|
||||
{
|
||||
consoleGroup.POST("/oidc/o", adminController.CreateOidc)
|
||||
consoleGroup.DELETE("/oidc/o/:id", adminController.DeleteOidc)
|
||||
consoleGroup.GET("/oidc/o/:id", adminController.GetOidcByID)
|
||||
consoleGroup.GET("/oidc/list", adminController.ListOidc)
|
||||
consoleGroup.PUT("/oidc/o/:id", adminController.UpdateOidc)
|
||||
consoleGroup.GET("/dashboard", adminController.GetDashboard)
|
||||
}
|
||||
}
|
||||
|
@ -1,23 +1,27 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"github.com/cloudwego/hertz/pkg/route"
|
||||
v1 "github.com/snowykami/neo-blog/internal/controller/v1"
|
||||
"github.com/snowykami/neo-blog/internal/middleware"
|
||||
"github.com/snowykami/neo-blog/pkg/constant"
|
||||
"github.com/cloudwego/hertz/pkg/route"
|
||||
v1 "github.com/snowykami/neo-blog/internal/controller/v1"
|
||||
"github.com/snowykami/neo-blog/internal/middleware"
|
||||
"github.com/snowykami/neo-blog/pkg/constant"
|
||||
)
|
||||
|
||||
// post 文章API路由
|
||||
|
||||
func registerPostRoutes(group *route.RouterGroup) {
|
||||
postController := v1.NewPostController()
|
||||
postGroup := group.Group("/post").Use(middleware.UseAuth(true)).Use(middleware.UseRole(constant.RoleEditor))
|
||||
postGroupWithoutAuth := group.Group("/post").Use(middleware.UseAuth(false))
|
||||
{
|
||||
postGroupWithoutAuth.GET("/p/:id", postController.Get)
|
||||
postGroupWithoutAuth.GET("/list", postController.List)
|
||||
postGroup.POST("/p", postController.Create)
|
||||
postGroup.PUT("/p/:id", postController.Update)
|
||||
postGroup.DELETE("/p/:id", postController.Delete)
|
||||
}
|
||||
postController := v1.NewPostController()
|
||||
postGroup := group.Group("/post").Use(middleware.UseAuth(true)).Use(middleware.UseRole(constant.RoleEditor))
|
||||
postGroupWithoutAuth := group.Group("/post").Use(middleware.UseAuth(false))
|
||||
{
|
||||
postGroupWithoutAuth.GET("/p/:id", postController.Get)
|
||||
postGroupWithoutAuth.GET("/list", postController.List)
|
||||
postGroup.POST("/p", postController.Create)
|
||||
postGroup.PUT("/p/:id", postController.Update)
|
||||
postGroup.DELETE("/p/:id", postController.Delete)
|
||||
// draft
|
||||
postGroup.POST("/d")
|
||||
postGroup.GET("/d/:id")
|
||||
postGroup.DELETE("/d/:id")
|
||||
}
|
||||
}
|
||||
|
@ -1,110 +1,110 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/snowykami/neo-blog/internal/dto"
|
||||
"github.com/snowykami/neo-blog/internal/model"
|
||||
"github.com/snowykami/neo-blog/internal/repo"
|
||||
"github.com/snowykami/neo-blog/pkg/errs"
|
||||
"gorm.io/gorm"
|
||||
"github.com/snowykami/neo-blog/internal/dto"
|
||||
"github.com/snowykami/neo-blog/internal/model"
|
||||
"github.com/snowykami/neo-blog/internal/repo"
|
||||
"github.com/snowykami/neo-blog/pkg/errs"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AdminService struct{}
|
||||
|
||||
func NewAdminService() *AdminService {
|
||||
return &AdminService{}
|
||||
return &AdminService{}
|
||||
}
|
||||
|
||||
func (c *AdminService) GetDashboard() (map[string]any, error) {
|
||||
var (
|
||||
postCount, commentCount, userCount, viewCount int64
|
||||
err error
|
||||
mustCount = func(q *gorm.DB, dest *int64) {
|
||||
if err == nil {
|
||||
err = q.Count(dest).Error
|
||||
}
|
||||
}
|
||||
mustScan = func(q *gorm.DB, dest *int64) {
|
||||
if err == nil {
|
||||
err = q.Scan(dest).Error
|
||||
}
|
||||
}
|
||||
)
|
||||
db := repo.GetDB()
|
||||
var (
|
||||
postCount, commentCount, userCount, viewCount int64
|
||||
err error
|
||||
mustCount = func(q *gorm.DB, dest *int64) {
|
||||
if err == nil {
|
||||
err = q.Count(dest).Error
|
||||
}
|
||||
}
|
||||
mustScan = func(q *gorm.DB, dest *int64) {
|
||||
if err == nil {
|
||||
err = q.Scan(dest).Error
|
||||
}
|
||||
}
|
||||
)
|
||||
db := repo.GetDB()
|
||||
|
||||
mustCount(db.Model(&model.Comment{}), &commentCount)
|
||||
mustCount(db.Model(&model.Post{}), &postCount)
|
||||
mustCount(db.Model(&model.User{}), &userCount)
|
||||
mustScan(db.Model(&model.Post{}).Select("SUM(view_count)"), &viewCount)
|
||||
mustCount(db.Model(&model.Comment{}), &commentCount)
|
||||
mustCount(db.Model(&model.Post{}), &postCount)
|
||||
mustCount(db.Model(&model.User{}), &userCount)
|
||||
mustScan(db.Model(&model.Post{}).Select("SUM(view_count)"), &viewCount)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"total_comments": commentCount,
|
||||
"total_posts": postCount,
|
||||
"total_users": userCount,
|
||||
"total_views": viewCount,
|
||||
}, nil
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"total_comments": commentCount,
|
||||
"total_posts": postCount,
|
||||
"total_users": userCount,
|
||||
"total_views": viewCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *AdminService) CreateOidcConfig(req *dto.AdminOidcConfigDto) error {
|
||||
oidcConfig := &model.OidcConfig{
|
||||
Name: req.Name,
|
||||
DisplayName: req.DisplayName,
|
||||
Icon: req.Icon,
|
||||
ClientID: req.ClientID,
|
||||
ClientSecret: req.ClientSecret,
|
||||
OidcDiscoveryUrl: req.OidcDiscoveryUrl,
|
||||
Enabled: req.Enabled,
|
||||
Type: req.Type,
|
||||
}
|
||||
return repo.Oidc.CreateOidcConfig(oidcConfig)
|
||||
oidcConfig := &model.OidcConfig{
|
||||
Name: req.Name,
|
||||
DisplayName: req.DisplayName,
|
||||
Icon: req.Icon,
|
||||
ClientID: req.ClientID,
|
||||
ClientSecret: req.ClientSecret,
|
||||
OidcDiscoveryUrl: req.OidcDiscoveryUrl,
|
||||
Enabled: req.Enabled,
|
||||
Type: req.Type,
|
||||
}
|
||||
return repo.Oidc.CreateOidcConfig(oidcConfig)
|
||||
}
|
||||
|
||||
func (c *AdminService) DeleteOidcConfig(id string) error {
|
||||
if id == "" {
|
||||
return errs.ErrBadRequest
|
||||
}
|
||||
return repo.Oidc.DeleteOidcConfig(id)
|
||||
if id == "" {
|
||||
return errs.ErrBadRequest
|
||||
}
|
||||
return repo.Oidc.DeleteOidcConfig(id)
|
||||
}
|
||||
|
||||
func (c *AdminService) GetOidcConfigByID(id string) (*dto.AdminOidcConfigDto, error) {
|
||||
if id == "" {
|
||||
return nil, errs.ErrBadRequest
|
||||
}
|
||||
config, err := repo.Oidc.GetOidcConfigByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return config.ToAdminDto(), nil
|
||||
if id == "" {
|
||||
return nil, errs.ErrBadRequest
|
||||
}
|
||||
config, err := repo.Oidc.GetOidcConfigByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return config.ToAdminDto(), nil
|
||||
}
|
||||
|
||||
func (c *AdminService) ListOidcConfigs(onlyEnabled bool) ([]*dto.AdminOidcConfigDto, error) {
|
||||
configs, err := repo.Oidc.ListOidcConfigs(onlyEnabled)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var dtos []*dto.AdminOidcConfigDto
|
||||
for _, config := range configs {
|
||||
dtos = append(dtos, config.ToAdminDto())
|
||||
}
|
||||
return dtos, nil
|
||||
configs, err := repo.Oidc.ListOidcConfigs(onlyEnabled)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var dtos []*dto.AdminOidcConfigDto
|
||||
for _, config := range configs {
|
||||
dtos = append(dtos, config.ToAdminDto())
|
||||
}
|
||||
return dtos, nil
|
||||
}
|
||||
|
||||
func (c *AdminService) UpdateOidcConfig(req *dto.AdminOidcConfigDto) error {
|
||||
if req.ID == 0 {
|
||||
return errs.ErrBadRequest
|
||||
}
|
||||
oidcConfig := &model.OidcConfig{
|
||||
Model: gorm.Model{ID: req.ID},
|
||||
Name: req.Name,
|
||||
DisplayName: req.DisplayName,
|
||||
Icon: req.Icon,
|
||||
ClientID: req.ClientID,
|
||||
ClientSecret: req.ClientSecret,
|
||||
OidcDiscoveryUrl: req.OidcDiscoveryUrl,
|
||||
Enabled: req.Enabled,
|
||||
Type: req.Type,
|
||||
}
|
||||
return repo.Oidc.UpdateOidcConfig(oidcConfig)
|
||||
if req.ID == 0 {
|
||||
return errs.ErrBadRequest
|
||||
}
|
||||
oidcConfig := &model.OidcConfig{
|
||||
Model: gorm.Model{ID: req.ID},
|
||||
Name: req.Name,
|
||||
DisplayName: req.DisplayName,
|
||||
Icon: req.Icon,
|
||||
ClientID: req.ClientID,
|
||||
ClientSecret: req.ClientSecret,
|
||||
OidcDiscoveryUrl: req.OidcDiscoveryUrl,
|
||||
Enabled: req.Enabled,
|
||||
Type: req.Type,
|
||||
}
|
||||
return repo.Oidc.UpdateOidcConfig(oidcConfig)
|
||||
}
|
||||
|
@ -15,12 +15,14 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hcaptcha/react-hcaptcha": "^1.12.1",
|
||||
"@marsidev/react-turnstile": "^1.3.0",
|
||||
"@mdxeditor/editor": "^3.46.1",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.13",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
@ -34,6 +36,7 @@
|
||||
"axios": "^1.11.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"deepmerge": "^4.3.1",
|
||||
"field-conv": "^1.0.9",
|
||||
"highlight.js": "^11.11.1",
|
||||
@ -45,6 +48,7 @@
|
||||
"next-intl": "^4.3.4",
|
||||
"next-mdx-remote-client": "^2.1.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.6.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-google-recaptcha-v3": "^1.11.0",
|
||||
|
1519
web/pnpm-lock.yaml
generated
1519
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -45,3 +45,13 @@ export async function listPosts({
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updatePost({post}: {post: Post}): Promise<BaseResponse<Post>> {
|
||||
const res = await axiosClient.put<BaseResponse<Post>>(`/post/p/${post.id}`, post)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deletePost({id}: {id: number}): Promise<null> {
|
||||
const res = await axiosClient.delete(`/post/p/${id}`)
|
||||
return res.data
|
||||
}
|
14
web/src/app/auth/layout.tsx
Normal file
14
web/src/app/auth/layout.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-2 md:p-10">
|
||||
<div className="flex w-full max-w-sm flex-col gap-6 pt-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,40 +1,12 @@
|
||||
import { Suspense } from 'react'
|
||||
import { LoginForm } from '@/components/auth/login/login-form'
|
||||
import { AuthHeader } from '@/components/auth/common/auth-header'
|
||||
|
||||
function LoginPageContent() {
|
||||
return (
|
||||
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-2 md:p-10">
|
||||
<div className="flex w-full max-w-sm flex-col gap-6">
|
||||
<AuthHeader />
|
||||
<LoginForm />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={(
|
||||
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-2 md:p-10">
|
||||
<div className="flex w-full max-w-sm flex-col gap-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="flex items-center gap-3 self-center mb-6">
|
||||
<div className="size-10 bg-gray-300 rounded-full"></div>
|
||||
<div className="h-8 bg-gray-300 rounded w-32"></div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-6 space-y-4">
|
||||
<div className="h-4 bg-gray-300 rounded w-3/4"></div>
|
||||
<div className="h-4 bg-gray-300 rounded w-1/2"></div>
|
||||
<div className="h-10 bg-gray-300 rounded"></div>
|
||||
<div className="h-10 bg-gray-300 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<LoginPageContent />
|
||||
</Suspense>
|
||||
<>
|
||||
<AuthHeader />
|
||||
<LoginForm />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -1,40 +1,12 @@
|
||||
import { Suspense } from 'react'
|
||||
import { AuthHeader } from '@/components/auth/common/auth-header'
|
||||
import { RegisterForm } from '@/components/auth/register/register-form'
|
||||
|
||||
function PageContent() {
|
||||
return (
|
||||
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-2 md:p-10">
|
||||
<div className="flex w-full max-w-sm flex-col gap-6">
|
||||
<AuthHeader />
|
||||
<RegisterForm />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense fallback={(
|
||||
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-2 md:p-10">
|
||||
<div className="flex w-full max-w-sm flex-col gap-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="flex items-center gap-3 self-center mb-6">
|
||||
<div className="size-10 bg-gray-300 rounded-full"></div>
|
||||
<div className="h-8 bg-gray-300 rounded w-32"></div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-6 space-y-4">
|
||||
<div className="h-4 bg-gray-300 rounded w-3/4"></div>
|
||||
<div className="h-4 bg-gray-300 rounded w-1/2"></div>
|
||||
<div className="h-10 bg-gray-300 rounded"></div>
|
||||
<div className="h-10 bg-gray-300 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<PageContent />
|
||||
</Suspense>
|
||||
<>
|
||||
<AuthHeader />
|
||||
<RegisterForm />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -1,12 +1,11 @@
|
||||
import { AuthHeader } from "@/components/auth/common/auth-header";
|
||||
import { ResetPasswordForm } from "@/components/auth/reset-password/reset-password-form";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-2 md:p-10">
|
||||
<div className="flex w-full max-w-sm flex-col gap-6">
|
||||
<>
|
||||
<AuthHeader />
|
||||
<ResetPasswordForm />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
@ -54,7 +54,7 @@ export default function ConsoleLayout({
|
||||
<AppSidebar variant="inset" />
|
||||
<SidebarInset>
|
||||
<SiteHeader title={title} />
|
||||
<div className="p-5 md:p-8">
|
||||
<div className="p-4 md:p-4">
|
||||
{children}
|
||||
</div>
|
||||
</SidebarInset>
|
||||
|
3
web/src/app/console/post/edit/[id]/page.tsx
Normal file
3
web/src/app/console/post/edit/[id]/page.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function EditPostPage() {
|
||||
return <div>Edit Post Page</div>;
|
||||
}
|
@ -8,6 +8,7 @@ import config from "@/config";
|
||||
import { getFirstLocale } from '@/i18n/request';
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { getLoginUser } from "@/api/user";
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app'
|
||||
import "./globals.css";
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@ -31,7 +32,7 @@ export default async function RootLayout({
|
||||
}>) {
|
||||
const token = (await cookies()).get("token")?.value || "";
|
||||
const refreshToken = (await cookies()).get("refresh_token")?.value || "";
|
||||
const user = await getLoginUser({token, refreshToken}).then(res => res.data).catch(() => null);
|
||||
const user = await getLoginUser({ token, refreshToken }).then(res => res.data).catch(() => null);
|
||||
|
||||
return (
|
||||
<html lang={await getFirstLocale() || "en"} className="h-full">
|
||||
@ -39,13 +40,15 @@ export default async function RootLayout({
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<Toaster richColors position="top-center" offset={80} />
|
||||
<DeviceProvider>
|
||||
<NextIntlClientProvider>
|
||||
<AuthProvider initialUser={user}>
|
||||
{children}
|
||||
</AuthProvider>
|
||||
</NextIntlClientProvider>
|
||||
</DeviceProvider>
|
||||
<NuqsAdapter>
|
||||
<DeviceProvider>
|
||||
<NextIntlClientProvider>
|
||||
<AuthProvider initialUser={user}>
|
||||
{children}
|
||||
</AuthProvider>
|
||||
</NextIntlClientProvider>
|
||||
</DeviceProvider>
|
||||
</ NuqsAdapter>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
@ -1,9 +1,11 @@
|
||||
import config from "@/config";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
export function AuthHeader() {
|
||||
return (
|
||||
<div className="flex items-center gap-3 self-center font-bold text-2xl">
|
||||
<div className="flex items-center gap-2 self-center font-bold text-2xl">
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-full overflow-hidden border-2 border-gray-300 dark:border-gray-600">
|
||||
<Image
|
||||
src={config.metadata.icon}
|
||||
@ -14,6 +16,7 @@ export function AuthHeader() {
|
||||
/>
|
||||
</div>
|
||||
<span className="font-bold text-2xl">{config.metadata.name}</span>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -5,7 +5,6 @@ import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
@ -102,7 +101,7 @@ export function LoginForm({
|
||||
<CardTitle className="text-xl">{t("welcome")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CurrentLogged />
|
||||
{user && <CurrentLogged />}
|
||||
<SectionDivider className="mb-6">{t("with_oidc")}</SectionDivider>
|
||||
<form>
|
||||
<div className="grid gap-4">
|
||||
@ -134,7 +133,7 @@ export function LoginForm({
|
||||
)}
|
||||
{/* 邮箱密码登录 */}
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">{t("email_or_username")}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
@ -145,7 +144,7 @@ export function LoginForm({
|
||||
onChange={e => setCredentials(c => ({ ...c, username: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="password">{t("password")}</Label>
|
||||
<Link
|
||||
|
@ -132,7 +132,7 @@ export function RegisterForm({
|
||||
<div className="grid gap-4">
|
||||
|
||||
{/* 用户名 */}
|
||||
<div className="grid gap-3">
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="username">{commonT("username")}</Label>
|
||||
</div>
|
||||
@ -145,7 +145,7 @@ export function RegisterForm({
|
||||
/>
|
||||
</div>
|
||||
{/* 密码 */}
|
||||
<div className="grid gap-3">
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="password">{commonT("password")}</Label>
|
||||
</div>
|
||||
@ -158,7 +158,7 @@ export function RegisterForm({
|
||||
/>
|
||||
</div>
|
||||
{/* 邮箱 */}
|
||||
<div className="grid gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">{commonT("email")}</Label>
|
||||
<div className="flex gap-3">
|
||||
<Input
|
||||
@ -173,7 +173,7 @@ export function RegisterForm({
|
||||
</div>
|
||||
</div>
|
||||
{/* 邮箱验证码 */}
|
||||
<div className="grid gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">{commonT("verify_code")}</Label>
|
||||
<div className="flex justify-between">
|
||||
<InputOTPControlled
|
||||
|
@ -17,7 +17,7 @@ import { toast } from "sonner"
|
||||
import { InputOTPControlled } from "@/components/common/input-otp"
|
||||
import { BaseErrorResponse } from "@/models/resp"
|
||||
import { loginPath, registerPath } from "@/hooks/use-route"
|
||||
import {useRouter} from "next/navigation"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
|
||||
export function ResetPasswordForm({
|
||||
@ -75,21 +75,21 @@ export function ResetPasswordForm({
|
||||
<CardContent>
|
||||
<form>
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-3">
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">{t("new_password")}</Label>
|
||||
<Input id="password" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">{commonT("email")}</Label>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex gap-2">
|
||||
<Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="verify_code">{t("verify_code")}</Label>
|
||||
<div className="flex gap-3 justify-between">
|
||||
<div className="flex gap-2 justify-between">
|
||||
<InputOTPControlled onChange={value => setVerifyCode(value)} />
|
||||
<Button
|
||||
disabled={!email || coolDown > 0}
|
||||
|
@ -16,6 +16,7 @@ import { OrderBy } from "@/models/common";
|
||||
import { PaginationController } from "@/components/common/pagination";
|
||||
import { QueryKey } from "@/constant";
|
||||
import { useStoredState } from "@/hooks/use-storage-state";
|
||||
import { parseAsInteger, useQueryState } from "nuqs";
|
||||
|
||||
// 定义排序类型
|
||||
enum SortBy {
|
||||
@ -27,11 +28,10 @@ const DEFAULT_SORTBY: SortBy = SortBy.Latest;
|
||||
|
||||
export default function BlogHome() {
|
||||
// 从路由查询参数中获取页码和标签们
|
||||
const searchParams = useSearchParams();
|
||||
const t = useTranslations("BlogHome");
|
||||
const [labels, setLabels] = useState<string[]>([]);
|
||||
const [keywords, setKeywords] = useState<string[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [page, setPage] = useQueryState("page", parseAsInteger.withDefault(1).withOptions({ history: "replace", clearOnDefault: true }));
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [totalPosts, setTotalPosts] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -42,7 +42,7 @@ export default function BlogHome() {
|
||||
setLoading(true);
|
||||
listPosts(
|
||||
{
|
||||
page: currentPage,
|
||||
page,
|
||||
size: config.postsPerPage,
|
||||
orderBy: sortBy === SortBy.Latest ? OrderBy.CreatedAt : OrderBy.Heat,
|
||||
desc: true,
|
||||
@ -57,22 +57,18 @@ export default function BlogHome() {
|
||||
console.error(err);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [keywords, labels, currentPage, sortBy, isSortByLoaded]);
|
||||
}, [keywords, labels, page, sortBy, isSortByLoaded]);
|
||||
|
||||
const handleSortChange = (type: SortBy) => {
|
||||
if (sortBy !== type) {
|
||||
setSortBy(type);
|
||||
setCurrentPage(1);
|
||||
setPage(1);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
setCurrentPage(page);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('page', page.toString());
|
||||
const newUrl = `${window.location.pathname}?${params.toString()}`;
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
setPage(page);
|
||||
}
|
||||
|
||||
return (
|
||||
@ -126,12 +122,13 @@ export default function BlogHome() {
|
||||
<BlogCardGrid posts={posts} isLoading={loading} showPrivate={true} />
|
||||
{/* 分页控制器 */}
|
||||
<div className="mt-8">
|
||||
<PaginationController
|
||||
{totalPosts > 0 && <PaginationController
|
||||
className="pt-4 flex justify-center"
|
||||
initialPage={currentPage}
|
||||
totalPages={Math.ceil(totalPosts / config.postsPerPage)}
|
||||
pageSize={config.postsPerPage}
|
||||
initialPage={page}
|
||||
total={totalPosts}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
/>}
|
||||
</div>
|
||||
{/* 加载状态指示器 */}
|
||||
{loading && (
|
||||
|
49
web/src/components/common/orderby-selector.tsx
Normal file
49
web/src/components/common/orderby-selector.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { OrderBy } from "@/models/common"
|
||||
import { useState } from "react"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
interface Order {
|
||||
orderBy: OrderBy
|
||||
desc: boolean
|
||||
}
|
||||
|
||||
export function OrderSelector({ initialOrder, onOrderChange }: { initialOrder: Order, onOrderChange: (order: Order) => void }) {
|
||||
const orderT = useTranslations("Order")
|
||||
const [open, setOpen] = useState(false)
|
||||
const [order, setOrder] = useState<Order>(initialOrder)
|
||||
|
||||
const orderBys = [OrderBy.CreatedAt, OrderBy.UpdatedAt, OrderBy.Heat, OrderBy.CommentCount, OrderBy.LikeCount, OrderBy.ViewCount]
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={setOpen} open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(!open)}>
|
||||
{orderT(order.orderBy)} {order.desc ? "↓" : "↑"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-40 p-0" align="end" side="bottom" sideOffset={8}>
|
||||
<div className="flex flex-col">
|
||||
{orderBys.map((ob) => (
|
||||
[true, false].map((desc) => (
|
||||
<Button
|
||||
key={`${ob}-${desc}`}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`justify-start ${order.orderBy === ob && order.desc === desc ? "bg-accent" : ""}`}
|
||||
onClick={() => {
|
||||
onOrderChange({ orderBy: ob, desc })
|
||||
setOrder({ orderBy: ob, desc })
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{orderT(ob)} {desc ? "↓" : "↑"}
|
||||
</Button>
|
||||
))
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
@ -7,132 +7,234 @@ import {
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination"
|
||||
import { useEffect, useState, useCallback } from "react"
|
||||
import { useCallback, useEffect, useMemo, useState, forwardRef, useImperativeHandle, useRef } from "react"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
export function PaginationController({
|
||||
initialPage = 1,
|
||||
totalPages = 10,
|
||||
buttons = 7, // recommended odd number >=5
|
||||
onPageChange,
|
||||
...props
|
||||
}: {
|
||||
interface PaginationControllerProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/** 总条目数(数据项个数,不是页数) */
|
||||
total: number
|
||||
/** 每页大小 */
|
||||
pageSize?: number
|
||||
/** 初始页(仅首次生效,之后内部自管理) */
|
||||
initialPage?: number
|
||||
totalPages: number
|
||||
buttons?: number
|
||||
/** 最多显示的按钮数(会强制为 >=5 的奇数) */
|
||||
maxButtons?: number
|
||||
/** 页码变化回调(用户点击或自动校正时触发) */
|
||||
onPageChange?: (page: number) => void
|
||||
} & React.HTMLAttributes<HTMLDivElement>) {
|
||||
const btns = Math.max(5, buttons ?? 7);
|
||||
const buttonsToShow = totalPages < btns ? totalPages : btns;
|
||||
/** 是否禁用交互 */
|
||||
disabled?: boolean
|
||||
/** 仅一页时隐藏组件(total>0 且 totalPages===1) */
|
||||
hideOnSinglePage?: boolean
|
||||
/** total 或 pageSize 变化导致越界时是否回调 onPageChange(默认 true) */
|
||||
reportAutoAdjust?: boolean
|
||||
}
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(() => Math.min(Math.max(1, initialPage ?? 1), Math.max(1, totalPages)));
|
||||
export interface PaginationControllerHandle {
|
||||
/** 以 1-based 设置当前页,会自动 clamp */
|
||||
setPage: (page: number) => void
|
||||
/** 获取当前页(1-based) */
|
||||
getPage: () => number
|
||||
}
|
||||
|
||||
export const PaginationController = forwardRef<PaginationControllerHandle, PaginationControllerProps>(function PaginationController({
|
||||
total,
|
||||
pageSize = 10,
|
||||
initialPage = 1,
|
||||
maxButtons = 7,
|
||||
onPageChange,
|
||||
disabled = false,
|
||||
hideOnSinglePage = false,
|
||||
reportAutoAdjust = true,
|
||||
className,
|
||||
...rest
|
||||
}, ref) {
|
||||
// 规范化 maxButtons: 至少5 且为奇数 (便于居中)
|
||||
const maxBtns = useMemo(() => {
|
||||
const m = Math.max(5, maxButtons || 7)
|
||||
return m % 2 === 0 ? m + 1 : m
|
||||
}, [maxButtons])
|
||||
const totalPages = useMemo(() => Math.max(1, Math.ceil(total / Math.max(1, pageSize))), [total, pageSize])
|
||||
const [currentPage, setCurrentPage] = useState(() => clampPage(initialPage, totalPages))
|
||||
|
||||
// 暴露方法给父组件
|
||||
useImperativeHandle(ref, () => ({
|
||||
setPage: (p: number) => setCurrentPage(() => clampPage(p, totalPages)),
|
||||
getPage: () => currentPage,
|
||||
}), [currentPage, totalPages])
|
||||
// 越界校正(不直接通知父组件)
|
||||
useEffect(() => {
|
||||
const p = Math.min(Math.max(1, initialPage ?? 1), Math.max(1, totalPages));
|
||||
setCurrentPage(p);
|
||||
}, [initialPage, totalPages]);
|
||||
setCurrentPage(prev => {
|
||||
const clamped = clampPage(prev, totalPages)
|
||||
return clamped === prev ? prev : clamped
|
||||
})
|
||||
}, [totalPages])
|
||||
|
||||
// 统一向父组件报告变化,避免在 setState 的 updater 中直接调用父级 setState 引发警告
|
||||
const lastReportedRef = useRef<number | null>(null)
|
||||
useEffect(() => {
|
||||
if (!onPageChange) return
|
||||
if (lastReportedRef.current === currentPage) return
|
||||
// 如果是自动校正且不希望报告,则跳过
|
||||
if (!reportAutoAdjust && lastReportedRef.current !== null && currentPage > totalPages) return
|
||||
lastReportedRef.current = currentPage
|
||||
onPageChange(currentPage)
|
||||
}, [currentPage, onPageChange, reportAutoAdjust, totalPages])
|
||||
|
||||
const handleSetPage = useCallback((p: number) => {
|
||||
const next = Math.min(Math.max(1, Math.floor(p)), Math.max(1, totalPages));
|
||||
setCurrentPage(next);
|
||||
if (typeof onPageChange === 'function') onPageChange(next);
|
||||
}, [onPageChange, totalPages]);
|
||||
if (disabled) return
|
||||
setCurrentPage(() => clampPage(p, totalPages))
|
||||
}, [disabled, totalPages])
|
||||
|
||||
// helper to render page link
|
||||
const renderPage = (pageNum: number) => (
|
||||
<PaginationItem key={pageNum}>
|
||||
// 计算要显示的页码集合
|
||||
const pages = useMemo(() => {
|
||||
if (totalPages <= maxBtns) {
|
||||
return { type: "all" as const, list: range(1, totalPages) }
|
||||
}
|
||||
const windowSize = maxBtns - 4 // 去掉首尾及两个潜在省略号
|
||||
let start = currentPage - Math.floor(windowSize / 2)
|
||||
let end = start + windowSize - 1
|
||||
if (start < 3) {
|
||||
start = 3
|
||||
end = start + windowSize - 1
|
||||
}
|
||||
if (end > totalPages - 2) {
|
||||
end = totalPages - 2
|
||||
start = end - windowSize + 1
|
||||
}
|
||||
return { type: "window" as const, list: range(start, end), start, end, windowSize }
|
||||
}, [currentPage, maxBtns, totalPages])
|
||||
|
||||
// total=0 的场景: 显示单个不可切换页
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div className={className} {...rest}>
|
||||
<Pagination>
|
||||
<PaginationContent className="select-none">
|
||||
<PaginationItem>
|
||||
<PaginationLink isActive aria-current="page">1</PaginationLink>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (hideOnSinglePage && totalPages === 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const renderPage = (p: number) => (
|
||||
<PaginationItem key={p}>
|
||||
<PaginationLink
|
||||
isActive={pageNum === currentPage}
|
||||
aria-current={pageNum === currentPage ? 'page' : undefined}
|
||||
onClick={() => handleSetPage(pageNum)}
|
||||
type="button"
|
||||
isActive={p === currentPage}
|
||||
aria-current={p === currentPage ? "page" : undefined}
|
||||
aria-label={`Go to page ${p}`}
|
||||
onClick={(e) => { e.preventDefault(); handleSetPage(p) }}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
>
|
||||
{pageNum}
|
||||
{p}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
);
|
||||
)
|
||||
|
||||
// if totalPages small, render all
|
||||
if (totalPages <= buttonsToShow) {
|
||||
return (
|
||||
const prevDisabled = disabled || currentPage === 1
|
||||
const nextDisabled = disabled || currentPage === totalPages
|
||||
|
||||
return (
|
||||
<div className={className} {...rest}>
|
||||
<Pagination>
|
||||
<PaginationContent className="select-none">
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
aria-disabled={currentPage === 1}
|
||||
onClick={() => currentPage > 1 && handleSetPage(currentPage - 1)}
|
||||
aria-disabled={prevDisabled}
|
||||
aria-label="Previous page"
|
||||
tabIndex={prevDisabled ? -1 : 0}
|
||||
onClick={(e) => { if (prevDisabled) return; e.preventDefault(); handleSetPage(currentPage - 1) }}
|
||||
/>
|
||||
</PaginationItem>
|
||||
{Array.from({ length: totalPages }).map((_, i) => renderPage(i + 1))}
|
||||
|
||||
{pages.type === "all" && (
|
||||
pages.list.map(renderPage)
|
||||
)}
|
||||
|
||||
{pages.type === "window" && (
|
||||
<>
|
||||
{renderPage(1)}
|
||||
{/* 前省略号 */}
|
||||
{pages.start! > 3 ? (
|
||||
<PaginationItem>
|
||||
<PaginationEllipsis aria-hidden aria-label="Truncated" />
|
||||
</PaginationItem>
|
||||
) : renderPage(2)}
|
||||
|
||||
{pages.list.map(renderPage)}
|
||||
|
||||
{/* 后省略号 */}
|
||||
{pages.end! < totalPages - 2 ? (
|
||||
<PaginationItem>
|
||||
<PaginationEllipsis aria-hidden aria-label="Truncated" />
|
||||
</PaginationItem>
|
||||
) : renderPage(totalPages - 1)}
|
||||
{renderPage(totalPages)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
aria-disabled={currentPage === totalPages}
|
||||
onClick={() => currentPage < totalPages && handleSetPage(currentPage + 1)}
|
||||
aria-disabled={nextDisabled}
|
||||
aria-label="Next page"
|
||||
tabIndex={nextDisabled ? -1 : 0}
|
||||
onClick={(e) => { if (nextDisabled) return; e.preventDefault(); handleSetPage(currentPage + 1) }}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
);
|
||||
}
|
||||
|
||||
// for larger totalPages, show: 1, 2 or ellipsis, center range, ellipsis or N-1, N
|
||||
const centerCount = buttonsToShow - 4; // slots for center pages
|
||||
let start = currentPage - Math.floor(centerCount / 2);
|
||||
let end = start + centerCount - 1;
|
||||
if (start < 3) {
|
||||
start = 3;
|
||||
end = start + centerCount - 1;
|
||||
}
|
||||
if (end > totalPages - 2) {
|
||||
end = totalPages - 2;
|
||||
start = end - (centerCount - 1);
|
||||
}
|
||||
|
||||
const centerPages = [] as number[];
|
||||
for (let i = start; i <= end; i++) centerPages.push(i);
|
||||
|
||||
return (
|
||||
<div {...props}>
|
||||
<Pagination >
|
||||
<PaginationContent className="select-none">
|
||||
<PaginationItem>
|
||||
<PaginationPrevious aria-disabled={currentPage === 1} onClick={() => currentPage > 1 && handleSetPage(currentPage - 1)} />
|
||||
</PaginationItem>
|
||||
|
||||
{renderPage(1)}
|
||||
|
||||
{/* second slot: either page 2 or ellipsis if center starts later */}
|
||||
{start > 3 ? (
|
||||
<PaginationItem>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
) : renderPage(2)}
|
||||
|
||||
{/* center pages */}
|
||||
{centerPages.map((p) => (
|
||||
<PaginationItem key={p}>
|
||||
<PaginationLink
|
||||
isActive={p === currentPage}
|
||||
aria-current={p === currentPage ? 'page' : undefined}
|
||||
onClick={() => handleSetPage(p)}
|
||||
type="button"
|
||||
>
|
||||
{p}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
))}
|
||||
|
||||
{end < totalPages - 2 ? (
|
||||
<PaginationItem>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
) : renderPage(totalPages - 1)}
|
||||
|
||||
{renderPage(totalPages)}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext aria-disabled={currentPage === totalPages} onClick={() => currentPage < totalPages && handleSetPage(currentPage + 1)} />
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})
|
||||
|
||||
// -------- helpers --------
|
||||
function clampPage(p: number, totalPages: number) {
|
||||
if (Number.isNaN(p)) return 1
|
||||
return Math.min(Math.max(1, Math.floor(p)), Math.max(1, totalPages))
|
||||
}
|
||||
|
||||
function range(start: number, end: number) {
|
||||
const arr: number[] = []
|
||||
for (let i = start; i <= end; i++) arr.push(i)
|
||||
return arr
|
||||
}
|
||||
|
||||
export function PageSizeSelector({ initialSize, onSizeChange }: { initialSize?: number, onSizeChange: (size: number) => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [size, setSize] = useState(initialSize || 10)
|
||||
const sizeList = [10, 20, 30, 50, 100]
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(!open)}>
|
||||
{size}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0">
|
||||
<div className="flex flex-col">
|
||||
{sizeList.map((item) => (
|
||||
<Button
|
||||
key={item}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSize(item)
|
||||
setOpen(false)
|
||||
onSizeChange(item)
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
@ -74,15 +74,14 @@ export function ThemeModeSegmented(props: React.HTMLAttributes<HTMLDivElement> &
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 总组件:根据设备类型渲染
|
||||
export function ThemeModeToggle(props: React.HTMLAttributes<HTMLElement> = {}) {
|
||||
const { isMobile, mode, setMode } = useDevice();
|
||||
const Comp: React.ElementType = isMobile ? ThemeModeSegmented : ThemeModeCycleButton;
|
||||
export function ThemeModeToggle(props: React.HTMLAttributes<HTMLElement> & { showSegmented?: boolean }) {
|
||||
const { mode, setMode } = useDevice();
|
||||
const Comp: React.ElementType = props.showSegmented ? ThemeModeSegmented : ThemeModeCycleButton;
|
||||
const { className, style } = props;
|
||||
// 仅转发 className / style,避免复杂的 prop 类型不匹配
|
||||
return <Comp mode={mode} setMode={setMode} className={className} style={style} />;
|
||||
}
|
||||
|
@ -19,6 +19,7 @@ import config from "@/config"
|
||||
import Link from "next/link"
|
||||
import { NavUserCenter } from "./nav-ucenter"
|
||||
import { sidebarData } from "./data"
|
||||
import { ThemeModeToggle } from "../common/theme-toggle"
|
||||
|
||||
|
||||
|
||||
@ -45,6 +46,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
<NavUserCenter items={sidebarData.navUserCenter} />
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<div className="mb-2 flex justify-center">
|
||||
<ThemeModeToggle showSegmented={true} />
|
||||
</div>
|
||||
<NavUser />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
|
@ -2,11 +2,11 @@
|
||||
import { getDashboard, DashboardResp } from "@/api/admin"
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Eye, MessageCircle, Newspaper, Users } from "lucide-react"
|
||||
import { JSX, useEffect, useState } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { path } from "../data"
|
||||
import Link from "next/link"
|
||||
import { IconType } from "@/types/icon"
|
||||
import { consolePath } from "@/hooks/use-route"
|
||||
|
||||
export function Dashboard() {
|
||||
return (
|
||||
@ -22,25 +22,25 @@ function DataOverview() {
|
||||
key: "totalPosts",
|
||||
label: "Total Posts",
|
||||
icon: Newspaper,
|
||||
url: path.post
|
||||
url: consolePath.post
|
||||
},
|
||||
{
|
||||
key: "totalUsers",
|
||||
label: "Total Users",
|
||||
icon: Users,
|
||||
url: path.user
|
||||
url: consolePath.user
|
||||
},
|
||||
{
|
||||
key: "totalComments",
|
||||
label: "Total Comments",
|
||||
icon: MessageCircle,
|
||||
url: path.comment
|
||||
url: consolePath.comment
|
||||
},
|
||||
{
|
||||
key: "totalViews",
|
||||
label: "Total Views",
|
||||
icon: Eye,
|
||||
url: path.file
|
||||
url: consolePath.file
|
||||
},
|
||||
]
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { consolePath } from "@/hooks/use-route";
|
||||
import type { User } from "@/models/user";
|
||||
import { IconType } from "@/types/icon";
|
||||
import { isAdmin, isEditor } from "@/utils/common/permission";
|
||||
@ -11,53 +12,41 @@ export interface SidebarItem {
|
||||
permission: ({ user }: { user: User }) => boolean;
|
||||
}
|
||||
|
||||
export const path = {
|
||||
dashboard: "/console",
|
||||
post: "/console/post",
|
||||
comment: "/console/comment",
|
||||
file: "/console/file",
|
||||
user: "/console/user",
|
||||
global: "/console/global",
|
||||
userProfile: "/console/user-profile",
|
||||
userSecurity: "/console/user-security",
|
||||
userPreference: "/console/user-preference",
|
||||
}
|
||||
|
||||
export const sidebarData: { navMain: SidebarItem[]; navUserCenter: SidebarItem[] } = {
|
||||
navMain: [
|
||||
{
|
||||
title: "dashboard.title",
|
||||
url: path.dashboard,
|
||||
url: consolePath.dashboard,
|
||||
icon: Gauge,
|
||||
permission: isAdmin
|
||||
},
|
||||
{
|
||||
title: "post.title",
|
||||
url: path.post,
|
||||
url: consolePath.post,
|
||||
icon: Newspaper,
|
||||
permission: isEditor
|
||||
},
|
||||
{
|
||||
title: "comment.title",
|
||||
url: path.comment,
|
||||
url: consolePath.comment,
|
||||
icon: MessageCircle,
|
||||
permission: isEditor
|
||||
},
|
||||
{
|
||||
title: "file.title",
|
||||
url: path.file,
|
||||
url: consolePath.file,
|
||||
icon: Folder,
|
||||
permission: () => true
|
||||
},
|
||||
{
|
||||
title: "user.title",
|
||||
url: path.user,
|
||||
url: consolePath.user,
|
||||
icon: Users,
|
||||
permission: isAdmin
|
||||
},
|
||||
{
|
||||
title: "global.title",
|
||||
url: path.global,
|
||||
url: consolePath.global,
|
||||
icon: Settings,
|
||||
permission: isAdmin
|
||||
},
|
||||
@ -65,19 +54,19 @@ export const sidebarData: { navMain: SidebarItem[]; navUserCenter: SidebarItem[]
|
||||
navUserCenter: [
|
||||
{
|
||||
title: "user_profile.title",
|
||||
url: path.userProfile,
|
||||
url: consolePath.userProfile,
|
||||
icon: UserPen,
|
||||
permission: () => true
|
||||
},
|
||||
{
|
||||
title: "user_security.title",
|
||||
url: path.userSecurity,
|
||||
url: consolePath.userSecurity,
|
||||
icon: ShieldCheck,
|
||||
permission: () => true
|
||||
},
|
||||
{
|
||||
title: "user-preference.title",
|
||||
url: path.userPreference,
|
||||
url: consolePath.userPreference,
|
||||
icon: Palette,
|
||||
permission: () => true
|
||||
}
|
||||
|
@ -14,6 +14,7 @@ import { User } from "@/models/user";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { IconType } from "@/types/icon";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { consolePath } from "@/hooks/use-route";
|
||||
|
||||
export function NavMain({
|
||||
items,
|
||||
@ -34,12 +35,12 @@ export function NavMain({
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent className="flex flex-col gap-2">
|
||||
<SidebarGroupLabel>General</SidebarGroupLabel>
|
||||
<SidebarGroupLabel>{t("general")}</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
item.permission({ user }) && <SidebarMenuItem key={item.title}>
|
||||
<Link href={item.url}>
|
||||
<SidebarMenuButton tooltip={item.title} isActive={pathname === item.url}>
|
||||
<SidebarMenuButton tooltip={item.title} isActive={item.url != consolePath.dashboard && pathname.startsWith(item.url) || item.url === pathname}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{t(item.title)}</span>
|
||||
</SidebarMenuButton>
|
||||
|
@ -32,7 +32,7 @@ export function NavUserCenter({
|
||||
|
||||
return (
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupLabel>Personal</SidebarGroupLabel>
|
||||
<SidebarGroupLabel>{t("personal")}</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
item.permission({ user }) && <SidebarMenuItem key={item.title}>
|
||||
|
@ -32,8 +32,13 @@ import {
|
||||
import { getGravatarFromUser } from "@/utils/common/gravatar"
|
||||
import { formatDisplayName, getFallbackAvatarFromUsername } from "@/utils/common/username"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useToUserProfile } from "@/hooks/use-route"
|
||||
|
||||
export function NavUser() {
|
||||
const operationT = useTranslations("Operation");
|
||||
const routeT = useTranslations("Route");
|
||||
const clickToProfile = useToUserProfile();
|
||||
const { isMobile } = useSidebar()
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
@ -86,9 +91,9 @@ export function NavUser() {
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={()=>{clickToProfile(user.username)}}>
|
||||
<IconUserCircle />
|
||||
Account
|
||||
{routeT("profile")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<IconCreditCard />
|
||||
@ -102,7 +107,7 @@ export function NavUser() {
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<IconLogout />
|
||||
Log out
|
||||
{operationT("logout")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
@ -1,29 +1,102 @@
|
||||
"use client";
|
||||
import { listPosts } from "@/api/post";
|
||||
import { deletePost, listPosts, updatePost } from "@/api/post";
|
||||
import { OrderSelector } from "@/components/common/orderby-selector";
|
||||
import { PageSizeSelector, PaginationController } from "@/components/common/pagination";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import config from "@/config";
|
||||
import { useDevice } from "@/contexts/device-context";
|
||||
import { useDoubleConfirm } from "@/hooks/use-double-confirm";
|
||||
import { useToEditPost, useToPost } from "@/hooks/use-route";
|
||||
import { OrderBy } from "@/models/common";
|
||||
import { Post } from "@/models/post"
|
||||
import { useEffect, useState } from "react";
|
||||
import { DropdownMenuGroup } from "@radix-ui/react-dropdown-menu";
|
||||
import { Ellipsis, Eye } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
useQueryState,
|
||||
parseAsInteger,
|
||||
parseAsBoolean,
|
||||
parseAsStringEnum,
|
||||
parseAsString
|
||||
} from "nuqs";
|
||||
import { useDebouncedState } from "@/hooks/use-debounce";
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
const MOBILE_PAGE_SIZE = 10;
|
||||
|
||||
export function PostManage() {
|
||||
const orderT = useTranslations("Order");
|
||||
const commonT = useTranslations("Common");
|
||||
const metricsT = useTranslations("Metrics");
|
||||
const { isMobile } = useDevice();
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [orderBy, setOrderBy] = useState<OrderBy>(OrderBy.CreatedAt);
|
||||
const [desc, setDesc] = useState<boolean>(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [orderBy, setOrderBy] = useQueryState("order_by", parseAsStringEnum<OrderBy>(Object.values(OrderBy)).withDefault(OrderBy.CreatedAt).withOptions({ history: "replace", clearOnDefault: true }));
|
||||
const [desc, setDesc] = useQueryState("desc", parseAsBoolean.withDefault(true).withOptions({ history: "replace", clearOnDefault: true }));
|
||||
const [page, setPage] = useQueryState("page", parseAsInteger.withDefault(1).withOptions({ history: "replace", clearOnDefault: true }));
|
||||
const [size, setSize] = useQueryState("size", parseAsInteger.withDefault(isMobile ? MOBILE_PAGE_SIZE : PAGE_SIZE).withOptions({ history: "replace", clearOnDefault: true }));
|
||||
const [keywords, setKeywords] = useQueryState("keywords", parseAsString.withDefault("").withOptions({ history: "replace", clearOnDefault: true }));
|
||||
const [keywordsInput, setKeywordsInput, debouncedKeywordsInput] = useDebouncedState(keywords, 200);
|
||||
|
||||
useEffect(() => {
|
||||
listPosts({ page, size: config.postsPerPage, orderBy, desc }).then(res => {
|
||||
setPosts(res.data.posts);
|
||||
});
|
||||
}, [page, orderBy, desc]);
|
||||
listPosts({ page, size, orderBy, desc, keywords }).
|
||||
then(res => {
|
||||
setPosts(res.data.posts);
|
||||
setTotal(res.data.total);
|
||||
});
|
||||
}, [page, orderBy, desc, size, keywords]);
|
||||
|
||||
useEffect(() => {
|
||||
setKeywords(debouncedKeywordsInput)
|
||||
}, [debouncedKeywordsInput, setKeywords, keywords])
|
||||
|
||||
const onPostUpdate = useCallback(({ post }: { post: Partial<Post> & Pick<Post, "id"> }) => {
|
||||
setPosts((prev) => prev.map((p) => (p.id === post.id ? { ...p, ...post } : p)));
|
||||
}, [setPosts]);
|
||||
|
||||
const onOrderChange = useCallback(({ orderBy, desc }: { orderBy: OrderBy; desc: boolean }) => {
|
||||
setOrderBy(orderBy);
|
||||
setDesc(desc);
|
||||
setPage(1);
|
||||
}, [setOrderBy, setDesc, setPage]);
|
||||
|
||||
const onPageChange = useCallback((p: number) => {
|
||||
setPage(p);
|
||||
}, [setPage]);
|
||||
|
||||
return <div>
|
||||
{posts.map(post => <PostItem key={post.id} post={post} />)}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<Input type="search" placeholder={commonT("search")} value={keywordsInput} onChange={(e) => setKeywordsInput(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm font-bold">{orderT("order")}</div>
|
||||
{<OrderSelector initialOrder={{ orderBy, desc }} onOrderChange={onOrderChange} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="flex-1" />
|
||||
{posts.map(post => <div key={post.id}>
|
||||
<PostItem post={post} onPostUpdate={onPostUpdate} />
|
||||
<Separator className="flex-1" />
|
||||
</div>)}
|
||||
<div className="flex justify-center items-center py-4">
|
||||
{total > 0 && <PaginationController initialPage={page} onPageChange={onPageChange} total={total} pageSize={size} />}
|
||||
<PageSizeSelector initialSize={size} onSizeChange={(s) => { setSize(s); setPage(1); }} /> {metricsT("per_page")}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function PostItem({ post }: { post: Post }) {
|
||||
function PostItem({ post, onPostUpdate }: { post: Post, onPostUpdate?: ({ post }: { post: Partial<Post> & Pick<Post, "id"> }) => void }) {
|
||||
const commonT = useTranslations("Common");
|
||||
const postT = useTranslations("Metrics");
|
||||
const stateT = useTranslations("State");
|
||||
const clickToPost = useToPost();
|
||||
return (
|
||||
<div>
|
||||
<div className="flex w-full items-center gap-3 py-2">
|
||||
@ -32,16 +105,97 @@ function PostItem({ post }: { post: Post }) {
|
||||
<div className="text-sm font-medium">
|
||||
{post.title}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">ID: {post.id}</span>
|
||||
<span className="mx-2 text-xs text-muted-foreground">|</span>
|
||||
<span className="text-xs text-muted-foreground">Created At: {new Date(post.createdAt).toLocaleDateString()}</span>
|
||||
<span className="mx-2 text-xs text-muted-foreground">|</span>
|
||||
<span className="text-xs text-muted-foreground">Updated At: {new Date(post.updatedAt).toLocaleDateString()}</span>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground">{stateT(post.isPrivate ? "private" : "public")}</span>
|
||||
<span className="text-xs text-muted-foreground">{postT("view_count")}: {post.viewCount}</span>
|
||||
<span className="text-xs text-muted-foreground">{postT("like_count")}: {post.likeCount}</span>
|
||||
<span className="text-xs text-muted-foreground">{postT("comment_count")}: {post.commentCount}</span>
|
||||
<span className="text-xs text-muted-foreground">{commonT("id")}: {post.id}</span>
|
||||
<span className="text-xs text-muted-foreground">{commonT("created_at")}: {new Date(post.createdAt).toLocaleDateString()}</span>
|
||||
<span className="text-xs text-muted-foreground">{commonT("updated_at")}: {new Date(post.updatedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* right */}
|
||||
<div className="flex items-center ml-auto">
|
||||
<Button variant="ghost" size="sm" onClick={() => clickToPost({ post })}>
|
||||
<Eye className="inline size-4 mr-1" />
|
||||
</Button>
|
||||
<PostDropdownMenu post={post} onPostUpdate={onPostUpdate} />
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PostDropdownMenu({ post, onPostUpdate }: { post: Post, onPostUpdate?: ({ post }: { post: Partial<Post> & Pick<Post, "id"> }) => void }) {
|
||||
const operationT = useTranslations("Operation");
|
||||
const clickToPostEdit = useToEditPost();
|
||||
const clickToPost = useToPost();
|
||||
const { confirming: confirmingDelete, onClick: onDeleteClick, onBlur: onDeleteBlur } = useDoubleConfirm();
|
||||
const [open, setOpen] = useState(false);
|
||||
const handleTogglePrivate = () => {
|
||||
updatePost({ post: { ...post, isPrivate: !post.isPrivate } })
|
||||
.then(() => {
|
||||
toast.success(operationT("update_success"));
|
||||
onPostUpdate?.({ post: { id: post.id, isPrivate: !post.isPrivate } });
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(operationT("update_failed"));
|
||||
});
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
deletePost({ id: post.id })
|
||||
.then(() => {
|
||||
toast.success(operationT("delete_success"));
|
||||
onPostUpdate?.({ post: { id: post.id } });
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(operationT("delete_failed"));
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
setOpen(o);
|
||||
if (!o) onDeleteBlur();
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost">
|
||||
<Ellipsis className="w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-4" align="start">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => clickToPostEdit({ post })} className="cursor-pointer" >
|
||||
{operationT("edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => clickToPost({ post })} className="cursor-pointer" >
|
||||
{operationT("view")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={handleTogglePrivate} className="text-red-600 hover:bg-red-600/10 focus:bg-red-600/10 cursor-pointer">
|
||||
{operationT(post.isPrivate ? "set_public" : "set_private")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
if (!confirmingDelete) {
|
||||
e.preventDefault();
|
||||
onDeleteClick(() => handleDelete());
|
||||
} else {
|
||||
onDeleteClick(() => handleDelete());
|
||||
}
|
||||
}}
|
||||
className="text-red-600 hover:bg-red-600/10 focus:bg-red-600/10 cursor-pointer">
|
||||
{confirmingDelete ? operationT("confirm_delete") : operationT("delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
@ -32,9 +32,11 @@ export function UserProfilePage() {
|
||||
const [username, setUsername] = useState(user?.username || '')
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null)
|
||||
const [avatarFileUrl, setAvatarFileUrl] = useState<string | null>(null) // 这部分交由useEffect控制,监听 avatarFile 变化
|
||||
const [backgroundFile, setBackgroundFile] = useState<File | null>(null)
|
||||
const [backgroundFileUrl, setBackgroundFileUrl] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [gender, setGender] = useState(user?.gender || '')
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
@ -50,6 +52,20 @@ export function UserProfilePage() {
|
||||
};
|
||||
}, [avatarFile, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
if (!backgroundFile) {
|
||||
setBackgroundFileUrl(null);
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(backgroundFile);
|
||||
setBackgroundFileUrl(url);
|
||||
return () => {
|
||||
URL.revokeObjectURL(url);
|
||||
setBackgroundFileUrl(null);
|
||||
};
|
||||
}, [backgroundFile, user]);
|
||||
|
||||
const handlePictureSelected = (e: PictureInputChangeEvent): void => {
|
||||
const file: File | null = e.target.files?.[0] ?? null;
|
||||
if (!file) {
|
||||
@ -67,12 +83,35 @@ export function UserProfilePage() {
|
||||
}
|
||||
if (file.size > constraints.maxSize) {
|
||||
setAvatarFile(null);
|
||||
toast.error(t("picture_size_cannot_exceed", {"size": "5MiB"}));
|
||||
toast.error(t("picture_size_cannot_exceed", { "size": "5MiB" }));
|
||||
return;
|
||||
}
|
||||
setAvatarFile(file);
|
||||
}
|
||||
|
||||
const handleBackgroundSelected = (e: PictureInputChangeEvent): void => {
|
||||
const file: File | null = e.target.files?.[0] ?? null;
|
||||
if (!file) {
|
||||
setBackgroundFile(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)) {
|
||||
setBackgroundFile(null);
|
||||
toast.error(t("only_allow_picture"));
|
||||
return;
|
||||
}
|
||||
if (file.size > constraints.maxSize) {
|
||||
setBackgroundFile(null);
|
||||
toast.error(t("picture_size_cannot_exceed", { "size": "5MiB" }));
|
||||
return;
|
||||
}
|
||||
setBackgroundFile(file);
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!user) return;
|
||||
if (
|
||||
@ -87,7 +126,7 @@ export function UserProfilePage() {
|
||||
(username.length < 1 || username.length > 20) ||
|
||||
(nickname.length < 1 || nickname.length > 20)
|
||||
) {
|
||||
toast.error(t("nickname_and_username_must_be_between", {"min": 1, "max": 20}))
|
||||
toast.error(t("nickname_and_username_must_be_between", { "min": 1, "max": 20 }))
|
||||
return
|
||||
}
|
||||
|
||||
@ -95,13 +134,15 @@ export function UserProfilePage() {
|
||||
username === user.username &&
|
||||
nickname === user.nickname &&
|
||||
gender === user.gender &&
|
||||
avatarFile === null
|
||||
avatarFile === null &&
|
||||
backgroundFile === null
|
||||
) {
|
||||
toast.warning(t("no_changes_made"))
|
||||
return
|
||||
}
|
||||
|
||||
let avatarUrl = user.avatarUrl;
|
||||
let backgroundUrl = user.backgroundUrl;
|
||||
setSubmitting(true);
|
||||
(async () => {
|
||||
if (avatarFile) {
|
||||
@ -114,8 +155,18 @@ export function UserProfilePage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (backgroundFile) {
|
||||
try {
|
||||
const resp = await uploadFile({ file: backgroundFile });
|
||||
backgroundUrl = getFileUri(resp.data.id);
|
||||
} catch (error: unknown) {
|
||||
toast.error(`${t("failed_to_upload_background")}: ${error}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await updateUser({ nickname, username, avatarUrl, gender, id: user.id });
|
||||
await updateUser({ nickname, username, avatarUrl, backgroundUrl, gender, id: user.id });
|
||||
window.location.reload();
|
||||
} catch (error: unknown) {
|
||||
toast.error(`${t("failed_to_update_profile")}: ${error}`);
|
||||
@ -123,7 +174,7 @@ export function UserProfilePage() {
|
||||
setSubmitting(false);
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
}
|
||||
|
||||
const handleCropped = (blob: Blob) => {
|
||||
@ -139,28 +190,40 @@ export function UserProfilePage() {
|
||||
{t("public_profile")}
|
||||
</h1>
|
||||
<Separator className="my-2" />
|
||||
<div className="grid w-full max-w-sm items-center gap-3">
|
||||
<Label htmlFor="picture">{t("picture")}</Label>
|
||||
<Avatar className="h-40 w-40 rounded-xl border-2">
|
||||
{avatarFileUrl ?
|
||||
<AvatarImage src={avatarFileUrl} alt={nickname || username} /> :
|
||||
<AvatarImage src={getGravatarFromUser({ user })} alt={nickname || 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 image={avatarFile} onCropped={handleCropped} />
|
||||
<div className="grid w-full max-w-sm items-center gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="picture">{t("picture")}</Label>
|
||||
<Avatar className="h-40 w-40 rounded-xl border-2">
|
||||
{avatarFileUrl ?
|
||||
<AvatarImage src={avatarFileUrl} alt={nickname || username} /> :
|
||||
<AvatarImage src={getGravatarFromUser({ user })} alt={nickname || username} />}
|
||||
<AvatarFallback>{getFallbackAvatarFromUsername(nickname || username)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex gap-2"><Input
|
||||
id="picture"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif,image/*"
|
||||
onChange={handlePictureSelected}
|
||||
/>
|
||||
<ImageCropper image={avatarFile} onCropped={handleCropped} />
|
||||
</div>
|
||||
</div>
|
||||
<Label htmlFor="nickname">{t("nickname")}</Label>
|
||||
<Input type="nickname" id="nickname" value={nickname} onChange={(e) => setNickname(e.target.value)} />
|
||||
<Label htmlFor="username">{t("username")}</Label>
|
||||
<Input type="username" id="username" value={username} onChange={(e) => setUsername(e.target.value)} />
|
||||
<Label htmlFor="gender">{t("gender")}</Label>
|
||||
<Input type="gender" id="gender" value={gender} onChange={(e) => setGender(e.target.value)}/>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="nickname">{t("nickname")}</Label>
|
||||
<Input type="nickname" id="nickname" value={nickname} onChange={(e) => setNickname(e.target.value)} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="username">{t("username")}</Label>
|
||||
<Input type="username" id="username" value={username} onChange={(e) => setUsername(e.target.value)} />
|
||||
</div >
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="gender">{t("gender")}</Label>
|
||||
<Input type="gender" id="gender" value={gender} onChange={(e) => setGender(e.target.value)} />
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<Button className="max-w-1/3" onClick={handleSubmit} disabled={submitting}>{t("update_profile")}{submitting && '...'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -34,7 +34,7 @@ export function UserSecurityPage() {
|
||||
}
|
||||
|
||||
const handleSendVerifyCode = () => {
|
||||
requestEmailVerifyCode({email})
|
||||
requestEmailVerifyCode({ email })
|
||||
.then(() => {
|
||||
toast.success(t("send_verify_code_success"))
|
||||
})
|
||||
@ -61,14 +61,20 @@ export function UserSecurityPage() {
|
||||
if (!user) return null;
|
||||
return (
|
||||
<div>
|
||||
<div className="grid w-full max-w-sm items-center gap-3">
|
||||
<div className="grid w-full max-w-sm items-center gap-4">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{t("password_setting")}
|
||||
</h1>
|
||||
<Label htmlFor="password">{t("old_password")}</Label>
|
||||
<Input id="password" type="password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} />
|
||||
<Label htmlFor="password">{t("new_password")}</Label>
|
||||
<Input id="password" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">{t("old_password")}</Label>
|
||||
<Input id="password" type="password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">{t("new_password")}</Label>
|
||||
<Input id="password" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<Button disabled={!oldPassword || !newPassword} className="max-w-1/3 border-2" onClick={handleSubmitPassword}>{t("update_password")}</Button>
|
||||
<Link href={resetPasswordPath}>{t("forgot_password_or_no_password")}</Link>
|
||||
@ -76,19 +82,23 @@ export function UserSecurityPage() {
|
||||
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<div className="grid w-full max-w-sm items-center gap-3 py-4">
|
||||
<div className="grid w-full max-w-sm items-center gap-4 py-4">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{t("email_setting")}
|
||||
</h1>
|
||||
<Label htmlFor="email">{commonT("email")}</Label>
|
||||
<div className="flex gap-3">
|
||||
<Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">{commonT("email")}</Label>
|
||||
<Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
</div>
|
||||
<Label htmlFor="verify-code">{t("verify_code")}</Label>
|
||||
<div className="flex justify-between">
|
||||
<InputOTPControlled onChange={(value) => setVerifyCode(value)} />
|
||||
<Button disabled={!email || email == user.email} variant="outline" className="border-2" onClick={handleSendVerifyCode}>{t("send_verify_code")}</Button>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="verify-code">{t("verify_code")}</Label>
|
||||
<div className="flex gap-2 justify-between">
|
||||
<InputOTPControlled onChange={(value) => setVerifyCode(value)} />
|
||||
<Button disabled={!email || email == user.email} variant="outline" className="border-2" onClick={handleSendVerifyCode}>{t("send_verify_code")}</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<Button disabled={verifyCode.length < 6} className="border-2" onClick={handleSubmitEmail}>{t("update_email")}</Button>
|
||||
</div>
|
||||
|
@ -171,7 +171,7 @@ function SidebarMenu() {
|
||||
)}
|
||||
</nav>
|
||||
<div className="flex items-center justify-center p-4 border-t border-border">
|
||||
<ThemeModeToggle/>
|
||||
<ThemeModeToggle showSegmented={true} />
|
||||
</div>
|
||||
|
||||
</SheetContent>
|
||||
|
184
web/src/components/ui/command.tsx
Normal file
184
web/src/components/ui/command.tsx
Normal file
@ -0,0 +1,184 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn("overflow-hidden p-0", className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
@ -77,7 +77,7 @@ function PaginationPrevious({
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
<span className="hidden sm:block">Previous</span>
|
||||
<span className="hidden sm:block"></span>
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
@ -93,7 +93,7 @@ function PaginationNext({
|
||||
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="hidden sm:block">Next</span>
|
||||
<span className="hidden sm:block"></span>
|
||||
<ChevronRightIcon />
|
||||
</PaginationLink>
|
||||
)
|
||||
|
48
web/src/components/ui/popover.tsx
Normal file
48
web/src/components/ui/popover.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useState, useMemo, useEffect } from "react";
|
||||
import React, { createContext, useContext, useState, useMemo, useEffect, useCallback } from "react";
|
||||
import type { User } from "@/models/user";
|
||||
import { getLoginUser, userLogout } from "@/api/user";
|
||||
import { useTranslations } from "next-intl";
|
||||
@ -34,15 +34,15 @@ export function AuthProvider({
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const logout = () => {
|
||||
const logout = useCallback(() => {
|
||||
userLogout().then(() => {
|
||||
toast.success(commonT("logout_success"));
|
||||
setUser(null);
|
||||
}).catch(() => {
|
||||
toast.error(commonT("logout_failed"));
|
||||
});
|
||||
};
|
||||
const value = useMemo(() => ({ user, setUser, logout }), [user]);
|
||||
}, [commonT]);
|
||||
const value = useMemo(() => ({ user, setUser, logout }), [user, logout]);
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
|
105
web/src/hooks/use-debounce.ts
Normal file
105
web/src/hooks/use-debounce.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react"
|
||||
|
||||
/**
|
||||
* useDebouncedValue
|
||||
* 返回一个在指定 delay 毫秒后稳定下来的值。
|
||||
* 当输入频繁变化时,只在用户停止输入 delay 时间后才更新。
|
||||
*/
|
||||
export function useDebouncedValue<T>(value: T, delay = 400) {
|
||||
const [debounced, setDebounced] = useState(value)
|
||||
const timerRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (timerRef.current) window.clearTimeout(timerRef.current)
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
setDebounced(value)
|
||||
}, delay)
|
||||
return () => {
|
||||
if (timerRef.current) window.clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [value, delay])
|
||||
|
||||
return debounced
|
||||
}
|
||||
|
||||
/** 防抖回调:返回一个稳定函数,调用时会在 delay 后真正触发 cb(后触发覆盖先触发)。*/
|
||||
export function useDebouncedCallback<T extends (...args: unknown[]) => unknown>(cb: T, delay = 400) {
|
||||
const cbRef = useRef(cb)
|
||||
const timerRef = useRef<number | null>(null)
|
||||
cbRef.current = cb
|
||||
return (...args: Parameters<T>) => {
|
||||
if (timerRef.current) window.clearTimeout(timerRef.current)
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
cbRef.current(...args)
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
|
||||
interface UseDebouncedStateOptions {
|
||||
/** 是否在首次 set 时立即更新(默认 false 延迟) */
|
||||
immediate?: boolean
|
||||
/** 组件卸载时是否自动触发最后一次(默认 false) */
|
||||
flushOnUnmount?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* useDebouncedState
|
||||
* 返回: [state, setState, debouncedState, controls]
|
||||
* - state: 立即更新的本地值(用户输入实时)
|
||||
* - setState: 设定本地值并启动防抖计时,到期后同步到 debouncedState
|
||||
* - debouncedState: 稳定值(可用于副作用 / 请求)
|
||||
* - controls: { flush, cancel }
|
||||
*/
|
||||
export function useDebouncedState<T>(
|
||||
initial: T,
|
||||
delay = 400,
|
||||
options: UseDebouncedStateOptions = {}
|
||||
) {
|
||||
const { immediate = false, flushOnUnmount = false } = options
|
||||
const [state, setState] = useState<T>(initial)
|
||||
const [debounced, setDebounced] = useState<T>(initial)
|
||||
const timerRef = useRef<number | null>(null)
|
||||
const pendingRef = useRef<T>(initial)
|
||||
|
||||
const flush = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
window.clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
setDebounced(pendingRef.current)
|
||||
}, [])
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
window.clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const set = useCallback((val: T | ((prev: T) => T)) => {
|
||||
setState(prev => {
|
||||
const next = typeof val === 'function' ? (val as (p: T) => T)(prev) : val
|
||||
pendingRef.current = next
|
||||
if (timerRef.current) window.clearTimeout(timerRef.current)
|
||||
if (immediate && debounced !== next && timerRef.current === null) {
|
||||
setDebounced(next)
|
||||
} else {
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
timerRef.current = null
|
||||
setDebounced(pendingRef.current)
|
||||
}, delay)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [delay, immediate, debounced])
|
||||
|
||||
useEffect(() => () => {
|
||||
if (flushOnUnmount && timerRef.current) {
|
||||
flush()
|
||||
} else if (timerRef.current) {
|
||||
window.clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [flushOnUnmount, flush])
|
||||
|
||||
return [state, set, debounced, { flush, cancel }] as const
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
|
||||
import { Post } from "@/models/post"
|
||||
import { useRouter, usePathname } from "next/navigation"
|
||||
|
||||
/**
|
||||
@ -10,6 +11,18 @@ export const loginPath = authPath + "/login"
|
||||
export const registerPath = authPath + "/register"
|
||||
export const resetPasswordPath = authPath + "/reset-password"
|
||||
|
||||
export const consolePath = {
|
||||
dashboard: "/console",
|
||||
post: "/console/post",
|
||||
comment: "/console/comment",
|
||||
file: "/console/file",
|
||||
user: "/console/user",
|
||||
global: "/console/global",
|
||||
userProfile: "/console/user-profile",
|
||||
userSecurity: "/console/user-security",
|
||||
userPreference: "/console/user-preference",
|
||||
}
|
||||
|
||||
export function useToLogin() {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
@ -25,3 +38,16 @@ export function useToUserProfile() {
|
||||
};
|
||||
}
|
||||
|
||||
export function useToPost(){
|
||||
const router = useRouter();
|
||||
return ({post}:{post: Post}) => {
|
||||
router.push(`/p/${post.id}`);
|
||||
};
|
||||
}
|
||||
|
||||
export function useToEditPost(){
|
||||
const router = useRouter();
|
||||
return ({post}:{post: Post}) => {
|
||||
router.push(`${consolePath.post}/edit/${post.id}`);
|
||||
};
|
||||
}
|
@ -54,8 +54,10 @@
|
||||
"update": "更新"
|
||||
},
|
||||
"Common": {
|
||||
"created_at": "创建于",
|
||||
"email": "邮箱",
|
||||
"forgot_password": "忘记密码?",
|
||||
"id": "编号",
|
||||
"login": "登录",
|
||||
"logout_failed": "退出登录失败",
|
||||
"logout_success": "退出登录成功",
|
||||
@ -65,10 +67,12 @@
|
||||
"obtain": "获取",
|
||||
"password": "密码",
|
||||
"register": "注册",
|
||||
"search": "搜索",
|
||||
"secondsAgo": "秒前",
|
||||
"send_verify_code": "发送验证码",
|
||||
"submit": "提交",
|
||||
"update": "更新",
|
||||
"updated_at": "更新于",
|
||||
"username": "用户名",
|
||||
"verify_code": "验证码"
|
||||
},
|
||||
@ -82,10 +86,12 @@
|
||||
"file": {
|
||||
"title": "文件管理"
|
||||
},
|
||||
"general": "常规",
|
||||
"global": {
|
||||
"title": "全局配置"
|
||||
},
|
||||
"login_required": "请先登录再进入后台",
|
||||
"personal": "个人",
|
||||
"post": {
|
||||
"title": "文章管理"
|
||||
},
|
||||
@ -93,7 +99,7 @@
|
||||
"title": "用户管理"
|
||||
},
|
||||
"user_profile": {
|
||||
"title": "个人资料",
|
||||
"title": "资料设置",
|
||||
"edit": "编辑",
|
||||
"failed_to_upload_avatar": "上传头像失败",
|
||||
"failed_to_update_profile": "更新个人资料失败",
|
||||
@ -155,6 +161,37 @@
|
||||
"and": "和",
|
||||
"privacy_policy": "隐私政策"
|
||||
},
|
||||
"Metrics": {
|
||||
"comment_count": "评论数",
|
||||
"like_count": "点赞数",
|
||||
"per_page": " /页",
|
||||
"view_count": "浏览数"
|
||||
},
|
||||
"Operation": {
|
||||
"confirm_delete": "确认删除?",
|
||||
"delete": "删除",
|
||||
"delete_failed": "删除失败",
|
||||
"delete_success": "删除成功",
|
||||
"edit": "编辑",
|
||||
"login": "登录",
|
||||
"logout": "登出",
|
||||
"previous": "上一页",
|
||||
"next": "下一页",
|
||||
"set_private": "设为私密",
|
||||
"set_public": "设为公开",
|
||||
"update_failed": "更新失败",
|
||||
"update_success": "更新成功",
|
||||
"view": "查看"
|
||||
},
|
||||
"Order": {
|
||||
"order": "排序",
|
||||
"comment_count": "评论数",
|
||||
"created_at": "创建时间",
|
||||
"heat": "热度",
|
||||
"like_count": "点赞数",
|
||||
"updated_at": "更新时间",
|
||||
"view_count": "浏览数"
|
||||
},
|
||||
"Register": {
|
||||
"title": "注册",
|
||||
"already_have_account": "已经有账号?",
|
||||
@ -175,5 +212,12 @@
|
||||
"send_verify_code_failed": "发送验证码失败",
|
||||
"send_verify_code_success": "验证码已发送",
|
||||
"verify_code": "验证码"
|
||||
},
|
||||
"Route": {
|
||||
"profile": "个人资料"
|
||||
},
|
||||
"State": {
|
||||
"private": "私密",
|
||||
"public": "公开"
|
||||
}
|
||||
}
|
@ -3,6 +3,7 @@ export interface User {
|
||||
username: string;
|
||||
nickname?: string;
|
||||
avatarUrl?: string;
|
||||
backgroundUrl?: string;
|
||||
email: string;
|
||||
gender?: string;
|
||||
role: string;
|
||||
|
0
web/tailwind.config.js
Normal file
0
web/tailwind.config.js
Normal file
Reference in New Issue
Block a user