diff --git a/public/svg/check-dark.svg b/public/svg/check-dark.svg new file mode 100644 index 0000000..d4c5fa8 --- /dev/null +++ b/public/svg/check-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/svg/check.svg b/public/svg/check.svg index c41d431..f95f74f 100644 --- a/public/svg/check.svg +++ b/public/svg/check.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/svg/copy-dark.svg b/public/svg/copy-dark.svg new file mode 100644 index 0000000..f0c4615 --- /dev/null +++ b/public/svg/copy-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/svg/copy.svg b/public/svg/copy.svg index 97c5e85..55c7a0e 100644 --- a/public/svg/copy.svg +++ b/public/svg/copy.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/app/blog/[id]/BlogContent.tsx b/src/app/blog/[id]/BlogContent.tsx new file mode 100644 index 0000000..32caad4 --- /dev/null +++ b/src/app/blog/[id]/BlogContent.tsx @@ -0,0 +1,32 @@ +import { MDXRemote } from "next-mdx-remote/rsc"; +import { MDXComponents } from "./types"; + +import remarkGfm from "remark-gfm"; +import rehypeInlineCode from "@/lib/rehypeInlineCode"; +import rehypeSlug from "rehype-slug"; +import rehypeAutolinkHeadings from "rehype-autolink-headings"; + +interface BlogContentProps { + content: string; + components: MDXComponents; +} + +export default function BlogContent({ content, components }: BlogContentProps) { + return ( + + ); +} diff --git a/src/app/blog/[id]/BlogFooter.tsx b/src/app/blog/[id]/BlogFooter.tsx new file mode 100644 index 0000000..c8907fb --- /dev/null +++ b/src/app/blog/[id]/BlogFooter.tsx @@ -0,0 +1,29 @@ +import Link from "next/link"; +import { Badge, Button } from "@/components/ui"; +import { BlogPost } from "./types"; + +interface BlogFooterProps { + post: BlogPost; +} + +export default function BlogFooter({ post }: BlogFooterProps) { + return ( + <> +
+ {post.tags.map((tag) => ( + + + {tag} + + + ))} +
+ + + ); +} diff --git a/src/app/blog/[id]/BlogHeader.tsx b/src/app/blog/[id]/BlogHeader.tsx new file mode 100644 index 0000000..6714a53 --- /dev/null +++ b/src/app/blog/[id]/BlogHeader.tsx @@ -0,0 +1,29 @@ +import Image from "next/image"; +import { BlogPost } from "./types"; + +interface BlogHeaderProps { + post: BlogPost; + readingTime: number; +} + +export default function BlogHeader({ post, readingTime }: BlogHeaderProps) { + return ( + <> +

{post.title}

+
+ {post.date} | {post.author} | + 预计阅读时间: {readingTime} 分钟 +
+ {post.coverImage && ( + {post.title} + )} + + ); +} diff --git a/src/app/blog/[id]/MdxComponents.tsx b/src/app/blog/[id]/MdxComponents.tsx new file mode 100644 index 0000000..4b9978b --- /dev/null +++ b/src/app/blog/[id]/MdxComponents.tsx @@ -0,0 +1,79 @@ +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism"; +import dynamic from "next/dynamic"; +import { MDXComponents } from "./types"; + +const CopyButton = dynamic(() => import("@/components/CopyButton"), { + ssr: false, +}); + +export const mdxComponents: MDXComponents = { + h1: (props: any) => ( +

