mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-04 00:06:22 +00:00
✨ 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:
@ -31,6 +31,7 @@ EMAIL_PORT=465
|
|||||||
EMAIL_SSL=true
|
EMAIL_SSL=true
|
||||||
|
|
||||||
# App settings 应用程序配置
|
# App settings 应用程序配置
|
||||||
|
LOG_LEVEL=debug
|
||||||
BASE_URL=https://blog.shenyu.moe
|
BASE_URL=https://blog.shenyu.moe
|
||||||
MAX_REQUEST_BODY_SIZE=1000000
|
MAX_REQUEST_BODY_SIZE=1000000
|
||||||
MODE=prod
|
MODE=prod
|
||||||
|
@ -13,7 +13,7 @@ func SetTokenCookie(c *app.RequestContext, token string) {
|
|||||||
|
|
||||||
func SetTokenAndRefreshTokenCookie(c *app.RequestContext, token, refreshToken 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("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) {
|
func ClearTokenAndRefreshTokenCookie(c *app.RequestContext) {
|
||||||
|
@ -352,12 +352,12 @@ func (s *UserService) UpdateUser(req *dto.UpdateUserReq) (*dto.UpdateUserResp, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) generate2Token(userID uint) (string, string, error) {
|
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()
|
tokenString, err := token.ToString()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", errs.ErrInternalServer
|
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()
|
refreshTokenString, err := refreshToken.ToString()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", errs.ErrInternalServer
|
return "", "", errs.ErrInternalServer
|
||||||
|
@ -11,11 +11,13 @@ const (
|
|||||||
RoleUser = "user"
|
RoleUser = "user"
|
||||||
RoleAdmin = "admin"
|
RoleAdmin = "admin"
|
||||||
EnvKeyBaseUrl = "BASE_URL" // 环境变量:基础URL
|
EnvKeyBaseUrl = "BASE_URL" // 环境变量:基础URL
|
||||||
|
EnvKeyLogLevel = "LOG_LEVEL" // 环境变量:日志级别
|
||||||
EnvKeyMode = "MODE" // 环境变量:运行模式
|
EnvKeyMode = "MODE" // 环境变量:运行模式
|
||||||
EnvKeyJwtSecrete = "JWT_SECRET" // 环境变量:JWT密钥
|
EnvKeyJwtSecrete = "JWT_SECRET" // 环境变量:JWT密钥
|
||||||
EnvKeyPasswordSalt = "PASSWORD_SALT" // 环境变量:密码盐
|
EnvKeyPasswordSalt = "PASSWORD_SALT" // 环境变量:密码盐
|
||||||
EnvKeyTokenDuration = "TOKEN_DURATION" // 环境变量:令牌有效期
|
EnvKeyTokenDuration = "TOKEN_DURATION" // 环境变量:令牌有效期
|
||||||
EnvKeyTokenDurationDefault = 300
|
EnvKeyTokenDurationDefault = 300
|
||||||
|
EnvKeyRefreshTokenDurationDefault = 604800
|
||||||
EnvKeyRefreshTokenDuration = "REFRESH_TOKEN_DURATION" // 环境变量:刷新令牌有效期
|
EnvKeyRefreshTokenDuration = "REFRESH_TOKEN_DURATION" // 环境变量:刷新令牌有效期
|
||||||
EnvKeyRefreshTokenDurationWithRemember = "REFRESH_TOKEN_DURATION_WITH_REMEMBER" // 环境变量:记住我刷新令牌有效期
|
EnvKeyRefreshTokenDurationWithRemember = "REFRESH_TOKEN_DURATION_WITH_REMEMBER" // 环境变量:记住我刷新令牌有效期
|
||||||
KVKeyEmailVerificationCode = "email_verification_code:" // KV存储:邮箱验证码
|
KVKeyEmailVerificationCode = "email_verification_code:" // KV存储:邮箱验证码
|
||||||
@ -40,6 +42,6 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
OrderByEnumPost = []string{OrderByCreatedAt, OrderByUpdatedAt, OrderByLikeCount, OrderByCommentCount, OrderByViewCount, OrderByHeat} // 帖子可用的排序方式
|
OrderByEnumPost = []string{OrderByCreatedAt, OrderByUpdatedAt, OrderByLikeCount, OrderByCommentCount, OrderByViewCount, OrderByHeat} // 帖子可用的排序方式
|
||||||
OrderByEnumComment = []string{OrderByCreatedAt, OrderByUpdatedAt, OrderByCommentCount} // 评论可用的排序方式
|
OrderByEnumComment = []string{OrderByCreatedAt, OrderByUpdatedAt, OrderByCommentCount} // 评论可用的排序方式
|
||||||
)
|
)
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
package utils
|
package utils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/snowykami/neo-blog/pkg/constant"
|
"github.com/snowykami/neo-blog/pkg/constant"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
@ -12,10 +14,26 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
_ = godotenv.Load()
|
err := godotenv.Load()
|
||||||
|
if err != nil {
|
||||||
|
logrus.Warnf("Error loading .env file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logrus.Infof("env loaded")
|
||||||
// Init env
|
// Init env
|
||||||
IsDevMode = Env.Get(constant.EnvKeyMode, constant.ModeProd) == constant.ModeDev
|
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{}
|
type envUtils struct{}
|
||||||
@ -56,3 +74,23 @@ func (e *envUtils) GetAsBool(key string, defaultValue ...bool) bool {
|
|||||||
}
|
}
|
||||||
return boolValue
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -13,6 +13,7 @@
|
|||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.13",
|
"@radix-ui/react-navigation-menu": "^1.2.13",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@ -22,14 +23,13 @@
|
|||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"next": "15.4.1",
|
"next": "15.4.1",
|
||||||
"next-intl": "^4.3.4",
|
"next-intl": "^4.3.4",
|
||||||
|
"next-mdx-remote-client": "^2.1.3",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"tailwind-merge": "^3.3.1"
|
"tailwind-merge": "^3.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@antfu/eslint-config": "^5.0.0",
|
|
||||||
"@eslint/eslintrc": "^3",
|
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
|
821
web/pnpm-lock.yaml
generated
821
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -10,9 +10,13 @@ interface ListPostsParams {
|
|||||||
keywords?: string
|
keywords?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPostById(id: string): Promise<Post | null> {
|
export async function getPostById(id: string, token: string=""): Promise<Post | null> {
|
||||||
try {
|
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
|
return res.data.data
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { getPostById } from '@/api/post'
|
import { getPostById } from '@/api/post'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
import BlogPost from '@/components/blog-post'
|
import BlogPost from '@/components/blog-post'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -6,8 +7,9 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function PostPage({ params }: Props) {
|
export default async function PostPage({ params }: Props) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const post = await getPostById(id)
|
const post = await getPostById(id, cookieStore.get('token')?.value || '');
|
||||||
if (!post)
|
if (!post)
|
||||||
return <div>文章不存在</div>
|
return <div>文章不存在</div>
|
||||||
return <BlogPost post={post} />
|
return <BlogPost post={post} />
|
||||||
|
@ -120,3 +120,7 @@
|
|||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
transition: background-color 0.3s, color 0.3s !important;
|
||||||
|
}
|
0
web/src/components/blog-comment.tsx
Normal file
0
web/src/components/blog-comment.tsx
Normal file
@ -9,11 +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';
|
||||||
@ -23,7 +23,6 @@ export default function BlogHome() {
|
|||||||
const [posts, setPosts] = useState<Post[]>([]);
|
const [posts, setPosts] = useState<Post[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [sortType, setSortType, sortTypeLoaded] = useStoredState<SortType>(POST_SORT_TYPE, 'latest');
|
const [sortType, setSortType, sortTypeLoaded] = useStoredState<SortType>(POST_SORT_TYPE, 'latest');
|
||||||
|
|
||||||
// 根据排序类型和防抖后的搜索关键词获取文章
|
// 根据排序类型和防抖后的搜索关键词获取文章
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sortTypeLoaded) return;
|
if (!sortTypeLoaded) return;
|
||||||
|
94
web/src/components/blog-post-header.client.tsx
Normal file
94
web/src/components/blog-post-header.client.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -1,105 +1,38 @@
|
|||||||
"use client";
|
import { Suspense } from "react";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import type { Post } from "@/models/post";
|
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 结构包含这些字段
|
async function PostContent({ post }: { post: Post }) {
|
||||||
// post.author, post.wordCount, post.readMinutes, post.createdAt, post.isOriginal, post.viewCount, post.commentCount
|
|
||||||
return (
|
return (
|
||||||
console.log(post),
|
<div className="py-12 px-6">
|
||||||
<div className="flex flex-wrap items-center gap-4 mt-6">
|
{post.type === "html" && (
|
||||||
{/* 作者 */}
|
<div
|
||||||
<span className="flex items-center gap-1">
|
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"
|
||||||
<PenLine className="w-4 h-4" />
|
dangerouslySetInnerHTML={{ __html: post.content }}
|
||||||
{post.user.nickname || "未知作者"}
|
/>
|
||||||
</span>
|
)}
|
||||||
{/* 字数 */}
|
{post.type === "markdown" && (
|
||||||
<span className="flex items-center gap-1">
|
<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">
|
||||||
<FileText className="w-4 h-4" />
|
<Suspense>
|
||||||
{post.content.length || 0}
|
<MDXRemote
|
||||||
</span>
|
source={post.content}
|
||||||
{/* 阅读时间 */}
|
/>
|
||||||
<span className="flex items-center gap-1">
|
</Suspense>
|
||||||
<Clock className="w-4 h-4" />
|
</div>
|
||||||
{post.content.length / 100 || 1} 分钟
|
)}
|
||||||
</span>
|
{post.type === "text" && (
|
||||||
{/* 发布时间 */}
|
<div className="text-xl text-slate-700 dark:text-slate-300 my-6">
|
||||||
<span className="flex items-center gap-1">
|
{post.content}
|
||||||
<Calendar className="w-4 h-4" />
|
</div>
|
||||||
{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>
|
</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 }) {
|
async function BlogPost({ 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" });
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
return (
|
return (
|
||||||
<div className="">
|
<div className="">
|
||||||
<PostHeader post={post} />
|
<PostHeader post={post} />
|
||||||
|
@ -18,6 +18,7 @@ import config from "@/config"
|
|||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
|
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
|
||||||
import { Menu } from "lucide-react"
|
import { Menu } from "lucide-react"
|
||||||
|
import { Switch } from "./ui/switch"
|
||||||
|
|
||||||
const navbarMenuComponents = [
|
const navbarMenuComponents = [
|
||||||
{
|
{
|
||||||
@ -44,7 +45,7 @@ const navbarMenuComponents = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const { navbarAdditionalClassName } = useDevice()
|
const { navbarAdditionalClassName, setMode, mode } = useDevice()
|
||||||
return (
|
return (
|
||||||
<nav className={`grid grid-cols-[1fr_auto_1fr] items-center gap-4 h-16 px-4 w-full ${navbarAdditionalClassName}`}>
|
<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">
|
||||||
@ -54,6 +55,7 @@ export function Navbar() {
|
|||||||
<NavMenuCenter />
|
<NavMenuCenter />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end space-x-2">
|
<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" />
|
<GravatarAvatar email="snowykami@outlook.com" />
|
||||||
<SidebarMenuClientOnly />
|
<SidebarMenuClientOnly />
|
||||||
</div>
|
</div>
|
||||||
|
31
web/src/components/ui/switch.tsx
Normal file
31
web/src/components/ui/switch.tsx
Normal 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 }
|
@ -2,6 +2,7 @@ import type { Label } from "@/models/label";
|
|||||||
import type { User } from "./user";
|
import type { User } from "./user";
|
||||||
|
|
||||||
export interface Post {
|
export interface Post {
|
||||||
|
description: string;
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
Reference in New Issue
Block a user