mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-04 00:06:22 +00:00
feat: 更新评论和帖子模型,添加用户信息和原创标识,优化API请求和组件结构
This commit is contained in:
@ -102,13 +102,12 @@ func (cc *CommentController) GetCommentList(ctx context.Context, c *app.RequestC
|
|||||||
resps.BadRequest(c, "无效的 target_id")
|
resps.BadRequest(c, "无效的 target_id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
req := dto.GetCommentListReq{
|
req := dto.GetCommentListReq{
|
||||||
Desc: pagination.Desc,
|
Desc: pagination.Desc,
|
||||||
OrderBy: pagination.OrderBy,
|
OrderBy: pagination.OrderBy,
|
||||||
Page: pagination.Page,
|
Page: pagination.Page,
|
||||||
Size: pagination.Size,
|
Size: pagination.Size,
|
||||||
TargetID: uint(targetID),
|
TargetID: uint(targetID),
|
||||||
TargetType: c.Query("target_type"),
|
TargetType: c.Query("target_type"),
|
||||||
}
|
}
|
||||||
resp, err := cc.service.GetCommentList(ctx, &req)
|
resp, err := cc.service.GetCommentList(ctx, &req)
|
||||||
@ -120,4 +119,6 @@ func (cc *CommentController) GetCommentList(ctx context.Context, c *app.RequestC
|
|||||||
resps.Ok(c, resps.Success, resp)
|
resps.Ok(c, resps.Success, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cc *CommentController) ReactComment(ctx context.Context, c *app.RequestContext) {}
|
func (cc *CommentController) ReactComment(ctx context.Context, c *app.RequestContext) {
|
||||||
|
|
||||||
|
}
|
||||||
|
@ -2,7 +2,6 @@ package v1
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"github.com/cloudwego/hertz/pkg/app"
|
"github.com/cloudwego/hertz/pkg/app"
|
||||||
"github.com/cloudwego/hertz/pkg/common/utils"
|
"github.com/cloudwego/hertz/pkg/common/utils"
|
||||||
"github.com/snowykami/neo-blog/internal/ctxutils"
|
"github.com/snowykami/neo-blog/internal/ctxutils"
|
||||||
@ -95,7 +94,6 @@ func (p *PostController) Update(ctx context.Context, c *app.RequestContext) {
|
|||||||
|
|
||||||
func (p *PostController) List(ctx context.Context, c *app.RequestContext) {
|
func (p *PostController) List(ctx context.Context, c *app.RequestContext) {
|
||||||
pagination := ctxutils.GetPaginationParams(c)
|
pagination := ctxutils.GetPaginationParams(c)
|
||||||
fmt.Println(pagination)
|
|
||||||
if pagination.OrderBy == "" {
|
if pagination.OrderBy == "" {
|
||||||
pagination.OrderBy = constant.OrderByUpdatedAt
|
pagination.OrderBy = constant.OrderByUpdatedAt
|
||||||
}
|
}
|
||||||
|
@ -5,11 +5,13 @@ import "time"
|
|||||||
type PostDto struct {
|
type PostDto struct {
|
||||||
ID uint `json:"id"` // 帖子ID
|
ID uint `json:"id"` // 帖子ID
|
||||||
UserID uint `json:"user_id"` // 发布者的用户ID
|
UserID uint `json:"user_id"` // 发布者的用户ID
|
||||||
|
User UserDto `json:"user"` // 发布者信息
|
||||||
Title string `json:"title"` // 帖子标题
|
Title string `json:"title"` // 帖子标题
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Cover string `json:"cover"` // 帖子封面图
|
Cover string `json:"cover"` // 帖子封面图
|
||||||
Type string `json:"type"` // 帖子类型 markdown / html / text
|
Type string `json:"type"` // 帖子类型 markdown / html / text
|
||||||
Labels []LabelDto `json:"labels"` // 关联的标签
|
Labels []LabelDto `json:"labels"` // 关联的标签
|
||||||
|
IsOriginal bool `json:"is_original"` // 是否为原创帖子
|
||||||
IsPrivate bool `json:"is_private"` // 是否为私密帖子
|
IsPrivate bool `json:"is_private"` // 是否为私密帖子
|
||||||
LikeCount uint64 `json:"like_count"` // 点赞数
|
LikeCount uint64 `json:"like_count"` // 点赞数
|
||||||
CommentCount uint64 `json:"comment_count"` // 评论数
|
CommentCount uint64 `json:"comment_count"` // 评论数
|
||||||
|
@ -23,9 +23,9 @@ type UserLoginReq struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserLoginResp struct {
|
type UserLoginResp struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
RefreshToken string `json:"refresh_token"`
|
RefreshToken string `json:"refresh_token"`
|
||||||
User *UserDto `json:"user"`
|
User UserDto `json:"user"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserRegisterReq struct {
|
type UserRegisterReq struct {
|
||||||
@ -37,9 +37,9 @@ type UserRegisterReq struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserRegisterResp struct {
|
type UserRegisterResp struct {
|
||||||
Token string `json:"token"` // 访问令牌
|
Token string `json:"token"` // 访问令牌
|
||||||
RefreshToken string `json:"refresh_token"` // 刷新令牌
|
RefreshToken string `json:"refresh_token"` // 刷新令牌
|
||||||
User *UserDto `json:"user"` // 用户信息
|
User UserDto `json:"user"` // 用户信息
|
||||||
}
|
}
|
||||||
|
|
||||||
type VerifyEmailReq struct {
|
type VerifyEmailReq struct {
|
||||||
@ -57,9 +57,9 @@ type OidcLoginReq struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OidcLoginResp struct {
|
type OidcLoginResp struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
RefreshToken string `json:"refresh_token"`
|
RefreshToken string `json:"refresh_token"`
|
||||||
User *UserDto `json:"user"`
|
User UserDto `json:"user"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListOidcConfigResp struct {
|
type ListOidcConfigResp struct {
|
||||||
@ -71,7 +71,7 @@ type GetUserReq struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GetUserResp struct {
|
type GetUserResp struct {
|
||||||
User *UserDto `json:"user"` // 用户信息
|
User UserDto `json:"user"` // 用户信息
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateUserReq struct {
|
type UpdateUserReq struct {
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"github.com/snowykami/neo-blog/internal/dto"
|
"github.com/snowykami/neo-blog/internal/dto"
|
||||||
"github.com/snowykami/neo-blog/pkg/constant"
|
"github.com/snowykami/neo-blog/pkg/constant"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@ -13,10 +14,11 @@ type Post struct {
|
|||||||
Title string `gorm:"type:text;not null"` // 帖子标题
|
Title string `gorm:"type:text;not null"` // 帖子标题
|
||||||
Cover string `gorm:"type:text"` // 帖子封面图
|
Cover string `gorm:"type:text"` // 帖子封面图
|
||||||
Content string `gorm:"type:text;not null"` // 帖子内容
|
Content string `gorm:"type:text;not null"` // 帖子内容
|
||||||
Type string `gorm:"type:text;default:markdown"` // markdown类型,支持markdown或html
|
Type string `gorm:"type:text;default:markdown"` // markdown类型,支持markdown或html或txt
|
||||||
CategoryID uint `gorm:"index"` // 帖子分类ID
|
CategoryID uint `gorm:"index"` // 帖子分类ID
|
||||||
Category Category `gorm:"foreignKey:CategoryID;references:ID"` // 关联的分类
|
Category Category `gorm:"foreignKey:CategoryID;references:ID"` // 关联的分类
|
||||||
Labels []Label `gorm:"many2many:post_labels;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` // 关联的标签
|
Labels []Label `gorm:"many2many:post_labels;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` // 关联的标签
|
||||||
|
IsOriginal bool `gorm:"default:true"` // 是否为原创帖子
|
||||||
IsPrivate bool `gorm:"default:false"`
|
IsPrivate bool `gorm:"default:false"`
|
||||||
LikeCount uint64
|
LikeCount uint64
|
||||||
CommentCount uint64
|
CommentCount uint64
|
||||||
@ -44,26 +46,36 @@ func (p *Post) AfterUpdate(tx *gorm.DB) (err error) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Post) ToDto() dto.PostDto {
|
func (p *Post) ToDto() *dto.PostDto {
|
||||||
return dto.PostDto{
|
fmt.Println("User", p.User)
|
||||||
ID: p.ID,
|
return &dto.PostDto{
|
||||||
UserID: p.UserID,
|
ID: p.ID,
|
||||||
Title: p.Title,
|
UserID: p.UserID,
|
||||||
Content: p.Content,
|
Title: p.Title,
|
||||||
Cover: p.Cover,
|
Content: p.Content,
|
||||||
Type: p.Type,
|
Cover: p.Cover,
|
||||||
IsPrivate: p.IsPrivate,
|
Type: p.Type,
|
||||||
|
IsOriginal: p.IsOriginal,
|
||||||
|
IsPrivate: p.IsPrivate,
|
||||||
|
Labels: func() []dto.LabelDto {
|
||||||
|
labelDtos := make([]dto.LabelDto, len(p.Labels))
|
||||||
|
for i, label := range p.Labels {
|
||||||
|
labelDtos[i] = label.ToDto()
|
||||||
|
}
|
||||||
|
return labelDtos
|
||||||
|
}(),
|
||||||
LikeCount: p.LikeCount,
|
LikeCount: p.LikeCount,
|
||||||
CommentCount: p.CommentCount,
|
CommentCount: p.CommentCount,
|
||||||
ViewCount: p.ViewCount,
|
ViewCount: p.ViewCount,
|
||||||
Heat: p.Heat,
|
Heat: p.Heat,
|
||||||
CreatedAt: p.CreatedAt,
|
CreatedAt: p.CreatedAt,
|
||||||
UpdatedAt: p.UpdatedAt,
|
UpdatedAt: p.UpdatedAt,
|
||||||
|
User: p.User.ToDto(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToDtoWithShortContent 返回一个简化的 DTO,内容可以根据需要截断
|
// ToDtoWithShortContent 返回一个简化的 DTO,内容可以根据需要截断
|
||||||
func (p *Post) ToDtoWithShortContent(contentLength int) dto.PostDto {
|
func (p *Post) ToDtoWithShortContent(contentLength int) *dto.PostDto {
|
||||||
dtoPost := p.ToDto()
|
dtoPost := p.ToDto()
|
||||||
if len(p.Content) > contentLength {
|
if len(p.Content) > contentLength {
|
||||||
dtoPost.Content = p.Content[:contentLength] + "..."
|
dtoPost.Content = p.Content[:contentLength] + "..."
|
||||||
|
@ -25,8 +25,8 @@ type UserOpenID struct {
|
|||||||
Sub string `gorm:"index"` // OIDC Sub openid
|
Sub string `gorm:"index"` // OIDC Sub openid
|
||||||
}
|
}
|
||||||
|
|
||||||
func (user *User) ToDto() *dto.UserDto {
|
func (user *User) ToDto() dto.UserDto {
|
||||||
return &dto.UserDto{
|
return dto.UserDto{
|
||||||
ID: user.ID,
|
ID: user.ID,
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
Nickname: user.Nickname,
|
Nickname: user.Nickname,
|
||||||
|
@ -100,15 +100,15 @@ func (cs *CommentService) GetComment(ctx context.Context, commentID string) (*dt
|
|||||||
}
|
}
|
||||||
|
|
||||||
commentDto := dto.CommentDto{
|
commentDto := dto.CommentDto{
|
||||||
ID: comment.ID,
|
ID: comment.ID,
|
||||||
TargetID: comment.TargetID,
|
TargetID: comment.TargetID,
|
||||||
TargetType: comment.TargetType,
|
TargetType: comment.TargetType,
|
||||||
Content: comment.Content,
|
Content: comment.Content,
|
||||||
ReplyID: comment.ReplyID,
|
ReplyID: comment.ReplyID,
|
||||||
Depth: comment.Depth,
|
Depth: comment.Depth,
|
||||||
CreatedAt: comment.CreatedAt.String(),
|
CreatedAt: comment.CreatedAt.String(),
|
||||||
UpdatedAt: comment.UpdatedAt.String(),
|
UpdatedAt: comment.UpdatedAt.String(),
|
||||||
User: *comment.User.ToDto(),
|
User: comment.User.ToDto(),
|
||||||
}
|
}
|
||||||
|
|
||||||
return &commentDto, err
|
return &commentDto, err
|
||||||
@ -132,8 +132,8 @@ func (cs *CommentService) GetCommentList(ctx context.Context, req *dto.GetCommen
|
|||||||
TargetType: comment.TargetType,
|
TargetType: comment.TargetType,
|
||||||
CreatedAt: comment.CreatedAt.String(),
|
CreatedAt: comment.CreatedAt.String(),
|
||||||
UpdatedAt: comment.UpdatedAt.String(),
|
UpdatedAt: comment.UpdatedAt.String(),
|
||||||
Depth: comment.Depth,
|
Depth: comment.Depth,
|
||||||
User: *comment.User.ToDto(),
|
User: comment.User.ToDto(),
|
||||||
}
|
}
|
||||||
commentDtos = append(commentDtos, commentDto)
|
commentDtos = append(commentDtos, commentDto)
|
||||||
}
|
}
|
||||||
|
@ -76,18 +76,7 @@ func (p *PostService) GetPost(ctx context.Context, id string) (*dto.PostDto, err
|
|||||||
if post.IsPrivate && (!ok || post.UserID != currentUser.ID) {
|
if post.IsPrivate && (!ok || post.UserID != currentUser.ID) {
|
||||||
return nil, errs.ErrForbidden
|
return nil, errs.ErrForbidden
|
||||||
}
|
}
|
||||||
return &dto.PostDto{
|
return post.ToDto(), nil
|
||||||
UserID: post.UserID,
|
|
||||||
Title: post.Title,
|
|
||||||
Content: post.Content,
|
|
||||||
Labels: func() []dto.LabelDto {
|
|
||||||
labelDtos := make([]dto.LabelDto, 0)
|
|
||||||
for _, label := range post.Labels {
|
|
||||||
labelDtos = append(labelDtos, label.ToDto())
|
|
||||||
}
|
|
||||||
return labelDtos
|
|
||||||
}(),
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PostService) UpdatePost(ctx context.Context, id string, req *dto.CreateOrUpdatePostReq) (uint, error) {
|
func (p *PostService) UpdatePost(ctx context.Context, id string, req *dto.CreateOrUpdatePostReq) (uint, error) {
|
||||||
@ -124,8 +113,8 @@ func (p *PostService) UpdatePost(ctx context.Context, id string, req *dto.Create
|
|||||||
return post.ID, nil
|
return post.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PostService) ListPosts(ctx context.Context, req *dto.ListPostReq) ([]dto.PostDto, error) {
|
func (p *PostService) ListPosts(ctx context.Context, req *dto.ListPostReq) ([]*dto.PostDto, error) {
|
||||||
postDtos := make([]dto.PostDto, 0)
|
postDtos := make([]*dto.PostDto, 0)
|
||||||
currentUserID, _ := ctxutils.GetCurrentUserID(ctx)
|
currentUserID, _ := ctxutils.GetCurrentUserID(ctx)
|
||||||
posts, err := repo.Post.ListPosts(currentUserID, req.Keywords, req.Page, req.Size, req.OrderBy, req.Desc)
|
posts, err := repo.Post.ListPosts(currentUserID, req.Keywords, req.Page, req.Size, req.OrderBy, req.Desc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { camelToSnakeObj, snakeToCamelObj } from 'field-conv'
|
import { camelToSnakeObj, snakeToCamelObj } from 'field-conv'
|
||||||
|
|
||||||
export const BACKEND_URL = process.env.BACKEND_URL || 'http://neo-blog-backend:8888'
|
export const BACKEND_URL = process.env.BACKEND_URL || (process.env.NODE_ENV == "production" ? 'http://neo-blog-backend:8888' : 'http://localhost:8888')
|
||||||
|
|
||||||
console.info(`Using backend URL: ${BACKEND_URL}`)
|
console.info(`Using ${process.env.NODE_ENV} backend URL: ${BACKEND_URL}`)
|
||||||
|
|
||||||
const isServer = typeof window === 'undefined'
|
const isServer = typeof window === 'undefined'
|
||||||
|
|
||||||
|
@ -11,9 +11,8 @@ interface ListPostsParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getPostById(id: string): Promise<Post | null> {
|
export async function getPostById(id: string): Promise<Post | null> {
|
||||||
console.log('Fetching post by ID:', id)
|
|
||||||
try {
|
try {
|
||||||
const res = await axiosClient.get<BaseResponse<Post>>(`/post/p/19`)
|
const res = await axiosClient.get<BaseResponse<Post>>(`/post/p/${id}`)
|
||||||
return res.data.data
|
return res.data.data
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
import { Navbar } from '@/components/navbar'
|
import { Navbar } from '@/components/navbar'
|
||||||
|
import { BackgroundProvider } from '@/contexts/background-context'
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
@ -15,22 +16,21 @@ export default function RootLayout({
|
|||||||
<header className="fixed top-0 left-0 w-full z-50 bg-white/80 dark:bg-slate-900/80 backdrop-blur flex justify-center border-b border-slate-200 dark:border-slate-800">
|
<header className="fixed top-0 left-0 w-full z-50 bg-white/80 dark:bg-slate-900/80 backdrop-blur flex justify-center border-b border-slate-200 dark:border-slate-800">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
</header>
|
</header>
|
||||||
<AnimatePresence mode="wait">
|
<motion.main
|
||||||
<motion.main
|
key={pathname}
|
||||||
key={pathname}
|
initial={{ opacity: 0, y: 16 }}
|
||||||
initial={{ opacity: 0, y: 16 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
transition={{
|
||||||
exit={{ opacity: 0, y: 16 }}
|
type: 'tween',
|
||||||
transition={{
|
ease: 'easeOut',
|
||||||
type: 'tween',
|
duration: 0.30,
|
||||||
ease: 'easeOut',
|
}}
|
||||||
duration: 0.18,
|
className="pt-16"
|
||||||
}}
|
>
|
||||||
className="pt-16"
|
<BackgroundProvider>
|
||||||
>
|
<div className='container mx-auto px-4 sm:px-6 lg:px-10 max-w-7xl'>{children}</div>
|
||||||
{children}
|
</BackgroundProvider>
|
||||||
</motion.main>
|
</motion.main>
|
||||||
</AnimatePresence>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
@ -3,6 +3,8 @@ import { Geist, Geist_Mono } from "next/font/google";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { DeviceProvider } from "@/contexts/device-context";
|
import { DeviceProvider } from "@/contexts/device-context";
|
||||||
import { NextIntlClientProvider } from 'next-intl';
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
|
import config from "@/config";
|
||||||
|
import { getUserLocales } from "@/i18n/request";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@ -15,17 +17,17 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: config.metadata.name,
|
||||||
description: "Generated by create next app",
|
description: config.metadata.description,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang={(await getUserLocales())[0] || "en"} className="h-full">
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
|
@ -9,10 +9,11 @@ import type { Label } from "@/models/label";
|
|||||||
import type { Post } from "@/models/post";
|
import type { Post } from "@/models/post";
|
||||||
import { listPosts } from "@/api/post";
|
import { listPosts } from "@/api/post";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useStoredState } from '@/hooks/use-storage-state';
|
import { useStoredState } from '@/hooks/use-storage-state';
|
||||||
import { listLabels } from "@/api/label";
|
import { listLabels } from "@/api/label";
|
||||||
import { POST_SORT_TYPE } from "@/localstore";
|
import { POST_SORT_TYPE } from "@/localstore";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
// 定义排序类型
|
// 定义排序类型
|
||||||
type SortType = 'latest' | 'popular';
|
type SortType = 'latest' | 'popular';
|
||||||
@ -84,7 +85,7 @@ export default function BlogHome() {
|
|||||||
{/* 主内容区域 */}
|
{/* 主内容区域 */}
|
||||||
<section className="py-16">
|
<section className="py-16">
|
||||||
{/* 容器 - 关键布局 */}
|
{/* 容器 - 关键布局 */}
|
||||||
<div className="container mx-auto px-4 sm:px-6 lg:px-10 max-w-7xl">
|
<div className="">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||||
{/* 主要内容区域 */}
|
{/* 主要内容区域 */}
|
||||||
<div className="lg:col-span-3 self-start">
|
<div className="lg:col-span-3 self-start">
|
||||||
|
@ -1,94 +1,99 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import Image from "next/image";
|
|
||||||
import type { Post } from "@/models/post";
|
import type { Post } from "@/models/post";
|
||||||
|
import { Calendar, Clock, FileText, Flame, Heart, MessageCircle, PenLine, SquarePen } from "lucide-react";
|
||||||
|
|
||||||
function WaveHeader({ title }: { title: string }) {
|
function PostMeta({ post }: { post: Post }) {
|
||||||
|
// 假设 post 结构包含这些字段
|
||||||
|
// post.author, post.wordCount, post.readMinutes, post.createdAt, post.isOriginal, post.viewCount, post.commentCount
|
||||||
return (
|
return (
|
||||||
<div className="relative h-64 flex flex-col items-center justify-center">
|
console.log(post),
|
||||||
{/* 波浪SVG,半透明悬浮 */}
|
<div className="flex flex-wrap items-center gap-4 mt-6">
|
||||||
<div className="absolute inset-0 w-full h-full pointer-events-none opacity-70 z-0">
|
{/* 作者 */}
|
||||||
<svg className="w-full h-full" viewBox="0 0 1440 320" preserveAspectRatio="none">
|
<span className="flex items-center gap-1">
|
||||||
<defs>
|
<PenLine className="w-4 h-4" />
|
||||||
<linearGradient id="wave-gradient" x1="0" y1="0" x2="1" y2="1">
|
{post.user.nickname || "未知作者"}
|
||||||
<stop offset="0%" stopColor="#4f8cff" />
|
</span>
|
||||||
<stop offset="100%" stopColor="#7b61ff" />
|
{/* 字数 */}
|
||||||
</linearGradient>
|
<span className="flex items-center gap-1">
|
||||||
</defs>
|
<FileText className="w-4 h-4" />
|
||||||
<path
|
{post.content.length || 0}
|
||||||
fill="url(#wave-gradient)"
|
</span>
|
||||||
fillOpacity="1"
|
{/* 阅读时间 */}
|
||||||
d="
|
<span className="flex items-center gap-1">
|
||||||
M0,160
|
<Clock className="w-4 h-4" />
|
||||||
C360,240 1080,80 1440,160
|
{post.content.length / 100 || 1} 分钟
|
||||||
L1440,320
|
</span>
|
||||||
L0,320
|
{/* 发布时间 */}
|
||||||
Z
|
<span className="flex items-center gap-1">
|
||||||
"
|
<Calendar className="w-4 h-4" />
|
||||||
>
|
{post.createdAt ? new Date(post.createdAt).toLocaleDateString("zh-CN") : ""}
|
||||||
<animate
|
</span>
|
||||||
attributeName="d"
|
{/* 最后编辑时间,如果和发布时间不一样 */}
|
||||||
dur="8s"
|
{post.updatedAt && post.createdAt !== post.updatedAt && (
|
||||||
repeatCount="indefinite"
|
<span className="flex items-center gap-1">
|
||||||
values="
|
<SquarePen className="w-4 h-4" />
|
||||||
M0,160 C360,240 1080,80 1440,160 L1440,320 L0,320 Z;
|
{new Date(post.updatedAt).toLocaleDateString("zh-CN")}
|
||||||
M0,120 C400,200 1040,120 1440,200 L1440,320 L0,320 Z;
|
</span>
|
||||||
M0,160 C360,240 1080,80 1440,160 L1440,320 L0,320 Z
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{/* 标题 */}
|
|
||||||
<h1 className="relative z-10 text-white text-4xl md:text-5xl font-bold drop-shadow-lg mt-16 text-center">
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BlogMeta({ post }: { post: Post }) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center mb-6">
|
|
||||||
<div className="flex gap-2 mb-2">
|
|
||||||
{post.labels?.map(label => (
|
|
||||||
<span
|
|
||||||
key={label.id}
|
|
||||||
className="bg-white/30 px-3 py-1 rounded-full text-sm font-medium backdrop-blur text-white"
|
|
||||||
>
|
|
||||||
{label.key}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-4 text-base text-white/90">
|
|
||||||
<span>发表于 {new Date(post.createdAt).toLocaleDateString()}</span>
|
|
||||||
<span>👁️ {post.viewCount}</span>
|
|
||||||
<span>💬 {post.commentCount}</span>
|
|
||||||
<span>🔥 {post.heat}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BlogContent({ post }: { post: Post }) {
|
|
||||||
return (
|
|
||||||
<main className="relative z-10 max-w-3xl mx-auto bg-white rounded-xl shadow-lg p-8 -mt-32">
|
|
||||||
{post.cover && (
|
|
||||||
<Image
|
|
||||||
src={post.cover}
|
|
||||||
alt="cover"
|
|
||||||
className="w-full h-64 object-cover rounded-lg mb-8"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<article
|
{/* 浏览数 */}
|
||||||
className="prose prose-lg max-w-none"
|
<span className="flex items-center gap-1">
|
||||||
dangerouslySetInnerHTML={{ __html: post.content }}
|
<Flame className="w-4 h-4" />
|
||||||
/>
|
{post.viewCount || 0}
|
||||||
</main>
|
</span>
|
||||||
|
{/* 点赞数 */}
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Heart className="w-4 h-4" />
|
||||||
|
{post.likeCount || 0}
|
||||||
|
</span>
|
||||||
|
{/* 评论数 */}
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<MessageCircle className="w-4 h-4" />
|
||||||
|
{post.commentCount || 0}
|
||||||
|
</span>
|
||||||
|
{/* 热度 */}
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Flame className="w-4 h-4" />
|
||||||
|
{post.heat || 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PostHeader({ post }: { post: Post }) {
|
||||||
|
// 三排 标签/标题/一些元数据
|
||||||
|
return (
|
||||||
|
<div className="py-32">
|
||||||
|
{
|
||||||
|
post.labels && post.labels.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
|
{post.labels.map(label => (
|
||||||
|
<span key={label.id} className="bg-blue-100 text-blue-600 text-xs px-2 py-1 rounded">
|
||||||
|
{label.key}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<h1 className="text-5xl font-bold mb-2">{post.title}</h1>
|
||||||
|
{/* 元数据区 */}
|
||||||
|
<div>
|
||||||
|
<PostMeta post={post} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostContent({ post }: { post: Post }) {
|
||||||
|
return (
|
||||||
|
<div className="">
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: post.content }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function BlogPost({ post }: { post: Post }) {
|
function BlogPost({ post }: { post: Post }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
@ -96,11 +101,10 @@ function BlogPost({ post }: { post: Post }) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 relative">
|
<div className="">
|
||||||
<WaveHeader title={post.title} />
|
<PostHeader post={post} />
|
||||||
<div className="relative z-10 -mt-40">
|
<div className="">
|
||||||
<BlogMeta post={post} />
|
<PostContent post={post} />
|
||||||
<BlogContent post={post} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -44,8 +44,9 @@ const navbarMenuComponents = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
|
const { navbarAdditionalClassName } = useDevice()
|
||||||
return (
|
return (
|
||||||
<nav className="grid grid-cols-[1fr_auto_1fr] items-center gap-4 h-16 px-4 w-full">
|
<nav className={`grid grid-cols-[1fr_auto_1fr] items-center gap-4 h-16 px-4 w-full ${navbarAdditionalClassName}`}>
|
||||||
<div className="flex items-center justify-start">
|
<div className="flex items-center justify-start">
|
||||||
<span className="font-bold truncate">{config.metadata.name}</span>
|
<span className="font-bold truncate">{config.metadata.name}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -62,7 +63,6 @@ export function Navbar() {
|
|||||||
|
|
||||||
function NavMenuCenter() {
|
function NavMenuCenter() {
|
||||||
const { isMobile } = useDevice()
|
const { isMobile } = useDevice()
|
||||||
console.log("isMobile", isMobile)
|
|
||||||
if (isMobile) return null
|
if (isMobile) return null
|
||||||
return (
|
return (
|
||||||
<NavigationMenu viewport={false}>
|
<NavigationMenu viewport={false}>
|
||||||
@ -121,10 +121,10 @@ function ListItem({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenuClientOnly() {
|
function SidebarMenuClientOnly() {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
useEffect(() => setMounted(true), []);
|
useEffect(() => setMounted(true), []);
|
||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
return <SidebarMenu />;
|
return <SidebarMenu />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenu() {
|
function SidebarMenu() {
|
||||||
|
@ -11,7 +11,9 @@ const config = {
|
|||||||
motto: "And now that story unfolds into a journey that, alone, I set out to",
|
motto: "And now that story unfolds into a journey that, alone, I set out to",
|
||||||
avatar: "https://cdn.liteyuki.org/snowykami/avatar.jpg",
|
avatar: "https://cdn.liteyuki.org/snowykami/avatar.jpg",
|
||||||
gravatarEmail: "snowykami@outlook.com"
|
gravatarEmail: "snowykami@outlook.com"
|
||||||
}
|
},
|
||||||
|
bodyWidth: "80vw",
|
||||||
|
bodyWidthMobile: "100vw",
|
||||||
}
|
}
|
||||||
|
|
||||||
export default config
|
export default config
|
60
web/src/contexts/background-context.tsx
Normal file
60
web/src/contexts/background-context.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import React, { createContext, useContext, useState, useCallback } from "react";
|
||||||
|
|
||||||
|
interface BackgroundContextType {
|
||||||
|
background: React.ReactNode;
|
||||||
|
setBackground: (bg: React.ReactNode) => void;
|
||||||
|
resetBackground: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BackgroundContext = createContext<BackgroundContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const BackgroundProvider: React.FC<{ children: React.ReactNode; defaultBackground?: React.ReactNode }> = ({
|
||||||
|
children,
|
||||||
|
defaultBackground = null,
|
||||||
|
}) => {
|
||||||
|
const [background, setBackgroundState] = useState<React.ReactNode>(defaultBackground);
|
||||||
|
|
||||||
|
const setBackground = useCallback((bg: React.ReactNode) => {
|
||||||
|
setBackgroundState(bg);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetBackground = useCallback(() => {
|
||||||
|
setBackgroundState(defaultBackground);
|
||||||
|
}, [defaultBackground]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackgroundContext.Provider value={{ background, setBackground, resetBackground }}>
|
||||||
|
{/* 背景节点自动渲染在 children 之下 */}
|
||||||
|
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
||||||
|
{background && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{background}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ position: "relative", zIndex: 1 }}>{children}</div>
|
||||||
|
</div>
|
||||||
|
</BackgroundContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useBackground() {
|
||||||
|
const ctx = useContext(BackgroundContext);
|
||||||
|
if (!ctx) throw new Error("useBackground must be used within a BackgroundProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 便于直接解构使用
|
||||||
|
export const backgroundContext = {
|
||||||
|
useBackground,
|
||||||
|
BackgroundProvider,
|
||||||
|
};
|
@ -13,6 +13,8 @@ interface DeviceContextProps {
|
|||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
};
|
};
|
||||||
|
navbarAdditionalClassName?: string; // 可选属性,允许传入额外的类名
|
||||||
|
setNavbarAdditionalClassName?: (className: string) => void; // 可选方法,允许设置额外的类名
|
||||||
}
|
}
|
||||||
|
|
||||||
const DeviceContext = createContext<DeviceContextProps>({
|
const DeviceContext = createContext<DeviceContextProps>({
|
||||||
@ -24,6 +26,8 @@ const DeviceContext = createContext<DeviceContextProps>({
|
|||||||
width: 0,
|
width: 0,
|
||||||
height: 0,
|
height: 0,
|
||||||
},
|
},
|
||||||
|
navbarAdditionalClassName: "",
|
||||||
|
setNavbarAdditionalClassName: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
@ -33,6 +37,7 @@ export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
|||||||
width: typeof window !== "undefined" ? window.innerWidth : 0,
|
width: typeof window !== "undefined" ? window.innerWidth : 0,
|
||||||
height: typeof window !== "undefined" ? window.innerHeight : 0,
|
height: typeof window !== "undefined" ? window.innerHeight : 0,
|
||||||
});
|
});
|
||||||
|
const [navbarAdditionalClassName, setNavbarAdditionalClassName] = useState<string>("");
|
||||||
|
|
||||||
// 检查系统主题
|
// 检查系统主题
|
||||||
const getSystemTheme = () =>
|
const getSystemTheme = () =>
|
||||||
@ -109,7 +114,7 @@ export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DeviceContext.Provider
|
<DeviceContext.Provider
|
||||||
value={{ isMobile, mode, setMode, toggleMode, viewport }}
|
value={{ isMobile, mode, setMode, toggleMode, viewport, navbarAdditionalClassName, setNavbarAdditionalClassName }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</DeviceContext.Provider>
|
</DeviceContext.Provider>
|
||||||
|
@ -10,7 +10,8 @@ export default getRequestConfig(async () => {
|
|||||||
try {
|
try {
|
||||||
return (await import(`@/locales/${locale}.json`)).default;
|
return (await import(`@/locales/${locale}.json`)).default;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.warn();
|
||||||
|
(err)
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import type { Label } from "@/models/label";
|
import type { Label } from "@/models/label";
|
||||||
|
import type { User } from "./user";
|
||||||
|
|
||||||
export interface Post {
|
export interface Post {
|
||||||
id: number;
|
id: number;
|
||||||
@ -7,7 +8,9 @@ export interface Post {
|
|||||||
cover: string | null; // 封面可以为空
|
cover: string | null; // 封面可以为空
|
||||||
type: "markdown" | "html" | "text";
|
type: "markdown" | "html" | "text";
|
||||||
labels: Label[] | null; // 标签可以为空
|
labels: Label[] | null; // 标签可以为空
|
||||||
|
user: User
|
||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
|
isOriginal: boolean;
|
||||||
likeCount: number;
|
likeCount: number;
|
||||||
commentCount: number;
|
commentCount: number;
|
||||||
viewCount: number;
|
viewCount: number;
|
||||||
|
Reference in New Issue
Block a user