✨ feat(tutorial): Add common tutorial list
This commit is contained in:
parent
24b86010e6
commit
872252c98c
153
apps/user/app/(main)/(user)/document/document-button.tsx
Normal file
153
apps/user/app/(main)/(user)/document/document-button.tsx
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { queryDocumentDetail } from '@/services/user/document';
|
||||||
|
import { Markdown } from '@repo/ui/markdown';
|
||||||
|
import { Button } from '@shadcn/ui/button';
|
||||||
|
import { useOutsideClick } from '@shadcn/ui/hooks/use-outside-click';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import { useEffect, useId, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
export function DocumentButton({ items }: { items: Item[] }) {
|
||||||
|
const [active, setActive] = useState<Item | boolean | null>(null);
|
||||||
|
const id = useId();
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { data } = useQuery({
|
||||||
|
enabled: !!(active as Item)?.id,
|
||||||
|
queryKey: ['queryDocumentDetail', (active as Item)?.id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await queryDocumentDetail({
|
||||||
|
id: (active as Item)?.id,
|
||||||
|
});
|
||||||
|
return data.data?.content;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setActive(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active && typeof active === 'object') {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
useOutsideClick(ref, () => setActive(null));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AnimatePresence>
|
||||||
|
{active && typeof active === 'object' && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className='fixed inset-0 z-10 h-full w-full bg-black/20'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
<AnimatePresence>
|
||||||
|
{active && typeof active === 'object' ? (
|
||||||
|
<div className='fixed inset-0 z-[100] grid place-items-center'>
|
||||||
|
<motion.button
|
||||||
|
key={`button-${active.title}-${id}`}
|
||||||
|
layout
|
||||||
|
initial={{
|
||||||
|
opacity: 0,
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
}}
|
||||||
|
exit={{
|
||||||
|
opacity: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.05,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
className='bg-foreground absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full'
|
||||||
|
onClick={() => setActive(null)}
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</motion.button>
|
||||||
|
<motion.div
|
||||||
|
layoutId={`card-${active.id}-${id}`}
|
||||||
|
ref={ref}
|
||||||
|
className='bg-muted flex size-full flex-col overflow-auto p-6 sm:rounded'
|
||||||
|
>
|
||||||
|
<Markdown
|
||||||
|
components={{
|
||||||
|
img: ({ node, className, ...props }) => {
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img {...props} width={800} height={600} className='my-4 h-auto' />
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data || ''}
|
||||||
|
</Markdown>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
<ul className='flex w-full flex-wrap items-start gap-4'>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<motion.div
|
||||||
|
layoutId={`card-${item.id}-${id}`}
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => setActive(item)}
|
||||||
|
className='flex cursor-pointer flex-col rounded-xl'
|
||||||
|
>
|
||||||
|
<Button variant='secondary'>{item.title}</Button>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CloseIcon = () => {
|
||||||
|
return (
|
||||||
|
<motion.svg
|
||||||
|
initial={{
|
||||||
|
opacity: 0,
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
}}
|
||||||
|
exit={{
|
||||||
|
opacity: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.05,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
width='24'
|
||||||
|
height='24'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
strokeWidth='2'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
className='h-4 w-4 text-black'
|
||||||
|
>
|
||||||
|
<path stroke='none' d='M0 0h24v24H0z' fill='none' />
|
||||||
|
<path d='M18 6l-12 12' />
|
||||||
|
<path d='M6 6l12 12' />
|
||||||
|
</motion.svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,110 +1,92 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ProList } from '@/components/pro-list';
|
import { queryDocumentList } from '@/services/user/document';
|
||||||
import { queryDocumentDetail, queryDocumentList } from '@/services/user/document';
|
import { getTutorialList } from '@/utils/tutorial';
|
||||||
import { Markdown } from '@repo/ui/markdown';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@shadcn/ui/tabs';
|
||||||
import { formatDate } from '@repo/ui/utils';
|
|
||||||
import { Button } from '@shadcn/ui/button';
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@shadcn/ui/card';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { ChevronLeft } from 'lucide-react';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { useTranslations } from 'next-intl';
|
import { DocumentButton } from './document-button';
|
||||||
import { Fragment, useState } from 'react';
|
import { TutorialButton } from './tutorial-button';
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
|
const locale = useLocale();
|
||||||
const t = useTranslations('document');
|
const t = useTranslations('document');
|
||||||
const [tags, setTags] = useState<string[]>([]);
|
|
||||||
const [selected, setSelected] = useState<number>();
|
|
||||||
|
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
enabled: !!selected,
|
queryKey: ['queryDocumentList'],
|
||||||
queryKey: ['queryDocumentDetail', selected],
|
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await queryDocumentDetail({
|
const response = await queryDocumentList();
|
||||||
id: selected!,
|
const list = response.data.data?.list || [];
|
||||||
});
|
return {
|
||||||
return data.data;
|
tags: Array.from(new Set(list.reduce((acc: string[], item) => acc.concat(item.tags), []))),
|
||||||
|
list,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { tags, list: DocumentList } = data || { tags: [], list: [] };
|
||||||
|
|
||||||
|
const { data: TutorialList } = useQuery({
|
||||||
|
queryKey: ['getTutorialList', locale],
|
||||||
|
queryFn: async () => {
|
||||||
|
const list = await getTutorialList();
|
||||||
|
return list.get(locale);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<div className='space-y-4'>
|
||||||
{selected ? (
|
{DocumentList?.length > 0 && (
|
||||||
<Card>
|
<>
|
||||||
<CardHeader className='pb-2'>
|
<h2 className='flex items-center gap-1.5 font-semibold'>{t('document')}</h2>
|
||||||
<div className='flex items-center justify-between'>
|
<Tabs defaultValue='all'>
|
||||||
<Button variant='outline' onClick={() => setSelected(undefined)}>
|
<TabsList className='h-full flex-wrap'>
|
||||||
<ChevronLeft className='size-4' />
|
<TabsTrigger value='all'>{t('all')}</TabsTrigger>
|
||||||
{t('back')}
|
{tags?.map((item) => (
|
||||||
</Button>
|
<TabsTrigger key={item} value={item}>
|
||||||
<CardTitle className='font-medium'>{data?.title}</CardTitle>
|
{item}
|
||||||
<CardDescription>{formatDate(data?.updated_at)}</CardDescription>
|
</TabsTrigger>
|
||||||
</div>
|
))}
|
||||||
</CardHeader>
|
</TabsList>
|
||||||
<CardContent>
|
<TabsContent value='all'>
|
||||||
<Markdown>{data?.content || ''}</Markdown>
|
<DocumentButton items={DocumentList} />
|
||||||
</CardContent>
|
</TabsContent>
|
||||||
</Card>
|
{tags?.map((item) => (
|
||||||
) : (
|
<TabsContent value={item} key={item}>
|
||||||
<ProList<API.DocumentItem, { tag: string }>
|
<DocumentButton
|
||||||
params={[
|
items={DocumentList.filter((docs) => (item ? docs.tags.includes(item) : true))}
|
||||||
{
|
/>
|
||||||
key: 'tag',
|
</TabsContent>
|
||||||
placeholder: t('category'),
|
))}
|
||||||
options: tags.map((item) => ({
|
</Tabs>
|
||||||
label: item,
|
</>
|
||||||
value: item,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
request={async (_, filter) => {
|
|
||||||
const response = await queryDocumentList();
|
|
||||||
const list = response.data.data?.list || [];
|
|
||||||
setTags(
|
|
||||||
Array.from(new Set(list.reduce((acc: string[], item) => acc.concat(item.tags), []))),
|
|
||||||
);
|
|
||||||
const filterList = list.filter((item) =>
|
|
||||||
filter.tag ? item.tags.includes(filter.tag) : true,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
list: filterList,
|
|
||||||
total: filterList.length || 0,
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
renderItem={(item) => {
|
|
||||||
return (
|
|
||||||
<Card className='overflow-hidden'>
|
|
||||||
<CardHeader className='bg-muted/50 flex flex-row items-center justify-between gap-2 space-y-0 p-3'>
|
|
||||||
<CardTitle>{item.title}</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
<Button
|
|
||||||
size='sm'
|
|
||||||
onClick={() => {
|
|
||||||
setSelected(item.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('read')}
|
|
||||||
</Button>
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className='p-3 text-sm'>
|
|
||||||
<ul className='grid gap-3 *:flex *:flex-col lg:grid-cols-2'>
|
|
||||||
<li>
|
|
||||||
<span className='text-muted-foreground'>{t('tags')}</span>
|
|
||||||
<span>{item.tags.join(', ')}</span>
|
|
||||||
</li>
|
|
||||||
<li className='font-semibold'>
|
|
||||||
<span className='text-muted-foreground'>{t('updatedAt')}</span>
|
|
||||||
<time>{formatDate(item.updated_at)}</time>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</Fragment>
|
|
||||||
|
{TutorialList && TutorialList?.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h2 className='flex items-center gap-1.5 font-semibold'>{t('tutorial')}</h2>
|
||||||
|
<Tabs defaultValue={TutorialList?.[0]?.title}>
|
||||||
|
<TabsList className='h-full flex-wrap'>
|
||||||
|
{TutorialList?.map((tutorial) => (
|
||||||
|
<TabsTrigger key={tutorial.title} value={tutorial.title}>
|
||||||
|
{tutorial.title}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
{TutorialList?.map((tutorial) => (
|
||||||
|
<TabsContent key={tutorial.title} value={tutorial.title}>
|
||||||
|
<TutorialButton
|
||||||
|
key={tutorial.path}
|
||||||
|
items={
|
||||||
|
tutorial.subItems && tutorial.subItems?.length > 0
|
||||||
|
? tutorial.subItems
|
||||||
|
: [tutorial]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
151
apps/user/app/(main)/(user)/document/tutorial-button.tsx
Normal file
151
apps/user/app/(main)/(user)/document/tutorial-button.tsx
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { getTutorial } from '@/utils/tutorial';
|
||||||
|
import { Markdown } from '@repo/ui/markdown';
|
||||||
|
import { Button } from '@shadcn/ui/button';
|
||||||
|
import { useOutsideClick } from '@shadcn/ui/hooks/use-outside-click';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import { useEffect, useId, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
export function TutorialButton({ items }: { items: Item[] }) {
|
||||||
|
const [active, setActive] = useState<Item | boolean | null>(null);
|
||||||
|
const id = useId();
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { data } = useQuery({
|
||||||
|
enabled: !!(active as Item)?.path,
|
||||||
|
queryKey: ['getTutorial', (active as Item)?.path],
|
||||||
|
queryFn: async () => {
|
||||||
|
const markdown = await getTutorial((active as Item)?.path);
|
||||||
|
return markdown;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setActive(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active && typeof active === 'object') {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
useOutsideClick(ref, () => setActive(null));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AnimatePresence>
|
||||||
|
{active && typeof active === 'object' && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className='fixed inset-0 z-10 h-full w-full bg-black/20'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
<AnimatePresence>
|
||||||
|
{active && typeof active === 'object' ? (
|
||||||
|
<div className='fixed inset-0 z-[100] grid place-items-center'>
|
||||||
|
<motion.button
|
||||||
|
key={`button-${active.title}-${id}`}
|
||||||
|
layout
|
||||||
|
initial={{
|
||||||
|
opacity: 0,
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
}}
|
||||||
|
exit={{
|
||||||
|
opacity: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.05,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
className='bg-foreground absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full'
|
||||||
|
onClick={() => setActive(null)}
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</motion.button>
|
||||||
|
<motion.div
|
||||||
|
layoutId={`card-${active.title}-${id}`}
|
||||||
|
ref={ref}
|
||||||
|
className='bg-muted flex size-full flex-col overflow-auto p-6 sm:rounded'
|
||||||
|
>
|
||||||
|
<Markdown
|
||||||
|
components={{
|
||||||
|
img: ({ node, className, ...props }) => {
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img {...props} width={800} height={600} className='my-4 h-auto' />
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data || ''}
|
||||||
|
</Markdown>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
<ul className='flex w-full flex-wrap items-start gap-4'>
|
||||||
|
{items.map((item) => (
|
||||||
|
<motion.div
|
||||||
|
layoutId={`card-${item.title}-${id}`}
|
||||||
|
key={item.title}
|
||||||
|
onClick={() => setActive(item)}
|
||||||
|
className='flex cursor-pointer flex-col rounded-xl'
|
||||||
|
>
|
||||||
|
<Button variant='secondary'>{item.title}</Button>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CloseIcon = () => {
|
||||||
|
return (
|
||||||
|
<motion.svg
|
||||||
|
initial={{
|
||||||
|
opacity: 0,
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
}}
|
||||||
|
exit={{
|
||||||
|
opacity: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.05,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
width='24'
|
||||||
|
height='24'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
strokeWidth='2'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
className='h-4 w-4 text-black'
|
||||||
|
>
|
||||||
|
<path stroke='none' d='M0 0h24v24H0z' fill='none' />
|
||||||
|
<path d='M18 6l-12 12' />
|
||||||
|
<path d='M6 6l12 12' />
|
||||||
|
</motion.svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -6,11 +6,11 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|||||||
return (
|
return (
|
||||||
<SidebarProvider className='container'>
|
<SidebarProvider className='container'>
|
||||||
<SidebarLeft className='sticky top-[84px] hidden w-52 border-r-0 bg-transparent lg:flex' />
|
<SidebarLeft className='sticky top-[84px] hidden w-52 border-r-0 bg-transparent lg:flex' />
|
||||||
<SidebarInset className='relative flex-grow overflow-hidden'>
|
<SidebarInset className='relative p-4'>
|
||||||
{/* <Header /> */}
|
{/* <Header /> */}
|
||||||
<div className='overflow-auto p-4'>{children}</div>
|
{children}
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
<SidebarRight className='sticky top-[84px] hidden w-52 border-r-0 bg-transparent lg:flex' />
|
<SidebarRight className='sticky top-[84px] hidden w-52 border-r-0 bg-transparent 2xl:flex' />
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,5 @@
|
|||||||
{
|
{
|
||||||
"back": "返回",
|
"all": "全部",
|
||||||
"category": "分类",
|
"document": "文档",
|
||||||
"read": "阅读",
|
"tutorial": "教程"
|
||||||
"tags": "分类",
|
|
||||||
"title": "标题",
|
|
||||||
"updatedAt": "更新时间"
|
|
||||||
}
|
}
|
||||||
|
|||||||
80
apps/user/utils/tutorial.ts
Normal file
80
apps/user/utils/tutorial.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
const BASE_URL = 'https://cdn.jsdelivr.net/gh/perfect-panel/ppanel-tutorial@main';
|
||||||
|
|
||||||
|
export async function getTutorial(path: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const url = `${BASE_URL}/${path}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const text = await response.text();
|
||||||
|
const markdown = addPrefixToImageUrls(text, getUrlPrefix(url));
|
||||||
|
return markdown;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching the markdown file:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type TutorialItem = {
|
||||||
|
title: string;
|
||||||
|
path: string;
|
||||||
|
subItems?: TutorialItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getTutorialList() {
|
||||||
|
return await getTutorial('SUMMARY.md').then((markdown) => {
|
||||||
|
const map = parseTutorialToMap(
|
||||||
|
markdown.replace(/en-us/gi, 'en-US').replace(/zh-cn/gi, 'zh-CN'),
|
||||||
|
);
|
||||||
|
map.forEach((value, key) => {
|
||||||
|
map.set(
|
||||||
|
key,
|
||||||
|
value.filter((item) => item.title !== 'README'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTutorialToMap(markdown: string): Map<string, TutorialItem[]> {
|
||||||
|
const map = new Map<string, TutorialItem[]>();
|
||||||
|
let currentSection = '';
|
||||||
|
const lines = markdown.split('\n');
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('## ')) {
|
||||||
|
currentSection = line.replace('## ', '').trim();
|
||||||
|
map.set(currentSection, []);
|
||||||
|
} else if (line.startsWith('* ')) {
|
||||||
|
const [, text, link] = line.match(/\* \[(.*?)\]\((.*?)\)/) || [];
|
||||||
|
if (text && link) {
|
||||||
|
if (!map.has(currentSection)) {
|
||||||
|
map.set(currentSection, []);
|
||||||
|
}
|
||||||
|
map.get(currentSection)!.push({ title: text, path: link });
|
||||||
|
}
|
||||||
|
} else if (line.startsWith(' * ')) {
|
||||||
|
const [, text, link] = line.match(/\* \[(.*?)\]\((.*?)\)/) || [];
|
||||||
|
if (text && link) {
|
||||||
|
const lastItem = map.get(currentSection)?.slice(-1)[0];
|
||||||
|
if (lastItem) {
|
||||||
|
if (!lastItem.subItems) {
|
||||||
|
lastItem.subItems = [];
|
||||||
|
}
|
||||||
|
lastItem.subItems.push({ title: text, path: link });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
function getUrlPrefix(url: string): string {
|
||||||
|
return url.replace(/\/[^/]+\.md$/, '/');
|
||||||
|
}
|
||||||
|
function addPrefixToImageUrls(markdown: string, prefix: string): string {
|
||||||
|
return markdown.replace(/!\[(.*?)\]\((.*?)\)/g, (match, imgAlt, imgUrl) => {
|
||||||
|
return ` ? imgUrl : `${prefix}${imgUrl}`})`;
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -5,7 +5,7 @@ import { cn } from '@shadcn/ui/lib/utils';
|
|||||||
import 'katex/dist/katex.min.css';
|
import 'katex/dist/katex.min.css';
|
||||||
import { Check, Copy } from 'lucide-react';
|
import { Check, Copy } from 'lucide-react';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown, { Components } from 'react-markdown';
|
||||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
import rehypeKatex from 'rehype-katex';
|
import rehypeKatex from 'rehype-katex';
|
||||||
@ -20,7 +20,7 @@ interface CodeBlockProps {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CodeBlock({ className, children, dark, ...props }: CodeBlockProps) {
|
function CodeBlock({ className, children, ...props }: CodeBlockProps) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const match = className?.startsWith('language-') ? /language-(\w+)/.exec(className) : null;
|
const match = className?.startsWith('language-') ? /language-(\w+)/.exec(className) : null;
|
||||||
|
|
||||||
@ -77,10 +77,10 @@ function CodeBlock({ className, children, dark, ...props }: CodeBlockProps) {
|
|||||||
|
|
||||||
interface MarkdownProps {
|
interface MarkdownProps {
|
||||||
children: string;
|
children: string;
|
||||||
dark?: false;
|
components?: Components;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Markdown({ children, dark }: MarkdownProps) {
|
export function Markdown({ children, components }: MarkdownProps) {
|
||||||
return (
|
return (
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
className='prose dark:prose-invert w-full max-w-[unset] break-words'
|
className='prose dark:prose-invert w-full max-w-[unset] break-words'
|
||||||
@ -197,8 +197,9 @@ export function Markdown({ children, dark }: MarkdownProps) {
|
|||||||
<pre className={cn('overflow-x-auto rounded-b-lg p-0', className)} {...props} />
|
<pre className={cn('overflow-x-auto rounded-b-lg p-0', className)} {...props} />
|
||||||
),
|
),
|
||||||
code(props) {
|
code(props) {
|
||||||
return <CodeBlock {...(props as CodeBlockProps)} dark={dark} />;
|
return <CodeBlock {...(props as CodeBlockProps)} />;
|
||||||
},
|
},
|
||||||
|
...components,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user