feat: enhance post retrieval with authorization token

- Updated `getPostById` function to accept an optional authorization token.
- Modified `PostPage` to retrieve the token from cookies and pass it to the API call.
- Added smooth transition effects for background and text colors in `globals.css`.
- Cleaned up imports and formatting in `blog-home.tsx`.
- Refactored `blog-post.tsx` to use `MDXRemote` for rendering markdown content.
- Introduced `blog-comment.tsx` and `blog-post-header.client.tsx` components for better structure.
- Added a switch component for dark/light mode toggle in the navbar.
- Updated `Post` model to include a description field.
This commit is contained in:
2025-07-28 08:30:37 +08:00
parent d73ed493be
commit 89e2fbe0b9
17 changed files with 1040 additions and 108 deletions

View File

@ -31,6 +31,7 @@ EMAIL_PORT=465
EMAIL_SSL=true
# App settings 应用程序配置
LOG_LEVEL=debug
BASE_URL=https://blog.shenyu.moe
MAX_REQUEST_BODY_SIZE=1000000
MODE=prod

View File

@ -13,7 +13,7 @@ func SetTokenCookie(c *app.RequestContext, token string) {
func SetTokenAndRefreshTokenCookie(c *app.RequestContext, token, refreshToken string) {
c.SetCookie("token", token, utils.Env.GetAsInt(constant.EnvKeyTokenDuration, constant.EnvKeyTokenDurationDefault), "/", "", protocol.CookieSameSiteLaxMode, true, true)
c.SetCookie("refresh_token", refreshToken, -1, "/", "", protocol.CookieSameSiteLaxMode, true, true)
c.SetCookie("refresh_token", refreshToken, utils.Env.GetAsInt(constant.EnvKeyRefreshTokenDuration, constant.EnvKeyRefreshTokenDurationDefault), "/", "", protocol.CookieSameSiteLaxMode, true, true)
}
func ClearTokenAndRefreshTokenCookie(c *app.RequestContext) {

View File

@ -352,12 +352,12 @@ func (s *UserService) UpdateUser(req *dto.UpdateUserReq) (*dto.UpdateUserResp, e
}
func (s *UserService) generate2Token(userID uint) (string, string, error) {
token := utils.Jwt.NewClaims(userID, "", false, time.Duration(utils.Env.GetAsInt(constant.EnvKeyTokenDuration, 24)*int(time.Hour)))
token := utils.Jwt.NewClaims(userID, "", false, time.Duration(utils.Env.GetAsInt(constant.EnvKeyTokenDuration, constant.EnvKeyTokenDurationDefault)*int(time.Second)))
tokenString, err := token.ToString()
if err != nil {
return "", "", errs.ErrInternalServer
}
refreshToken := utils.Jwt.NewClaims(userID, utils.Strings.GenerateRandomString(64), true, time.Duration(utils.Env.GetAsInt(constant.EnvKeyRefreshTokenDuration, 30)*int(time.Hour)))
refreshToken := utils.Jwt.NewClaims(userID, utils.Strings.GenerateRandomString(64), true, time.Duration(utils.Env.GetAsInt(constant.EnvKeyRefreshTokenDuration, constant.EnvKeyRefreshTokenDurationDefault)*int(time.Second)))
refreshTokenString, err := refreshToken.ToString()
if err != nil {
return "", "", errs.ErrInternalServer

View File

@ -11,11 +11,13 @@ const (
RoleUser = "user"
RoleAdmin = "admin"
EnvKeyBaseUrl = "BASE_URL" // 环境变量基础URL
EnvKeyLogLevel = "LOG_LEVEL" // 环境变量:日志级别
EnvKeyMode = "MODE" // 环境变量:运行模式
EnvKeyJwtSecrete = "JWT_SECRET" // 环境变量JWT密钥
EnvKeyPasswordSalt = "PASSWORD_SALT" // 环境变量:密码盐
EnvKeyTokenDuration = "TOKEN_DURATION" // 环境变量:令牌有效期
EnvKeyTokenDurationDefault = 300
EnvKeyRefreshTokenDurationDefault = 604800
EnvKeyRefreshTokenDuration = "REFRESH_TOKEN_DURATION" // 环境变量:刷新令牌有效期
EnvKeyRefreshTokenDurationWithRemember = "REFRESH_TOKEN_DURATION_WITH_REMEMBER" // 环境变量:记住我刷新令牌有效期
KVKeyEmailVerificationCode = "email_verification_code:" // KV存储邮箱验证码
@ -40,6 +42,6 @@ const (
)
var (
OrderByEnumPost = []string{OrderByCreatedAt, OrderByUpdatedAt, OrderByLikeCount, OrderByCommentCount, OrderByViewCount, OrderByHeat} // 帖子可用的排序方式
OrderByEnumComment = []string{OrderByCreatedAt, OrderByUpdatedAt, OrderByCommentCount} // 评论可用的排序方式
OrderByEnumPost = []string{OrderByCreatedAt, OrderByUpdatedAt, OrderByLikeCount, OrderByCommentCount, OrderByViewCount, OrderByHeat} // 帖子可用的排序方式
OrderByEnumComment = []string{OrderByCreatedAt, OrderByUpdatedAt, OrderByCommentCount} // 评论可用的排序方式
)

View File

@ -1,7 +1,9 @@
package utils
import (
"fmt"
"github.com/joho/godotenv"
"github.com/sirupsen/logrus"
"github.com/snowykami/neo-blog/pkg/constant"
"os"
"strconv"
@ -12,10 +14,26 @@ var (
)
func init() {
_ = godotenv.Load()
err := godotenv.Load()
if err != nil {
logrus.Warnf("Error loading .env file: %v", err)
}
logrus.Infof("env loaded")
// Init env
IsDevMode = Env.Get(constant.EnvKeyMode, constant.ModeProd) == constant.ModeDev
// Set log level
logrus.SetLevel(getLogLevel(Env.Get(constant.EnvKeyLogLevel, "info")))
if logrus.GetLevel() == logrus.DebugLevel {
logrus.Debug("Debug mode is enabled, printing environment variables:")
for _, e := range os.Environ() {
if len(e) > 0 && e[0] == '_' {
// Skip environment variables that start with '_'
continue
}
fmt.Printf("%s ", e)
}
}
}
type envUtils struct{}
@ -56,3 +74,23 @@ func (e *envUtils) GetAsBool(key string, defaultValue ...bool) bool {
}
return boolValue
}
func getLogLevel(levelString string) logrus.Level {
switch levelString {
case "debug":
return logrus.DebugLevel
case "info":
return logrus.InfoLevel
case "warn":
return logrus.WarnLevel
case "error":
return logrus.ErrorLevel
case "fatal":
return logrus.FatalLevel
case "panic":
return logrus.PanicLevel
default:
logrus.Warnf("Unknown log level: %s, defaulting to InfoLevel", levelString)
return logrus.InfoLevel
}
}

View File

@ -13,6 +13,7 @@
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-navigation-menu": "^1.2.13",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"axios": "^1.11.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -22,14 +23,13 @@
"lucide-react": "^0.525.0",
"next": "15.4.1",
"next-intl": "^4.3.4",
"next-mdx-remote-client": "^2.1.3",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-icons": "^5.5.0",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@antfu/eslint-config": "^5.0.0",
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",

821
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -10,9 +10,13 @@ interface ListPostsParams {
keywords?: string
}
export async function getPostById(id: string): Promise<Post | null> {
export async function getPostById(id: string, token: string=""): Promise<Post | null> {
try {
const res = await axiosClient.get<BaseResponse<Post>>(`/post/p/${id}`)
const res = await axiosClient.get<BaseResponse<Post>>(`/post/p/${id}`, {
headers: {
Authorization: `Bearer ${token}`
}
})
return res.data.data
}
catch (error) {

View File

@ -1,4 +1,5 @@
import { getPostById } from '@/api/post'
import { cookies } from 'next/headers'
import BlogPost from '@/components/blog-post'
interface Props {
@ -6,8 +7,9 @@ interface Props {
}
export default async function PostPage({ params }: Props) {
const cookieStore = await cookies();
const { id } = await params
const post = await getPostById(id)
const post = await getPostById(id, cookieStore.get('token')?.value || '');
if (!post)
return <div></div>
return <BlogPost post={post} />

View File

@ -120,3 +120,7 @@
@apply bg-background text-foreground;
}
}
html, body {
transition: background-color 0.3s, color 0.3s !important;
}

View File

View File

@ -9,11 +9,11 @@ import type { Label } from "@/models/label";
import type { Post } from "@/models/post";
import { listPosts } from "@/api/post";
import { useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { useStoredState } from '@/hooks/use-storage-state';
import { listLabels } from "@/api/label";
import { POST_SORT_TYPE } from "@/localstore";
import Image from "next/image";
// 定义排序类型
type SortType = 'latest' | 'popular';
@ -23,7 +23,6 @@ export default function BlogHome() {
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(false);
const [sortType, setSortType, sortTypeLoaded] = useStoredState<SortType>(POST_SORT_TYPE, 'latest');
// 根据排序类型和防抖后的搜索关键词获取文章
useEffect(() => {
if (!sortTypeLoaded) return;

View File

@ -0,0 +1,94 @@
"use client"
import { useBackground } from "@/contexts/background-context";
import type { Post } from "@/models/post";
import { useEffect, useRef,Suspense } from "react";
import { Calendar, Clock, FileText, Flame, Heart, MessageCircle, PenLine, SquarePen } from "lucide-react";
function PostMeta({ post }: { post: Post }) {
return (
<div className="flex flex-wrap items-center gap-4 mt-6 text-secondary">
{/* 作者 */}
<span className="flex items-center gap-1">
<PenLine className="w-4 h-4" />
{post.user.nickname || "未知作者"}
</span>
{/* 字数 */}
<span className="flex items-center gap-1">
<FileText className="w-4 h-4" />
{post.content.length || 0}
</span>
{/* 阅读时间 */}
<span className="flex items-center gap-1">
<Clock className="w-4 h-4" />
{post.content.length / 100 || 1}
</span>
{/* 发布时间 */}
<span className="flex items-center gap-1">
<Calendar className="w-4 h-4" />
{post.createdAt ? new Date(post.createdAt).toLocaleDateString("zh-CN") : ""}
</span>
{/* 最后编辑时间,如果和发布时间不一样 */}
{post.updatedAt && post.createdAt !== post.updatedAt && (
<span className="flex items-center gap-1">
<SquarePen className="w-4 h-4" />
{new Date(post.updatedAt).toLocaleDateString("zh-CN")}
</span>
)}
{/* 浏览数 */}
<span className="flex items-center gap-1">
<Flame className="w-4 h-4" />
{post.viewCount || 0}
</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>
);
}
export default function PostHeader({ post }: { post: Post }) {
const { setBackground } = useBackground();
const headerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (headerRef.current) {
const { clientHeight } = headerRef.current;
setBackground(<div className={`bg-gradient-to-br from-blue-700 to-purple-700 dark:from-blue-500 dark:to-purple-500`} style={{ height: clientHeight }} />);
}
}, [headerRef, setBackground]);
return (
<div className="py-32" ref={headerRef}>
{(post.labels || post.isOriginal) && (
<div className="flex flex-wrap gap-2 mb-4">
{post.isOriginal && (
<span className="bg-green-100 text-green-600 text-xs px-2 py-1 rounded">
</span>
)}
{(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 text-primary-foreground">{post.title}</h1>
{/* 元数据区 */}
<div>
<PostMeta post={post} />
</div>
</div>
);
}

View File

@ -1,105 +1,38 @@
"use client";
import { useEffect } from "react";
import { Suspense } from "react";
import type { Post } from "@/models/post";
import { Calendar, Clock, FileText, Flame, Heart, MessageCircle, PenLine, SquarePen } from "lucide-react";
import { MDXRemote } from "next-mdx-remote-client/rsc";
import PostHeader from "@/components/blog-post-header.client";
function PostMeta({ post }: { post: Post }) {
// 假设 post 结构包含这些字段
// post.author, post.wordCount, post.readMinutes, post.createdAt, post.isOriginal, post.viewCount, post.commentCount
async function PostContent({ post }: { post: Post }) {
return (
console.log(post),
<div className="flex flex-wrap items-center gap-4 mt-6">
{/* 作者 */}
<span className="flex items-center gap-1">
<PenLine className="w-4 h-4" />
{post.user.nickname || "未知作者"}
</span>
{/* 字数 */}
<span className="flex items-center gap-1">
<FileText className="w-4 h-4" />
{post.content.length || 0}
</span>
{/* 阅读时间 */}
<span className="flex items-center gap-1">
<Clock className="w-4 h-4" />
{post.content.length / 100 || 1}
</span>
{/* 发布时间 */}
<span className="flex items-center gap-1">
<Calendar className="w-4 h-4" />
{post.createdAt ? new Date(post.createdAt).toLocaleDateString("zh-CN") : ""}
</span>
{/* 最后编辑时间,如果和发布时间不一样 */}
{post.updatedAt && post.createdAt !== post.updatedAt && (
<span className="flex items-center gap-1">
<SquarePen className="w-4 h-4" />
{new Date(post.updatedAt).toLocaleDateString("zh-CN")}
</span>
<div className="py-12 px-6">
{post.type === "html" && (
<div
className="prose prose-lg max-w-none dark:prose-invert [&_h1]:text-5xl [&_h2]:text-4xl [&_h3]:text-3xl [&_h4]:text-2xl [&_h5]:text-xl [&_h6]:text-lg [&_p]:text-xl [&_p]:my-6 [&_ul]:my-6 [&_ol]:my-6 [&_pre]:my-8 [&_blockquote]:my-8"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
)}
{post.type === "markdown" && (
<div className="prose prose-lg max-w-none dark:prose-invert [&_h1]:text-5xl [&_h2]:text-4xl [&_h3]:text-3xl [&_h4]:text-2xl [&_h5]:text-xl [&_h6]:text-lg [&_p]:text-xl [&_p]:my-6 [&_ul]:my-6 [&_ol]:my-6 [&_pre]:my-8 [&_blockquote]:my-8">
<Suspense>
<MDXRemote
source={post.content}
/>
</Suspense>
</div>
)}
{post.type === "text" && (
<div className="text-xl text-slate-700 dark:text-slate-300 my-6">
{post.content}
</div>
)}
{/* 浏览数 */}
<span className="flex items-center gap-1">
<Flame className="w-4 h-4" />
{post.viewCount || 0}
</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 }) {
useEffect(() => {
if (typeof window !== "undefined") {
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
}
}, []);
async function BlogPost({ post }: { post: Post }) {
return (
<div className="">
<PostHeader post={post} />

View File

@ -18,6 +18,7 @@ import config from "@/config"
import { useState, useEffect } from "react"
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
import { Menu } from "lucide-react"
import { Switch } from "./ui/switch"
const navbarMenuComponents = [
{
@ -44,7 +45,7 @@ const navbarMenuComponents = [
]
export function Navbar() {
const { navbarAdditionalClassName } = useDevice()
const { navbarAdditionalClassName, setMode, mode } = useDevice()
return (
<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">
@ -54,6 +55,7 @@ export function Navbar() {
<NavMenuCenter />
</div>
<div className="flex items-center justify-end space-x-2">
<Switch checked={mode === "dark"} onCheckedChange={(checked) => setMode(checked ? "dark" : "light")} />
<GravatarAvatar email="snowykami@outlook.com" />
<SidebarMenuClientOnly />
</div>

View File

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@ -2,6 +2,7 @@ import type { Label } from "@/models/label";
import type { User } from "./user";
export interface Post {
description: string;
id: number;
title: string;
content: string;