mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-03 15:56:22 +00:00
feat: 添加代码高亮支持,优化Markdown渲染,新增滚动到顶部组件
This commit is contained in:
@ -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": {
|
||||
|
85
web/pnpm-lock.yaml
generated
85
web/pnpm-lock.yaml
generated
@ -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
|
||||
|
@ -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({
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<Toaster richColors/>
|
||||
<DeviceProvider>
|
||||
<NextIntlClientProvider>{children}</NextIntlClientProvider>
|
||||
</DeviceProvider>
|
||||
|
@ -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 }) {
|
||||
{/* 阅读时间 */}
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
{post.content.length / 100 || 1} 分钟
|
||||
{Math.ceil(post.content.length / 400 || 1)} 分钟
|
||||
</span>
|
||||
{/* 发布时间 */}
|
||||
<span className="flex items-center gap-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 (
|
||||
<div className="py-12">
|
||||
<div className="prose prose-lg max-w-none dark:prose-invert">
|
||||
{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"
|
||||
className={`${markdownClass}`}
|
||||
dangerouslySetInnerHTML={{ __html: post.content }}
|
||||
/>
|
||||
)}
|
||||
{post.type === "markdown" && (
|
||||
<Suspense>
|
||||
<MDXRemote
|
||||
source={post.content}
|
||||
/>
|
||||
<RenderMarkdown source={post.content} />
|
||||
</Suspense>
|
||||
)}
|
||||
{post.type === "text" && (
|
||||
@ -118,10 +133,9 @@ async function PostContent({ post }: { post: Post }) {
|
||||
async function BlogPost({ post }: { post: Post }) {
|
||||
return (
|
||||
<div className="">
|
||||
<ScrollToTop />
|
||||
<PostHeader post={post} />
|
||||
<div className="">
|
||||
<PostContent post={post} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
88
web/src/components/markdown-codeblock.tsx
Normal file
88
web/src/components/markdown-codeblock.tsx
Normal file
@ -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<HTMLButtonElement>) {
|
||||
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 (
|
||||
<div className="relative my-6 rounded-lg overflow-hidden bg-[#f5f5f7] dark:bg-[#23272f] border border-gray-200 dark:border-gray-700 shadow-sm group">
|
||||
<div className="flex items-center h-8 px-3 bg-[#e5e7eb] dark:bg-[#23272f] border-b border-gray-200 dark:border-gray-700 relative">
|
||||
<span className="w-3 h-3 rounded-full bg-red-400 mr-2" />
|
||||
<span className="w-3 h-3 rounded-full bg-yellow-400 mr-2" />
|
||||
<span className="w-3 h-3 rounded-full bg-green-400" />
|
||||
{language && (
|
||||
<span className="absolute left-1/2 -translate-x-1/2 text-xs text-gray-500 dark:text-gray-400 font-mono">
|
||||
{language}
|
||||
</span>
|
||||
)}
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex space-x-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
className="px-2 py-1 rounded text-xs bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 border border-gray-300 dark:border-gray-600"
|
||||
title="复制代码"
|
||||
onClick={handleCopy}
|
||||
tabIndex={-1}
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre
|
||||
className="overflow-x-auto bg-transparent text-sm text-gray-800 dark:text-gray-100"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
82
web/src/components/markdown.tsx
Normal file
82
web/src/components/markdown.tsx
Normal file
@ -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">) => (
|
||||
<h1
|
||||
className="scroll-m-20 text-4xl font-extrabold tracking-tight text-balance mt-10 mb-6"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h2: (props: React.ComponentPropsWithoutRef<"h2">) => (
|
||||
<h2
|
||||
className="scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight first:mt-0 mt-8 mb-4"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h3: (props: React.ComponentPropsWithoutRef<"h3">) => (
|
||||
<h3
|
||||
className="scroll-m-20 text-2xl font-semibold tracking-tight mt-6 mb-3"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h4: (props: React.ComponentPropsWithoutRef<"h4">) => (
|
||||
<h4
|
||||
className="scroll-m-20 text-xl font-semibold tracking-tight mt-5 mb-2"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
p: (props: React.ComponentPropsWithoutRef<"p">) => (
|
||||
<p
|
||||
className="leading-7 mt-4 mb-4"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
blockquote: (props: React.ComponentPropsWithoutRef<"blockquote">) => (
|
||||
<blockquote
|
||||
className="border-l-4 border-blue-400 pl-4 italic my-6 py-2"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
code: (props: React.ComponentPropsWithoutRef<"code">) => (
|
||||
<code
|
||||
className="bg-gray-100 dark:bg-gray-800 rounded px-1 py-0.5 text-sm font-mono"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
pre: ({ children, ...props }: React.ComponentPropsWithoutRef<"pre">) => (
|
||||
<CodeBlock {...props}>{children}</CodeBlock>
|
||||
),
|
||||
a: (props: React.ComponentPropsWithoutRef<"a">) => (
|
||||
<a
|
||||
className="text-blue-600 hover:underline"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export function RenderMarkdown(props: Omit<MDXRemoteProps, "components">) {
|
||||
return (
|
||||
<Suspense fallback={<div>加载中...</div>}>
|
||||
<MDXRemote {...props}
|
||||
components={markdownComponents}
|
||||
options={{
|
||||
mdxOptions: {
|
||||
rehypePlugins: [[rehypeHighlight, { ignoreMissing: true }]],
|
||||
}
|
||||
}} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export function RenderMarkdownWithComponents(props: MDXRemoteProps) {
|
||||
return (
|
||||
<Suspense fallback={<div>加载中...</div>}>
|
||||
<MDXRemote {...props} components={markdownComponents} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
9
web/src/components/scroll-to-top.client.tsx
Normal file
9
web/src/components/scroll-to-top.client.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
'use client'
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function ScrollToTop() {
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, []);
|
||||
return null;
|
||||
}
|
25
web/src/components/ui/sonner.tsx
Normal file
25
web/src/components/ui/sonner.tsx
Normal file
@ -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 (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
Reference in New Issue
Block a user