feat: enhance post management with pagination, search, and order functionality
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:
2025-09-25 00:51:29 +08:00
parent 59b68613cd
commit 64b1c54911
44 changed files with 2790 additions and 474 deletions

View File

@ -132,6 +132,12 @@ pnpm install
pnpm dev
```
### 前端规范
表单元素使用`grid gap-4`作为容器,表单项使用`grid gap-2`作为容器
flex布局横向使用`flex gap-3`作为容器
### 联合调试
默认情况下,本机启动后端和前端服务器无须额外配置即可互联,若后端在不同的主机上,需要在.env.development(自己创建)中配置`BACKEND_URL`变量

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
export default function EditPostPage() {
return <div>Edit Post Page</div>;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

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

View File

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

View File

@ -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": "公开"
}
}

View File

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