mirror of
https://github.com/LiuYuYang01/ThriveX-Blog.git
synced 2026-09-03 06:24:24 +08:00
feat: 增强Markdown组件以支持小组件渲染和图片预览功能
1. 新增WidgetRenderer以解析和渲染小组件,支持从Markdown中提取小组件语法。 2. 更新MarkdownBody组件,添加onOpenGalleryPreview回调以处理图片画廊预览。 3. 修改样式以确保小组件和列表项的显示效果一致。 4. 优化Skeleton组件的高度设置,提升加载体验。
This commit is contained in:
@@ -178,6 +178,10 @@
|
||||
@apply my-2.5 ml-6;
|
||||
}
|
||||
|
||||
.tx-widget li {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
ul:not(.contains-task-list) {
|
||||
@apply list-disc;
|
||||
}
|
||||
@@ -199,6 +203,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.tx-widget ol > li::before {
|
||||
content: none !important;
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
@apply my-5 pl-4 bg-[rgba(246,248,250)] border-l-4 border-[#11181C] dark:border-gray-500 dark:bg-[rgba(246,248,250,0.1)];
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ import './index.scss';
|
||||
|
||||
import hljs from 'highlight.js';
|
||||
import 'highlight.js/styles/atom-one-dark.css';
|
||||
import WidgetRenderer from './widgets';
|
||||
import { isWidgetCodeLanguage, parseWidgetPayload, payloadFromLinkAlias } from './widgets/parse';
|
||||
import './widgets/index.scss';
|
||||
|
||||
interface Props {
|
||||
data: string;
|
||||
@@ -149,7 +152,7 @@ function MarkdownImage({
|
||||
<img
|
||||
alt={alt}
|
||||
src={imageSrc}
|
||||
className={`${className ?? 'max-h-[500px]'} ${loaded ? '' : 'markdown-img--loading'}`}
|
||||
className={`${className ?? 'max-h-125'} ${loaded ? '' : 'markdown-img--loading'}`}
|
||||
onLoad={markLoaded}
|
||||
ref={(node) => {
|
||||
if (node?.complete) markLoaded();
|
||||
@@ -164,9 +167,15 @@ type MarkdownBodyProps = {
|
||||
data: string;
|
||||
headings: TocHeading[];
|
||||
onOpenPreview: (src: string) => void;
|
||||
onOpenGalleryPreview: (src: string, urls: string[]) => void;
|
||||
};
|
||||
|
||||
const MarkdownBody = memo(function MarkdownBody({ data, headings, onOpenPreview }: MarkdownBodyProps) {
|
||||
const MarkdownBody = memo(function MarkdownBody({
|
||||
data,
|
||||
headings,
|
||||
onOpenPreview,
|
||||
onOpenGalleryPreview,
|
||||
}: MarkdownBodyProps) {
|
||||
const headingIndexRef = useRef(0);
|
||||
|
||||
const headingComponents = useMemo(
|
||||
@@ -178,18 +187,32 @@ const MarkdownBody = memo(function MarkdownBody({ data, headings, onOpenPreview
|
||||
() => ({
|
||||
img: (props) => <MarkdownImage {...props} onPreview={onOpenPreview} />,
|
||||
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => {
|
||||
if (children === 'douyin-video' && href) {
|
||||
const videoId = href.split('/').pop();
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<iframe src={`https://open.douyin.com/player/video?vid=${videoId}&autoplay=0`} referrerPolicy="unsafe-url" allowFullScreen className="douyin" />
|
||||
</div>
|
||||
);
|
||||
const label = typeof children === 'string' ? children : Array.isArray(children) && children.length === 1 && typeof children[0] === 'string' ? children[0] : null;
|
||||
if (label && href) {
|
||||
const payload = payloadFromLinkAlias(label, href);
|
||||
if (payload) {
|
||||
return <WidgetRenderer data={payload} onPreview={onOpenGalleryPreview} />;
|
||||
}
|
||||
}
|
||||
return <a href={href}>{children}</a>;
|
||||
},
|
||||
// CodeBlock / Widget 自身已输出完整块级结构,避免再包一层 pre
|
||||
pre: ({ children }) => <>{children}</>,
|
||||
code: ({ node, inline, className = '', children, ...props }: any) => {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const match = /language-([\w-]+)/.exec(className || '');
|
||||
const codeString = node?.value ?? String(children).replace(/\n$/, '');
|
||||
|
||||
if (!inline && isWidgetCodeLanguage(className)) {
|
||||
const payload = parseWidgetPayload(codeString);
|
||||
if (payload) {
|
||||
return <WidgetRenderer data={payload} onPreview={onOpenGalleryPreview} />;
|
||||
}
|
||||
return (
|
||||
<div className="tx-widget tx-widget--unknown">
|
||||
小组件语法解析失败,请检查 JSON / 配置格式
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (inline || !match) {
|
||||
return (
|
||||
@@ -200,12 +223,10 @@ const MarkdownBody = memo(function MarkdownBody({ data, headings, onOpenPreview
|
||||
}
|
||||
|
||||
const language = match[1].toLowerCase();
|
||||
const codeString = node?.value ?? String(children);
|
||||
|
||||
return <CodeBlock language={language} value={codeString} />;
|
||||
},
|
||||
}),
|
||||
[onOpenPreview],
|
||||
[onOpenPreview, onOpenGalleryPreview],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -244,6 +265,7 @@ const ContentMD = ({ data, headings = [] }: Props) => {
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [previewStartIndex, setPreviewStartIndex] = useState(0);
|
||||
const [previewPhotos, setPreviewPhotos] = useState<PhotoItem[]>([]);
|
||||
|
||||
const photos = useMemo<PhotoItem[]>(
|
||||
() => extractMarkdownImages(data).map((url, i) => ({ id: `${i}`, url, alt: `图片-${i + 1}` })),
|
||||
@@ -251,11 +273,20 @@ const ContentMD = ({ data, headings = [] }: Props) => {
|
||||
);
|
||||
|
||||
const openPreview = useCallback((src: string) => {
|
||||
setPreviewPhotos(photos);
|
||||
const index = photos.findIndex((photo) => photo.url === src);
|
||||
setPreviewStartIndex(index >= 0 ? index : 0);
|
||||
setPreviewOpen(true);
|
||||
}, [photos]);
|
||||
|
||||
const openGalleryPreview = useCallback((src: string, urls: string[]) => {
|
||||
const list = urls.map((url, i) => ({ id: `g-${i}`, url, alt: `图片-${i + 1}` }));
|
||||
setPreviewPhotos(list);
|
||||
const index = list.findIndex((photo) => photo.url === src);
|
||||
setPreviewStartIndex(index >= 0 ? index : 0);
|
||||
setPreviewOpen(true);
|
||||
}, []);
|
||||
|
||||
const closePreview = useCallback(() => {
|
||||
setPreviewOpen(false);
|
||||
}, []);
|
||||
@@ -299,13 +330,13 @@ const ContentMD = ({ data, headings = [] }: Props) => {
|
||||
<Skeleton className="h-4 w-11/12" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
</div>
|
||||
<Skeleton className="h-[200px] w-3/6 my-4" />
|
||||
<Skeleton className="h-50 w-3/6 my-4" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-10/12" />
|
||||
<Skeleton className="h-4 w-9/12" />
|
||||
</div>
|
||||
<Skeleton className="h-[120px] w-full" />
|
||||
<Skeleton className="h-30 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -313,11 +344,16 @@ const ContentMD = ({ data, headings = [] }: Props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<MarkdownBody data={data} headings={headings} onOpenPreview={openPreview} />
|
||||
<MarkdownBody
|
||||
data={data}
|
||||
headings={headings}
|
||||
onOpenPreview={openPreview}
|
||||
onOpenGalleryPreview={openGalleryPreview}
|
||||
/>
|
||||
|
||||
<PhotoPreview
|
||||
open={previewOpen}
|
||||
photos={photos}
|
||||
photos={previewPhotos.length ? previewPhotos : photos}
|
||||
index={previewStartIndex}
|
||||
onClose={closePreview}
|
||||
/>
|
||||
|
||||
257
src/app/article/components/MD/widgets/components.tsx
Normal file
257
src/app/article/components/MD/widgets/components.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type {
|
||||
GalleryItem,
|
||||
StepItem,
|
||||
TabItem,
|
||||
TimelineItem,
|
||||
WidgetPayload,
|
||||
} from './types';
|
||||
|
||||
function asString(value: unknown, fallback = ''): string {
|
||||
return typeof value === 'string' ? value : fallback;
|
||||
}
|
||||
|
||||
function asArray<T>(value: unknown): T[] {
|
||||
return Array.isArray(value) ? (value as T[]) : [];
|
||||
}
|
||||
|
||||
function cx(...parts: Array<string | false | null | undefined>) {
|
||||
return parts.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function WidgetShell({
|
||||
children,
|
||||
className,
|
||||
label,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
label?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cx('tx-widget', className)} data-widget={label}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BilibiliWidget({ data }: { data: WidgetPayload }) {
|
||||
const bvid = asString(data.bvid || data.id);
|
||||
if (!bvid) return null;
|
||||
const page = Number(data.page || 1) || 1;
|
||||
const src = `https://player.bilibili.com/player.html?bvid=${encodeURIComponent(bvid)}&page=${page}&high_quality=1&danmaku=0`;
|
||||
|
||||
return (
|
||||
<WidgetShell label="bilibili" className="tx-widget--media">
|
||||
<div className="tx-widget__ratio">
|
||||
<iframe
|
||||
src={src}
|
||||
title={`Bilibili ${bvid}`}
|
||||
allowFullScreen
|
||||
scrolling="no"
|
||||
frameBorder={0}
|
||||
sandbox="allow-scripts allow-same-origin allow-popups allow-presentation"
|
||||
/>
|
||||
</div>
|
||||
</WidgetShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function YoutubeWidget({ data }: { data: WidgetPayload }) {
|
||||
const id = asString(data.id || data.videoId);
|
||||
if (!id) return null;
|
||||
return (
|
||||
<WidgetShell label="youtube" className="tx-widget--media">
|
||||
<div className="tx-widget__ratio">
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${encodeURIComponent(id)}`}
|
||||
title={`YouTube ${id}`}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
</WidgetShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function NeteaseWidget({ data }: { data: WidgetPayload }) {
|
||||
const id = asString(data.id);
|
||||
const auto = data.auto === true || data.autoplay === true ? 1 : 0;
|
||||
if (!id) return null;
|
||||
return (
|
||||
<WidgetShell label="netease" className="tx-widget--embed">
|
||||
<iframe
|
||||
className="tx-widget__netease"
|
||||
src={`https://music.163.com/outchain/player?type=2&id=${encodeURIComponent(id)}&auto=${auto}&height=66`}
|
||||
title={`Netease ${id}`}
|
||||
/>
|
||||
</WidgetShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function DouyinWidget({ data }: { data: WidgetPayload }) {
|
||||
const id = asString(data.id || data.vid);
|
||||
if (!id) return null;
|
||||
return (
|
||||
<WidgetShell label="douyin" className="tx-widget--media">
|
||||
<div className="tx-widget__ratio tx-widget__ratio--portrait">
|
||||
<iframe
|
||||
src={`https://open.douyin.com/player/video?vid=${encodeURIComponent(id)}&autoplay=0`}
|
||||
title={`Douyin ${id}`}
|
||||
referrerPolicy="unsafe-url"
|
||||
allowFullScreen
|
||||
className="douyin"
|
||||
/>
|
||||
</div>
|
||||
</WidgetShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function AudioWidget({ data }: { data: WidgetPayload }) {
|
||||
const src = asString(data.src || data.url);
|
||||
const title = asString(data.title, '音频');
|
||||
if (!src) return null;
|
||||
return (
|
||||
<WidgetShell label="audio" className="tx-widget--card">
|
||||
<div className="tx-widget__audio">
|
||||
<div className="tx-widget__audio-meta">
|
||||
<strong>{title}</strong>
|
||||
{asString(data.artist) && <span>{asString(data.artist)}</span>}
|
||||
</div>
|
||||
<audio controls preload="none" src={src}>
|
||||
你的浏览器不支持音频播放
|
||||
</audio>
|
||||
</div>
|
||||
</WidgetShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabsWidget({ data }: { data: WidgetPayload }) {
|
||||
const items = asArray<TabItem>(data.items).filter((item) => item?.title);
|
||||
const [active, setActive] = useState(0);
|
||||
if (!items.length) return null;
|
||||
const current = items[Math.min(active, items.length - 1)];
|
||||
|
||||
return (
|
||||
<WidgetShell label="tabs" className="tx-widget--card">
|
||||
<div className="tx-widget__tabs" role="tablist">
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={`${item.title}-${index}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={index === active}
|
||||
className={cx(index === active && 'is-active')}
|
||||
onClick={() => setActive(index)}
|
||||
>
|
||||
{item.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="tx-widget__tab-panel whitespace-pre-wrap" role="tabpanel">
|
||||
{current?.content || ''}
|
||||
</div>
|
||||
</WidgetShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimelineWidget({ data }: { data: WidgetPayload }) {
|
||||
const items = asArray<TimelineItem>(data.items);
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<WidgetShell label="timeline" className="tx-widget--card">
|
||||
<div className="tx-widget__timeline">
|
||||
{items.map((item, index) => (
|
||||
<div className="tx-widget__timeline-item" key={`${item.title}-${index}`}>
|
||||
<span className="tx-widget__dot" aria-hidden />
|
||||
<div className="tx-widget__timeline-body">
|
||||
{item.time && <time>{item.time}</time>}
|
||||
<strong>{item.title}</strong>
|
||||
{item.content && <p className="whitespace-pre-wrap">{item.content}</p>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</WidgetShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function StepsWidget({ data }: { data: WidgetPayload }) {
|
||||
const items = asArray<StepItem>(data.items);
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<WidgetShell label="steps" className="tx-widget--card">
|
||||
<div className="tx-widget__steps">
|
||||
{items.map((item, index) => (
|
||||
<div className="tx-widget__step-item" key={`${item.title}-${index}`}>
|
||||
<span className="tx-widget__step-index">{index + 1}</span>
|
||||
<div className="tx-widget__step-body">
|
||||
<strong>{item.title}</strong>
|
||||
{item.content && <p className="whitespace-pre-wrap">{item.content}</p>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</WidgetShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function CtaWidget({ data }: { data: WidgetPayload }) {
|
||||
const title = asString(data.title, '立刻行动');
|
||||
const description = asString(data.description);
|
||||
const primaryText = asString(data.primaryText || data.buttonText, '了解更多');
|
||||
const primaryUrl = asString(data.primaryUrl || data.url || data.href, '#');
|
||||
const secondaryText = asString(data.secondaryText);
|
||||
const secondaryUrl = asString(data.secondaryUrl);
|
||||
|
||||
return (
|
||||
<WidgetShell label="cta" className="tx-widget--cta">
|
||||
<div>
|
||||
<strong>{title}</strong>
|
||||
{description && <p>{description}</p>}
|
||||
</div>
|
||||
<div className="tx-widget__cta-actions">
|
||||
<a href={primaryUrl} target="_blank" rel="noopener noreferrer" className="is-primary">
|
||||
{primaryText}
|
||||
</a>
|
||||
{secondaryText && secondaryUrl && (
|
||||
<a href={secondaryUrl} target="_blank" rel="noopener noreferrer">
|
||||
{secondaryText}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</WidgetShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function GalleryWidget({
|
||||
data,
|
||||
onPreview,
|
||||
}: {
|
||||
data: WidgetPayload;
|
||||
onPreview?: (src: string, urls: string[]) => void;
|
||||
}) {
|
||||
const items = asArray<GalleryItem>(data.items).filter((item) => item?.src);
|
||||
const urls = items.map((item) => item.src);
|
||||
if (!items.length) return null;
|
||||
|
||||
return (
|
||||
<WidgetShell label="gallery" className="tx-widget--card">
|
||||
<div className={cx('tx-widget__gallery', items.length === 1 && 'is-single')}>
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={`${item.src}-${index}`}
|
||||
type="button"
|
||||
className="tx-widget__gallery-item"
|
||||
onClick={() => onPreview?.(item.src, urls)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={item.src} alt={item.alt || `gallery-${index + 1}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</WidgetShell>
|
||||
);
|
||||
}
|
||||
926
src/app/article/components/MD/widgets/index.scss
Normal file
926
src/app/article/components/MD/widgets/index.scss
Normal file
@@ -0,0 +1,926 @@
|
||||
@reference '../../../../../styles/tailwind.css';
|
||||
|
||||
.tx-widget {
|
||||
margin: 1.25rem 0;
|
||||
color: #334155;
|
||||
// 隔离文章 Markdown 的 ol/li 编号与缩进样式
|
||||
list-style: none;
|
||||
|
||||
ol,
|
||||
ul,
|
||||
li {
|
||||
list-style: none !important;
|
||||
margin-left: 0 !important;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
ol > li::before,
|
||||
ul > li::before {
|
||||
content: none !important;
|
||||
display: none !important;
|
||||
border: 0 !important;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
.dark & {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
&--card,
|
||||
&--cta,
|
||||
&--terminal,
|
||||
&--embed {
|
||||
// border: 1px solid #e8eef6;
|
||||
// border-radius: 14px;
|
||||
// background: linear-gradient(180deg, #f8fbff 0%, #fff 100%);
|
||||
overflow: hidden;
|
||||
|
||||
.dark & {
|
||||
border-color: #3d4654;
|
||||
background: linear-gradient(180deg, #1b2230 0%, #161b26 100%);
|
||||
}
|
||||
}
|
||||
|
||||
&--media {
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&--unknown {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 10px;
|
||||
background: #fff4f4;
|
||||
color: #b42318;
|
||||
font-size: 0.875rem;
|
||||
|
||||
.dark & {
|
||||
background: #3a2323;
|
||||
color: #f5a8a0;
|
||||
}
|
||||
}
|
||||
|
||||
&__ratio {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-top: 56.25%;
|
||||
background: #0f172a;
|
||||
|
||||
iframe {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
&--portrait {
|
||||
padding-top: 70%;
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__netease {
|
||||
width: 100%;
|
||||
height: 86px;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
&__eyebrow {
|
||||
display: inline-block;
|
||||
margin-bottom: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #539dfd;
|
||||
}
|
||||
|
||||
&__audio {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
// padding: 1rem 1.1rem;
|
||||
|
||||
audio {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__collapse-trigger {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.95rem 1.1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
transition: transform 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&.is-open svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
&__collapse-body {
|
||||
padding: 0 1.1rem 1.1rem;
|
||||
color: #475569;
|
||||
line-height: 1.7;
|
||||
border-top: 1px dashed #e2e8f0;
|
||||
|
||||
.dark & {
|
||||
color: #94a3b8;
|
||||
border-top-color: #3d4654;
|
||||
}
|
||||
}
|
||||
|
||||
&__tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
padding: 0.75rem 0.75rem 0;
|
||||
|
||||
button {
|
||||
border-radius: 999px;
|
||||
padding: 0.4rem 0.85rem;
|
||||
font-size: 0.875rem;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
|
||||
&.is-active {
|
||||
background: #539dfd;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:hover:not(.is-active) {
|
||||
background: rgba(83, 157, 253, 0.12);
|
||||
color: #539dfd;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__tab-panel {
|
||||
padding: 1rem 1.1rem 1.2rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
&__timeline {
|
||||
margin: 0;
|
||||
padding: 1.15rem 1.2rem 0.4rem;
|
||||
display: grid;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
&__timeline-item {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 16px minmax(0, 1fr);
|
||||
gap: 0.9rem;
|
||||
padding-bottom: 1.15rem;
|
||||
|
||||
&:not(:last-child)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 7px;
|
||||
top: 18px;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: #dbe7f8;
|
||||
|
||||
.dark & {
|
||||
background: #3d4654;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__timeline-body {
|
||||
min-width: 0;
|
||||
|
||||
time {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: #539dfd;
|
||||
margin-bottom: 0.2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
color: #1f2937;
|
||||
font-size: 0.95rem;
|
||||
|
||||
.dark & {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
line-height: 1.65;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__dot {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
border: 3px solid #539dfd;
|
||||
margin-top: 2px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.dark & {
|
||||
background: #161b26;
|
||||
}
|
||||
}
|
||||
|
||||
&__steps {
|
||||
margin: 0;
|
||||
// padding: 1.15rem 1.2rem;
|
||||
display: grid;
|
||||
gap: 0.95rem;
|
||||
}
|
||||
|
||||
&__step-item {
|
||||
display: grid;
|
||||
grid-template-columns: 32px minmax(0, 1fr);
|
||||
gap: 0.9rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
&__step-body {
|
||||
min-width: 0;
|
||||
padding-top: 0.15rem;
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin-bottom: 0.2rem;
|
||||
color: #1f2937;
|
||||
font-size: 0.95rem;
|
||||
|
||||
.dark & {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
line-height: 1.65;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__step-index {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(83, 157, 253, 0.12);
|
||||
color: #539dfd;
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__info {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
|
||||
@media (max-width: 640px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
&__info-cover {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
&__info-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
|
||||
strong {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
&__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.35rem;
|
||||
color: #539dfd;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
&__quote {
|
||||
margin: 0;
|
||||
padding: 1.15rem 1.25rem;
|
||||
border-left: 3px solid #539dfd;
|
||||
background: transparent;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 0.85rem;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
font-size: 0.875rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #539dfd;
|
||||
}
|
||||
}
|
||||
|
||||
&__download-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem;
|
||||
border-radius: 12px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(83, 157, 253, 0.06);
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.25rem 0;
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex-shrink: 0;
|
||||
padding: 0.45rem 0.8rem;
|
||||
border-radius: 999px;
|
||||
background: #539dfd;
|
||||
color: #fff;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__api {
|
||||
padding: 1rem 1.1rem 1.15rem;
|
||||
|
||||
p {
|
||||
margin: 0.75rem 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0.65rem 0 0;
|
||||
padding: 0.75rem;
|
||||
border-radius: 10px;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
overflow: auto;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__api-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
|
||||
code {
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
button {
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #539dfd;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__method {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 3.25rem;
|
||||
padding: 0.2rem 0.45rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: #64748b;
|
||||
|
||||
&.is-get {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
&.is-post {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
&.is-put,
|
||||
&.is-patch {
|
||||
background: #d97706;
|
||||
}
|
||||
|
||||
&.is-delete {
|
||||
background: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
&--cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1.15rem 1.25rem;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(83, 157, 253, 0.18), transparent 45%),
|
||||
linear-gradient(180deg, #f8fbff 0%, #fff 100%);
|
||||
|
||||
.dark & {
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(83, 157, 253, 0.2), transparent 45%),
|
||||
linear-gradient(180deg, #1b2230 0%, #161b26 100%);
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
font-size: 1.05rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
&__cta-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
|
||||
a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 2.5rem;
|
||||
padding: 0 1rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #dbe7f8;
|
||||
color: #334155;
|
||||
font-size: 0.875rem;
|
||||
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
|
||||
|
||||
.dark & {
|
||||
border-color: #3d4654;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
&.is-primary {
|
||||
background: #539dfd;
|
||||
border-color: #539dfd;
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: #3f8ef0;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover:not(.is-primary) {
|
||||
border-color: #539dfd;
|
||||
color: #539dfd;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
&__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(83, 157, 253, 0.12);
|
||||
color: #2563eb;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__kbd-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__kbd-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
|
||||
i {
|
||||
font-style: normal;
|
||||
color: #94a3b8;
|
||||
margin: 0 0.15rem;
|
||||
}
|
||||
|
||||
kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.75rem;
|
||||
padding: 0.2rem 0.45rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #cbd5e1;
|
||||
background: linear-gradient(180deg, #fff, #f1f5f9);
|
||||
box-shadow: 0 1px 0 #cbd5e1;
|
||||
font-size: 0.8125rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
|
||||
.dark & {
|
||||
border-color: #475569;
|
||||
background: linear-gradient(180deg, #1e293b, #0f172a);
|
||||
box-shadow: 0 1px 0 #334155;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--terminal {
|
||||
background: #0b1220 !important;
|
||||
border-color: #1e293b !important;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
&__terminal-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.65rem 0.9rem;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
color: #94a3b8;
|
||||
font-size: 0.75rem;
|
||||
|
||||
button {
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__prompt {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
&--terminal pre {
|
||||
margin: 0;
|
||||
padding: 0.9rem 1rem 1.1rem;
|
||||
overflow: auto;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.7;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&__diff-head {
|
||||
padding: 0.7rem 1rem;
|
||||
border-bottom: 1px solid #e8eef6;
|
||||
font-size: 0.8125rem;
|
||||
color: #64748b;
|
||||
|
||||
.dark & {
|
||||
border-bottom-color: #3d4654;
|
||||
}
|
||||
}
|
||||
|
||||
&__diff pre {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0;
|
||||
overflow: auto;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&__diff-line {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5rem 1fr;
|
||||
padding: 0 1rem;
|
||||
gap: 0.35rem;
|
||||
|
||||
&.is-add {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
&.is-del {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
code {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
&__gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
// padding: 0.75rem;
|
||||
|
||||
&.is-single {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
&__gallery-item {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
aspect-ratio: 4 / 3;
|
||||
cursor: zoom-in;
|
||||
background: #e2e8f0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover img {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
}
|
||||
|
||||
&__compare {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 16 / 10;
|
||||
cursor: ew-resize;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
background: #0f172a;
|
||||
|
||||
.is-after,
|
||||
.is-before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.is-before {
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__compare-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
transform: translateX(-50%);
|
||||
background: #fff;
|
||||
z-index: 2;
|
||||
|
||||
span {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #fff;
|
||||
background: #539dfd;
|
||||
transform: translate(-50%, -50%);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
&__compare-labels {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
right: 0.75rem;
|
||||
bottom: 0.75rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
|
||||
em {
|
||||
font-style: normal;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.65);
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.9rem 1.1rem 0;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
|
||||
.dark & {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
&__related {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.65rem;
|
||||
|
||||
li a {
|
||||
display: block;
|
||||
padding: 0.85rem;
|
||||
border-radius: 12px;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(83, 157, 253, 0.08);
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__related-empty {
|
||||
padding: 1rem 1.1rem;
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
&__vote {
|
||||
padding: 1.1rem;
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin-bottom: 0.85rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
button {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
.dark & {
|
||||
border-color: #3d4654;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&:not(:disabled):hover {
|
||||
border-color: #539dfd;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__vote-fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
background: rgba(83, 157, 253, 0.16);
|
||||
transition: width 0.35s ease;
|
||||
}
|
||||
|
||||
&__vote-label,
|
||||
&__vote-percent {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&__vote-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
&__vote-percent {
|
||||
color: #539dfd;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
51
src/app/article/components/MD/widgets/index.tsx
Normal file
51
src/app/article/components/MD/widgets/index.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import type { WidgetPayload } from './types';
|
||||
import {
|
||||
AudioWidget,
|
||||
BilibiliWidget,
|
||||
CtaWidget,
|
||||
DouyinWidget,
|
||||
GalleryWidget,
|
||||
NeteaseWidget,
|
||||
StepsWidget,
|
||||
TabsWidget,
|
||||
TimelineWidget,
|
||||
YoutubeWidget,
|
||||
} from './components';
|
||||
|
||||
type Props = {
|
||||
data: WidgetPayload;
|
||||
onPreview?: (src: string, urls: string[]) => void;
|
||||
};
|
||||
|
||||
export default function WidgetRenderer({ data, onPreview }: Props) {
|
||||
switch (data.type) {
|
||||
case 'bilibili':
|
||||
return <BilibiliWidget data={data} />;
|
||||
case 'youtube':
|
||||
return <YoutubeWidget data={data} />;
|
||||
case 'netease':
|
||||
return <NeteaseWidget data={data} />;
|
||||
case 'douyin':
|
||||
return <DouyinWidget data={data} />;
|
||||
case 'audio':
|
||||
return <AudioWidget data={data} />;
|
||||
case 'tabs':
|
||||
return <TabsWidget data={data} />;
|
||||
case 'timeline':
|
||||
return <TimelineWidget data={data} />;
|
||||
case 'steps':
|
||||
return <StepsWidget data={data} />;
|
||||
case 'cta':
|
||||
return <CtaWidget data={data} />;
|
||||
case 'gallery':
|
||||
return <GalleryWidget data={data} onPreview={onPreview} />;
|
||||
default:
|
||||
return (
|
||||
<div className="tx-widget tx-widget--unknown">
|
||||
未知小组件类型:{String((data as WidgetPayload).type)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
113
src/app/article/components/MD/widgets/parse.ts
Normal file
113
src/app/article/components/MD/widgets/parse.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { WidgetPayload, WidgetType } from './types';
|
||||
|
||||
const LINK_ALIAS: Record<string, WidgetType> = {
|
||||
bilibili: 'bilibili',
|
||||
youtube: 'youtube',
|
||||
netease: 'netease',
|
||||
'netease-music': 'netease',
|
||||
douyin: 'douyin',
|
||||
'douyin-video': 'douyin',
|
||||
audio: 'audio',
|
||||
};
|
||||
|
||||
function stripFenceWrappers(raw: string): string {
|
||||
return raw
|
||||
.replace(/^```(?:tx-widget|widget)?\s*/i, '')
|
||||
.replace(/```$/i, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function parseLineConfig(raw: string): Record<string, unknown> | null {
|
||||
const lines = raw
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (!lines.length) return null;
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^([A-Za-z_][\w-]*)\s*[:=]\s*(.+)$/);
|
||||
if (!match) return null;
|
||||
const key = match[1];
|
||||
let value: unknown = match[2].trim();
|
||||
if ((value as string).startsWith('[') || (value as string).startsWith('{')) {
|
||||
try {
|
||||
value = JSON.parse(value as string);
|
||||
} catch {
|
||||
// keep string
|
||||
}
|
||||
} else if (value === 'true' || value === 'false') {
|
||||
value = value === 'true';
|
||||
} else if (/^-?\d+(\.\d+)?$/.test(value as string)) {
|
||||
value = Number(value);
|
||||
} else if (
|
||||
((value as string).startsWith('"') && (value as string).endsWith('"')) ||
|
||||
((value as string).startsWith("'") && (value as string).endsWith("'"))
|
||||
) {
|
||||
value = (value as string).slice(1, -1);
|
||||
}
|
||||
result[key] = value;
|
||||
}
|
||||
|
||||
return result.type ? result : null;
|
||||
}
|
||||
|
||||
export function parseWidgetPayload(raw: string): WidgetPayload | null {
|
||||
const text = stripFenceWrappers(raw);
|
||||
if (!text) return null;
|
||||
|
||||
try {
|
||||
const json = JSON.parse(text) as WidgetPayload;
|
||||
if (json && typeof json === 'object' && typeof json.type === 'string') {
|
||||
return json;
|
||||
}
|
||||
} catch {
|
||||
// fallback to line config
|
||||
}
|
||||
|
||||
const lineConfig = parseLineConfig(text);
|
||||
if (lineConfig && typeof lineConfig.type === 'string') {
|
||||
return lineConfig as WidgetPayload;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function payloadFromLinkAlias(label: string, href?: string): WidgetPayload | null {
|
||||
const type = LINK_ALIAS[label.trim().toLowerCase()];
|
||||
if (!type || !href) return null;
|
||||
|
||||
if (type === 'bilibili') {
|
||||
const bvid = href.match(/BV[\w]+/i)?.[0] || href;
|
||||
return { type, bvid };
|
||||
}
|
||||
|
||||
if (type === 'youtube') {
|
||||
const id =
|
||||
href.match(/(?:v=|youtu\.be\/|embed\/)([\w-]{6,})/)?.[1] ||
|
||||
href.replace(/^https?:\/\//, '').split('/').pop() ||
|
||||
href;
|
||||
return { type, id };
|
||||
}
|
||||
|
||||
if (type === 'netease') {
|
||||
const id = href.match(/[?&]id=(\d+)/)?.[1] || href.match(/(\d{5,})/)?.[1] || href;
|
||||
return { type, id };
|
||||
}
|
||||
|
||||
if (type === 'douyin') {
|
||||
const id = href.match(/(\d{8,})/)?.[1] || href.split('/').pop() || href;
|
||||
return { type, id };
|
||||
}
|
||||
|
||||
if (type === 'audio') {
|
||||
return { type, src: href };
|
||||
}
|
||||
|
||||
return { type, id: href };
|
||||
}
|
||||
|
||||
export function isWidgetCodeLanguage(className?: string): boolean {
|
||||
return /language-(?:tx-widget|widget)\b/i.test(className || '');
|
||||
}
|
||||
21
src/app/article/components/MD/widgets/types.ts
Normal file
21
src/app/article/components/MD/widgets/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export type WidgetType =
|
||||
| 'bilibili'
|
||||
| 'youtube'
|
||||
| 'netease'
|
||||
| 'douyin'
|
||||
| 'audio'
|
||||
| 'tabs'
|
||||
| 'timeline'
|
||||
| 'steps'
|
||||
| 'cta'
|
||||
| 'gallery';
|
||||
|
||||
export type WidgetPayload = {
|
||||
type: WidgetType;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type TabItem = { title: string; content: string };
|
||||
export type TimelineItem = { time?: string; title: string; content?: string };
|
||||
export type StepItem = { title: string; content?: string };
|
||||
export type GalleryItem = { src: string; alt?: string };
|
||||
115
src/app/demo/widgets/content.md
Normal file
115
src/app/demo/widgets/content.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# 文章小组件演示
|
||||
|
||||
这是一篇用于预览自定义小组件的模拟文章。管理端通过 `tx-widget` 代码块(或链接别名)插入,博客端会渲染成对应组件。
|
||||
|
||||
## 媒体嵌入
|
||||
|
||||
### Bilibili
|
||||
|
||||
```tx-widget
|
||||
{
|
||||
"type": "bilibili",
|
||||
"bvid": "BV1GJ411x7h7"
|
||||
}
|
||||
```
|
||||
|
||||
### YouTube
|
||||
|
||||
```tx-widget
|
||||
{
|
||||
"type": "youtube",
|
||||
"id": "dQw4w9WgXcQ"
|
||||
}
|
||||
```
|
||||
|
||||
### 网易云音乐
|
||||
|
||||
```tx-widget
|
||||
{
|
||||
"type": "netease",
|
||||
"id": "1824045033"
|
||||
}
|
||||
```
|
||||
|
||||
### 抖音(链接别名写法)
|
||||
|
||||
[douyin-video](7234567890123456789)
|
||||
|
||||
### 音频播放器
|
||||
|
||||
```tx-widget
|
||||
{
|
||||
"type": "audio",
|
||||
"title": "演示音频",
|
||||
"artist": "ThriveX Demo",
|
||||
"src": "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3"
|
||||
}
|
||||
```
|
||||
|
||||
## 内容结构
|
||||
|
||||
### Tabs 切换
|
||||
|
||||
```tx-widget
|
||||
{
|
||||
"type": "tabs",
|
||||
"items": [
|
||||
{ "title": "React", "content": "适合组件化与生态丰富的 Web 应用。" },
|
||||
{ "title": "Vue", "content": "上手快,模板语法友好,适合中后台。" },
|
||||
{ "title": "Svelte", "content": "编译期优化,运行时更轻。" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 时间线
|
||||
|
||||
```tx-widget
|
||||
{
|
||||
"type": "timeline",
|
||||
"items": [
|
||||
{ "time": "2024", "title": "起步", "content": "搭建博客基础能力。" },
|
||||
{ "time": "2025", "title": "增强", "content": "补齐主题、评论与缓存。" },
|
||||
{ "time": "2026", "title": "小组件", "content": "文章内可嵌入可交互模块。" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤条
|
||||
|
||||
```tx-widget
|
||||
{
|
||||
"type": "steps",
|
||||
"items": [
|
||||
{ "title": "写 Markdown", "content": "在正文中插入 tx-widget 语法。" },
|
||||
{ "title": "管理端预览", "content": "发布前确认组件参数。" },
|
||||
{ "title": "博客渲染", "content": "读者看到的是真实小组件。" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 画廊
|
||||
|
||||
```tx-widget
|
||||
{
|
||||
"type": "gallery",
|
||||
"items": [
|
||||
{ "src": "https://picsum.photos/seed/a1/800/600", "alt": "风景 1" },
|
||||
{ "src": "https://picsum.photos/seed/a2/800/600", "alt": "风景 2" },
|
||||
{ "src": "https://picsum.photos/seed/a3/800/600", "alt": "风景 3" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## CTA
|
||||
|
||||
```tx-widget
|
||||
{
|
||||
"type": "cta",
|
||||
"title": "想把 ThriveX 用到自己的博客?",
|
||||
"description": "开箱即用的管理端 + 博客端,支持主题与内容扩展。",
|
||||
"primaryText": "查看项目",
|
||||
"primaryUrl": "https://github.com",
|
||||
"secondaryText": "阅读文档",
|
||||
"secondaryUrl": "https://liuyuyang.net"
|
||||
}
|
||||
```
|
||||
50
src/app/demo/widgets/page.tsx
Normal file
50
src/app/demo/widgets/page.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import MD from '@/app/article/components/MD';
|
||||
import Summary from '@/app/article/components/Summary';
|
||||
import ArticleTOC from '@/app/article/components/ArticleTOC';
|
||||
import { extractArticleHeadings } from '@/utils/article';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '文章小组件演示',
|
||||
description: '预览 ThriveX 文章自定义小组件效果,便于决定保留哪些组件',
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
};
|
||||
|
||||
function loadDemoMarkdown() {
|
||||
const filePath = path.join(process.cwd(), 'src/app/demo/widgets/content.md');
|
||||
return readFileSync(filePath, 'utf-8');
|
||||
}
|
||||
|
||||
export default function WidgetsDemoPage() {
|
||||
const content = loadDemoMarkdown();
|
||||
const headings = extractArticleHeadings(content);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[#f7f8fa] pb-20 pt-24 dark:bg-black-a">
|
||||
<div className="mx-auto w-[92%] max-w-4xl">
|
||||
<header className="mb-8 rounded-2xl border border-[#e8eef6] bg-white px-6 py-7 shadow-[0_10px_30px_rgba(83,157,253,0.08)] dark:border-[#3d4654] dark:bg-[#1b2230]">
|
||||
<p className="mb-2 text-sm tracking-wide text-primary">DEMO</p>
|
||||
<h1 className="text-2xl font-semibold text-[#1f2937] dark:text-slate-100 sm:text-3xl">
|
||||
文章小组件演示
|
||||
</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-[#667085] dark:text-slate-400 sm:text-base">
|
||||
这页不依赖数据库,直接渲染模拟 Markdown。看完效果后告诉我要保留哪些组件即可。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rounded-2xl border border-[#e8eef6] bg-white px-4 py-6 dark:border-[#3d4654] dark:bg-[#161b26] sm:px-8">
|
||||
<ArticleTOC headings={headings}>
|
||||
<Summary content="当前保留:媒体嵌入、Tabs / 时间线 / 步骤、画廊、CTA。统一用 tx-widget 语法插入。" />
|
||||
<MD data={content} headings={headings} />
|
||||
</ArticleTOC>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user