diff --git a/web/package.json b/web/package.json index d57f838..51210e3 100644 --- a/web/package.json +++ b/web/package.json @@ -20,13 +20,17 @@ "deepmerge": "^4.3.1", "field-conv": "^1.0.9", "framer-motion": "^12.23.9", + "highlight.js": "^11.11.1", "lucide-react": "^0.525.0", "next": "15.4.1", "next-intl": "^4.3.4", "next-mdx-remote-client": "^2.1.3", + "next-themes": "^0.4.6", "react": "19.1.0", "react-dom": "19.1.0", "react-icons": "^5.5.0", + "rehype-highlight": "^7.0.2", + "sonner": "^2.0.6", "tailwind-merge": "^3.3.1" }, "devDependencies": { diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 03d773c..8c0d119 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: framer-motion: specifier: ^12.23.9 version: 12.23.9(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + highlight.js: + specifier: ^11.11.1 + version: 11.11.1 lucide-react: specifier: ^0.525.0 version: 0.525.0(react@19.1.0) @@ -53,6 +56,9 @@ importers: next-mdx-remote-client: specifier: ^2.1.3 version: 2.1.3(@types/react@19.1.8)(acorn@8.15.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(unified@11.0.5) + next-themes: + specifier: ^0.4.6 + version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: specifier: 19.1.0 version: 19.1.0 @@ -62,6 +68,12 @@ importers: react-icons: specifier: ^5.5.0 version: 5.5.0(react@19.1.0) + rehype-highlight: + specifier: ^7.0.2 + version: 7.0.2 + sonner: + specifier: ^2.0.6 + version: 2.0.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) tailwind-merge: specifier: ^3.3.1 version: 3.3.1 @@ -1610,15 +1622,25 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + hast-util-to-estree@3.1.3: resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==} hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1897,6 +1919,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lowlight@3.3.0: + resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} + lucide-react@0.525.0: resolution: {integrity: sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==} peerDependencies: @@ -2106,6 +2131,12 @@ packages: react: ^19.1.0 react-dom: ^19.1.0 + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + next@15.4.1: resolution: {integrity: sha512-eNKB1q8C7o9zXF8+jgJs2CzSLIU3T6bQtX6DcTnCq1sIR1CJ0GlSyRs1BubQi3/JgCnr9Vr+rS5mOMI38FFyQw==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} @@ -2303,6 +2334,9 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + rehype-highlight@7.0.2: + resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==} + rehype-recma@1.0.0: resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==} @@ -2414,6 +2448,12 @@ packages: simple-swizzle@0.2.2: resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + sonner@2.0.6: + resolution: {integrity: sha512-yHFhk8T/DK3YxjFQXIrcHT1rGEeTLliVzWbO0xN8GberVun2RiBnxAjXAYpZrqwEVHBG9asI/Li8TAAhN9m59Q==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2578,6 +2618,9 @@ packages: unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + unist-util-is@6.0.0: resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==} @@ -4313,6 +4356,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-to-estree@3.1.3: dependencies: '@types/estree': 1.0.8 @@ -4354,10 +4401,19 @@ snapshots: transitivePeerDependencies: - supports-color + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.4 + highlight.js@11.11.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -4621,6 +4677,12 @@ snapshots: dependencies: js-tokens: 4.0.0 + lowlight@3.3.0: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + highlight.js: 11.11.1 + lucide-react@0.525.0(react@19.1.0): dependencies: react: 19.1.0 @@ -5012,6 +5074,11 @@ snapshots: - supports-color - unified + next-themes@0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + next@15.4.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@next/env': 15.4.1 @@ -5246,6 +5313,14 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + rehype-highlight@7.0.2: + dependencies: + '@types/hast': 3.0.4 + hast-util-to-text: 4.0.2 + lowlight: 3.3.0 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + rehype-recma@1.0.0: dependencies: '@types/estree': 1.0.8 @@ -5429,6 +5504,11 @@ snapshots: is-arrayish: 0.3.2 optional: true + sonner@2.0.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + source-map-js@1.2.1: {} source-map@0.7.6: {} @@ -5625,6 +5705,11 @@ snapshots: trough: 2.2.0 vfile: 6.0.3 + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.0 + unist-util-is@6.0.0: dependencies: '@types/unist': 3.0.3 diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 4c6e7b1..66c2a50 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -5,6 +5,7 @@ import { DeviceProvider } from "@/contexts/device-context"; import { NextIntlClientProvider } from 'next-intl'; import config from "@/config"; import { getUserLocales } from "@/i18n/request"; +import { Toaster } from "@/components/ui/sonner" const geistSans = Geist({ variable: "--font-geist-sans", @@ -31,6 +32,7 @@ export default async function RootLayout({ + {children} diff --git a/web/src/components/blog-post.tsx b/web/src/components/blog-post.tsx index ba0be70..8e7fa14 100644 --- a/web/src/components/blog-post.tsx +++ b/web/src/components/blog-post.tsx @@ -2,6 +2,8 @@ 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 ScrollToTop from "@/components/scroll-to-top.client"; +import { RenderMarkdown } from "@/components/markdown"; function PostMeta({ post }: { post: Post }) { return ( @@ -19,7 +21,7 @@ function PostMeta({ post }: { post: Post }) { {/* 阅读时间 */} - {post.content.length / 100 || 1} 分钟 + {Math.ceil(post.content.length / 400 || 1)} 分钟 {/* 发布时间 */} @@ -90,19 +92,32 @@ function PostHeader({ post }: { post: Post }) { } async function PostContent({ post }: { post: Post }) { + const markdownClass = + "prose prose-lg max-w-none dark:prose-invert " + + // h1-h6 + "[&_h1]:scroll-m-20 [&_h1]:text-4xl [&_h1]:font-extrabold [&_h1]:tracking-tight [&_h1]:text-balance [&_h1]:mt-10 [&_h1]:mb-6 " + + "[&_h2]:scroll-m-20 [&_h2]:border-b [&_h2]:pb-2 [&_h2]:text-3xl [&_h2]:font-semibold [&_h2]:tracking-tight [&_h2]:first:mt-0 [&_h2]:mt-8 [&_h2]:mb-4 " + + "[&_h3]:scroll-m-20 [&_h3]:text-2xl [&_h3]:font-semibold [&_h3]:tracking-tight [&_h3]:mt-6 [&_h3]:mb-3 " + + "[&_h4]:scroll-m-20 [&_h4]:text-xl [&_h4]:font-semibold [&_h4]:tracking-tight [&_h4]:mt-5 [&_h4]:mb-2 " + + // p + "[&_p]:leading-7 [&_p]:mt-4 [&_p]:mb-4 " + + // blockquote + "[&_blockquote]:border-l-4 [&_blockquote]:border-blue-400 [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:my-6 [&_blockquote]:py-2 " + + // code + "[&_code]:bg-gray-100 [&_code]:dark:bg-gray-800 [&_code]:rounded [&_code]:px-1 [&_code]:py-0.5 [&_code]:text-sm [&_code]:font-mono " + + // a + "[&_a]:text-blue-600 [&_a]:hover:underline"; return ( -
+
{post.type === "html" && (
)} {post.type === "markdown" && ( - + )} {post.type === "text" && ( @@ -118,10 +133,9 @@ async function PostContent({ post }: { post: Post }) { async function BlogPost({ post }: { post: Post }) { return (
+ -
-
); } diff --git a/web/src/components/markdown-codeblock.tsx b/web/src/components/markdown-codeblock.tsx new file mode 100644 index 0000000..6d7084f --- /dev/null +++ b/web/src/components/markdown-codeblock.tsx @@ -0,0 +1,88 @@ +'use client' +import React from "react"; +import { toast } from "sonner"; + +function extractText(node: any): string { + if (typeof node === "string") return node; + if (Array.isArray(node)) return node.map(extractText).join(""); + if (node && typeof node === "object" && "props" in node) { + return extractText(node.props.children); + } + return ""; +} + +export default function CodeBlock(props: React.ComponentPropsWithoutRef<"pre">) { + let className: string | undefined = undefined; + if ( + props.children && + typeof props.children === "object" && + "props" in props.children && + props.children && + typeof (props.children as any).props === "object" && + (props.children as any).props.className + ) { + className = (props.children as any).props.className as string | undefined; + } + let language = ""; + if (className) { + const match = className.match(/language-(\w+)/); + if (match) { + language = match[1]; + } + } + let codeContent = ""; + if ( + props.children && + typeof props.children === "object" && + "props" in props.children && + (props.children as any).props + ) { + codeContent = extractText((props.children as any).props.children); + } + + function handleCopy(e: React.MouseEvent) { + if (typeof window !== "undefined" && window.navigator?.clipboard) { + window.navigator.clipboard.writeText(codeContent); + } else { + const textarea = document.createElement("textarea"); + textarea.value = codeContent; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); + } + toast.success("已经复制", { + description: "代码已复制到剪贴板", + }); + } + + return ( +
+
+ + + + {language && ( + + {language} + + )} +
+ +
+
+
+        
+ ); +} \ No newline at end of file diff --git a/web/src/components/markdown.tsx b/web/src/components/markdown.tsx new file mode 100644 index 0000000..30fca9c --- /dev/null +++ b/web/src/components/markdown.tsx @@ -0,0 +1,82 @@ + +import { MDXRemote, MDXRemoteProps } from "next-mdx-remote-client/rsc"; +import { Suspense } from "react"; +import rehypeHighlight from "rehype-highlight"; +import "highlight.js/styles/github.css"; // 你可以换成喜欢的主题 +import CodeBlock from "@/components/markdown-codeblock"; + +export const markdownComponents = { + h1: (props: React.ComponentPropsWithoutRef<"h1">) => ( +

+ ), + h2: (props: React.ComponentPropsWithoutRef<"h2">) => ( +

+ ), + h3: (props: React.ComponentPropsWithoutRef<"h3">) => ( +

+ ), + h4: (props: React.ComponentPropsWithoutRef<"h4">) => ( +

+ ), + p: (props: React.ComponentPropsWithoutRef<"p">) => ( +

+ ), + blockquote: (props: React.ComponentPropsWithoutRef<"blockquote">) => ( +

+ ), + code: (props: React.ComponentPropsWithoutRef<"code">) => ( + + ), + pre: ({ children, ...props }: React.ComponentPropsWithoutRef<"pre">) => ( + {children} + ), + a: (props: React.ComponentPropsWithoutRef<"a">) => ( + + ), +}; + +export function RenderMarkdown(props: Omit) { + return ( + 加载中...

}> + + + ); +} + +export function RenderMarkdownWithComponents(props: MDXRemoteProps) { + return ( + 加载中...
}> + + + ); +} \ No newline at end of file diff --git a/web/src/components/scroll-to-top.client.tsx b/web/src/components/scroll-to-top.client.tsx new file mode 100644 index 0000000..44c1030 --- /dev/null +++ b/web/src/components/scroll-to-top.client.tsx @@ -0,0 +1,9 @@ +'use client' +import { useEffect } from "react"; + +export default function ScrollToTop() { + useEffect(() => { + window.scrollTo(0, 0); + }, []); + return null; +} \ No newline at end of file diff --git a/web/src/components/ui/sonner.tsx b/web/src/components/ui/sonner.tsx new file mode 100644 index 0000000..957524e --- /dev/null +++ b/web/src/components/ui/sonner.tsx @@ -0,0 +1,25 @@ +"use client" + +import { useTheme } from "next-themes" +import { Toaster as Sonner, ToasterProps } from "sonner" + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + + ) +} + +export { Toaster }