+ ), + h2: (props: any) => ( +

+ ), + h3: (props: any) => ( +

+ ), + p: (props: any) => ( +

+ ), + a: (props: any) => ( + + ), + ol: (props: any) => ( +

    + ), + li: (props: any) => ( +
  1. + ), + code: ({ className, ...props }: any) => { + const match = /language-(\w+)/.exec(className || ""); + const language = match ? match[1] : "text"; + const isInline = className === "inline-code"; + if (isInline) { + return ( + + ); + } + return ( +
    + + +
    + ); + }, + pre: (props: any) =>
    , +}; diff --git a/src/app/blog/[id]/RelatedPosts.tsx b/src/app/blog/[id]/RelatedPosts.tsx new file mode 100644 index 0000000..8bf2036 --- /dev/null +++ b/src/app/blog/[id]/RelatedPosts.tsx @@ -0,0 +1,31 @@ +import Link from "next/link"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui"; +import { BlogPost } from "./types"; + +function RelatedPosts({ posts }: { posts: BlogPost[] }) { + if (posts.length === 0) return null; + + return ( + + + 相关文章 + + +
      + {posts.map((relatedPost) => ( +
    • + + {relatedPost.title} + +
    • + ))} +
    +
    +
    + ); +} + +export default RelatedPosts; diff --git a/src/app/blog/[id]/ShareButtons.tsx b/src/app/blog/[id]/ShareButtons.tsx new file mode 100644 index 0000000..a1933c8 --- /dev/null +++ b/src/app/blog/[id]/ShareButtons.tsx @@ -0,0 +1,37 @@ +import { FaTwitter, FaFacebook, FaLinkedin } from "react-icons/fa"; + +interface ShareButtonsProps { + url: string; + title: string; +} + +export default function ShareButtons({ url, title }: ShareButtonsProps) { + return ( +
    + ); +} diff --git a/src/app/blog/[id]/TableOfContents.tsx b/src/app/blog/[id]/TableOfContents.tsx new file mode 100644 index 0000000..9f49b9c --- /dev/null +++ b/src/app/blog/[id]/TableOfContents.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { Heading } from "./types"; + +interface TableOfContentsProps { + headings: Heading[]; +} + +const TableOfContents = React.memo(function TableOfContents({ + headings, +}: TableOfContentsProps) { + return ( +
    +

    + 目录 +

    + +
    + ); +}); + +export default TableOfContents; diff --git a/src/app/blog/[id]/page.tsx b/src/app/blog/[id]/page.tsx index 724e67b..a585f10 100644 --- a/src/app/blog/[id]/page.tsx +++ b/src/app/blog/[id]/page.tsx @@ -1,217 +1,41 @@ -import { MDXRemote } from "next-mdx-remote/rsc"; import { serialize } from "next-mdx-remote/serialize"; -import rehypeSlug from "rehype-slug"; -import rehypeAutolinkHeadings from "rehype-autolink-headings"; import { notFound } from "next/navigation"; -import Link from "next/link"; import { blogPosts } from "@/data/blogPosts"; import Comments from "@/components/Comments"; import ReadingProgress from "@/components/ReadingProgress"; -import { FaTwitter, FaFacebook, FaLinkedin } from "react-icons/fa"; -import { - Card, - CardContent, - CardHeader, - CardTitle, - Badge, - Button, -} from "@/components/ui"; -import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; -import { - oneDark, - oneLight, -} from "react-syntax-highlighter/dist/esm/styles/prism"; -import remarkGfm from "remark-gfm"; -import rehypeInlineCode from "@/lib/rehypeInlineCode"; -import dynamic from "next/dynamic"; - -const CopyButton = dynamic(() => import("@/components/CopyButton"), { - ssr: false, -}); - -function ShareButtons({ url, title }: { url: string; title: string }) { - return ( - - ); -} - -function TableOfContents({ - headings, -}: { - headings: { text: string; level: number; slug: string }[]; -}) { - return ( -
    -

    - 目录 -

    - -
    - ); -} - -function estimateReadingTime(content: string): number { - const wordsPerMinute = 200; - const wordCount = content.split(/\s+/).length; - return Math.ceil(wordCount / wordsPerMinute); +import { extractHeadings, estimateReadingTime } from "@/utils/blogHelpers"; +import BlogHeader from "./BlogHeader"; +import BlogContent from "./BlogContent"; +import BlogFooter from "./BlogFooter"; +import RelatedPosts from "./RelatedPosts"; +import ShareButtons from "./ShareButtons"; +import TableOfContents from "./TableOfContents"; +import { mdxComponents } from "./MdxComponents"; +import type { BlogPost, Heading } from "./types"; + +interface BlogPostParams { + params: { id: string }; } export async function generateStaticParams() { - const posts = blogPosts; // 假设这个函数可以获取所有博客文章 - return posts.map((post) => ({ - id: post.id.toString(), // 将 id 转换为字符串 + return blogPosts.map((post) => ({ + id: post.id.toString(), })); } -const components = { - h1: (props: any) => ( -

    - ), - h2: (props: any) => ( -

    - ), - h3: (props: any) => ( -

    - ), - p: (props: any) => ( -

    - ), - a: (props: any) => ( - - ), - ol: (props: any) => ( -

      - ), - li: (props: any) => ( -
    1. - ), - code: ({ className, ...props }: any) => { - const match = /language-(\w+)/.exec(className || ""); - const language = match ? match[1] : "text"; - const isInline = className === "inline-code"; - if (isInline) { - return ( - - ); - } - return ( -
      - - -
      - ); - }, - pre: (props: any) =>
      , -}; - -function extractHeadings(content: string) { - const headingRegex = /^(#{1,3})\s+(.+)$/gm; - const headings = []; - let match; - - while ((match = headingRegex.exec(content)) !== null) { - headings.push({ - level: match[1].length, - text: match[2], - slug: match[2].toLowerCase().replace(/\s+/g, "-"), - }); - } - - return headings; -} - -export default async function BlogPost({ params }: { params: { id: string } }) { - const post = blogPosts.find((p) => p.id.toString() === params.id); +export default async function BlogPost({ params }: BlogPostParams) { + const post = blogPosts.find((p) => p.id.toString() === params.id) as + | BlogPost + | undefined; if (!post) { notFound(); } - const mdxSource = await serialize(post.content, { - mdxOptions: { - rehypePlugins: [ - rehypeSlug, - [rehypeAutolinkHeadings, { behavior: "wrap" }], - rehypeInlineCode, - ], - }, - parseFrontmatter: true, - }); - - const headings = extractHeadings(post.content); + const headings: Heading[] = extractHeadings(post.content); + const readingTime: number = estimateReadingTime(post.content); - const relatedPosts = blogPosts + const relatedPosts: BlogPost[] = blogPosts .filter( (p) => p.id !== post.id && p.tags.some((tag) => post.tags.includes(tag)) ) @@ -225,71 +49,17 @@ export default async function BlogPost({ params }: { params: { id: string } }) {
      -

      {post.title}

      -
      - {post.date} | {post.author} | - 预计阅读时间: {estimateReadingTime(post.content)} 分钟 -
      - - + - -
      - {post.tags.map((tag) => ( - - - {tag} - - - ))} -
      - - - {relatedPosts.length > 0 && ( - - - 相关文章 - - -
        - {relatedPosts.map((relatedPost) => ( -
      • - - {relatedPost.title} - -
      • - ))} -
      -
      -
      - )} - + + -
      @@ -297,4 +67,4 @@ export default async function BlogPost({ params }: { params: { id: string } }) {

    ); -} \ No newline at end of file +} diff --git a/src/app/blog/[id]/types.ts b/src/app/blog/[id]/types.ts new file mode 100644 index 0000000..4e78d78 --- /dev/null +++ b/src/app/blog/[id]/types.ts @@ -0,0 +1,21 @@ +import { MDXRemoteSerializeResult } from 'next-mdx-remote'; + +export interface BlogPost { + id: number; + title: string; + date: string; + author: string; + content: string; + coverImage?: string; + tags: string[]; +} + +export interface Heading { + level: number; + text: string; + slug: string; +} + +export interface MDXComponents { + [key: string]: React.ComponentType; +} \ No newline at end of file diff --git a/src/components/Comments.tsx b/src/components/Comments.tsx index 6ea6f49..9dbb0f7 100644 --- a/src/components/Comments.tsx +++ b/src/components/Comments.tsx @@ -17,48 +17,36 @@ interface Comment { author: string; content: string; date: string; - replies: Comment[]; + replies?: Comment[]; } +type NewComment = Pick; + export default function Comments() { const [comments, setComments] = useState([]); - const [newComment, setNewComment] = useState({ author: "", content: "" }); + const [newComment, setNewComment] = useState({ + author: "", + content: "", + }); const [sortOrder, setSortOrder] = useState<"newest" | "oldest">("newest"); const sortedComments = useMemo(() => { return [...comments].sort((a, b) => { - return sortOrder === "newest" - ? new Date(b.date).getTime() - new Date(a.date).getTime() - : new Date(a.date).getTime() - new Date(b.date).getTime(); - }); - }, [comments, sortOrder]); - - const addReply = useCallback((parentId: number, replyContent: string) => { - setComments((prevComments) => { - const newComments = [...prevComments]; - const parentComment = newComments.find((c) => c.id === parentId); - if (parentComment) { - parentComment.replies.push({ - id: Date.now(), - author: "匿名用户", - content: replyContent, - date: new Date().toLocaleString(), - replies: [], - }); + if (sortOrder === "newest") { + return new Date(b.date).getTime() - new Date(a.date).getTime(); + } else { + return new Date(a.date).getTime() - new Date(b.date).getTime(); } - return newComments; }); - }, []); + }, [comments, sortOrder]); const handleSubmit = useCallback( - (e: React.FormEvent) => { + (e: React.FormEvent) => { e.preventDefault(); const comment: Comment = { + ...newComment, id: Date.now(), - author: newComment.author.trim(), - content: newComment.content.trim(), - date: new Date().toLocaleString(), - replies: [], + date: new Date().toISOString(), }; setComments((prevComments) => [...prevComments, comment]); setNewComment({ author: "", content: "" }); @@ -116,19 +104,21 @@ export default function Comments() {

    {comment.content}

    - - {comment.replies.map((reply) => ( - - - {reply.author} - {reply.date} - - -

    {reply.content}

    -
    -
    - ))} -
    + {comment?.replies && comment?.replies?.length > 0 && ( + + {comment?.replies?.map((reply) => ( + + + {reply.author} + {reply.date} + + +

    {reply.content}

    +
    +
    + ))} +
    + )} ))} diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx index 5dba9e9..7db1734 100644 --- a/src/components/CopyButton.tsx +++ b/src/components/CopyButton.tsx @@ -1,8 +1,9 @@ "use client"; -import React, { useState, useCallback } from "react"; +import React, { useState, useCallback, useMemo } from "react"; import Image from "next/image"; import { throttle } from "lodash-es"; +import { useTheme } from "next-themes"; interface CopyButtonProps { text: string; @@ -11,35 +12,54 @@ interface CopyButtonProps { const CopyButton: React.FC = ({ text, language }) => { const [state, setState] = useState<"idle" | "copy" | "copied">("idle"); + const { theme } = useTheme(); - const throttledCopy = throttle(async () => { - if (state === "copied") return; - try { - await navigator.clipboard.writeText(text); - setState("copied"); - setTimeout(() => setState("idle"), 1000); - } catch (err) { - console.error("复制失败:", err); - setState("idle"); - } - }, 300); + const throttledCopy = useMemo( + () => + throttle(async () => { + if (state === "copied") return; + try { + await navigator.clipboard.writeText(text); + setState("copied"); + setTimeout(() => setState("idle"), 1000); + } catch (err) { + console.error("复制失败:", err); + setState("idle"); + } + }, 300), + [text, state] + ); + + const handleCopy = useCallback(() => { + throttledCopy(); + }, [throttledCopy]); + + const buttonClassName = useMemo( + () => ` + absolute top-2 right-2 p-1.5 rounded-md + bg-gray-100 dark:bg-gray-800 + border border-gray-200 dark:border-gray-700 + hover:bg-gray-200 dark:hover:bg-gray-700 + transition-all duration-300 ease-in-out + flex items-center justify-center + shadow-sm hover:shadow-md dark:shadow-gray-900 + h-[30px] + `, + [] + ); - const handleCopy = useCallback(throttledCopy, [throttledCopy, text, state]); + const getImageSrc = (baseName: string) => { + return theme === "dark" + ? `/svg/${baseName}-dark.svg` + : `/svg/${baseName}.svg`; + }; return (