feat(tutorial): Add common tutorial list

This commit is contained in:
web@ppanel 2024-11-16 21:56:18 +07:00
parent 24b86010e6
commit 872252c98c
7 changed files with 472 additions and 108 deletions

View 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>
);
};

View File

@ -1,110 +1,92 @@
'use client';
import { ProList } from '@/components/pro-list';
import { queryDocumentDetail, queryDocumentList } from '@/services/user/document';
import { Markdown } from '@repo/ui/markdown';
import { formatDate } from '@repo/ui/utils';
import { Button } from '@shadcn/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@shadcn/ui/card';
import { queryDocumentList } from '@/services/user/document';
import { getTutorialList } from '@/utils/tutorial';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@shadcn/ui/tabs';
import { useQuery } from '@tanstack/react-query';
import { ChevronLeft } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Fragment, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { DocumentButton } from './document-button';
import { TutorialButton } from './tutorial-button';
export default function Page() {
const locale = useLocale();
const t = useTranslations('document');
const [tags, setTags] = useState<string[]>([]);
const [selected, setSelected] = useState<number>();
const { data } = useQuery({
enabled: !!selected,
queryKey: ['queryDocumentDetail', selected],
queryKey: ['queryDocumentList'],
queryFn: async () => {
const { data } = await queryDocumentDetail({
id: selected!,
});
return data.data;
const response = await queryDocumentList();
const list = response.data.data?.list || [];
return {
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 (
<Fragment>
{selected ? (
<Card>
<CardHeader className='pb-2'>
<div className='flex items-center justify-between'>
<Button variant='outline' onClick={() => setSelected(undefined)}>
<ChevronLeft className='size-4' />
{t('back')}
</Button>
<CardTitle className='font-medium'>{data?.title}</CardTitle>
<CardDescription>{formatDate(data?.updated_at)}</CardDescription>
</div>
</CardHeader>
<CardContent>
<Markdown>{data?.content || ''}</Markdown>
</CardContent>
</Card>
) : (
<ProList<API.DocumentItem, { tag: string }>
params={[
{
key: 'tag',
placeholder: t('category'),
options: tags.map((item) => ({
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>
);
}}
/>
<div className='space-y-4'>
{DocumentList?.length > 0 && (
<>
<h2 className='flex items-center gap-1.5 font-semibold'>{t('document')}</h2>
<Tabs defaultValue='all'>
<TabsList className='h-full flex-wrap'>
<TabsTrigger value='all'>{t('all')}</TabsTrigger>
{tags?.map((item) => (
<TabsTrigger key={item} value={item}>
{item}
</TabsTrigger>
))}
</TabsList>
<TabsContent value='all'>
<DocumentButton items={DocumentList} />
</TabsContent>
{tags?.map((item) => (
<TabsContent value={item} key={item}>
<DocumentButton
items={DocumentList.filter((docs) => (item ? docs.tags.includes(item) : true))}
/>
</TabsContent>
))}
</Tabs>
</>
)}
</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>
);
}

View 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>
);
};

View File

@ -6,11 +6,11 @@ export default async function DashboardLayout({ children }: { children: React.Re
return (
<SidebarProvider className='container'>
<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 /> */}
<div className='overflow-auto p-4'>{children}</div>
{children}
</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>
);
}

View File

@ -1,8 +1,5 @@
{
"back": "返回",
"category": "分类",
"read": "阅读",
"tags": "分类",
"title": "标题",
"updatedAt": "更新时间"
"all": "全部",
"document": "文档",
"tutorial": "教程"
}

View 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 `![${imgAlt}](${imgUrl.startsWith('http') ? imgUrl : `${prefix}${imgUrl}`})`;
});
}

View File

@ -5,7 +5,7 @@ import { cn } from '@shadcn/ui/lib/utils';
import 'katex/dist/katex.min.css';
import { Check, Copy } from 'lucide-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 { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import rehypeKatex from 'rehype-katex';
@ -20,7 +20,7 @@ interface CodeBlockProps {
[key: string]: unknown;
}
function CodeBlock({ className, children, dark, ...props }: CodeBlockProps) {
function CodeBlock({ className, children, ...props }: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const match = className?.startsWith('language-') ? /language-(\w+)/.exec(className) : null;
@ -77,10 +77,10 @@ function CodeBlock({ className, children, dark, ...props }: CodeBlockProps) {
interface MarkdownProps {
children: string;
dark?: false;
components?: Components;
}
export function Markdown({ children, dark }: MarkdownProps) {
export function Markdown({ children, components }: MarkdownProps) {
return (
<ReactMarkdown
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} />
),
code(props) {
return <CodeBlock {...(props as CodeBlockProps)} dark={dark} />;
return <CodeBlock {...(props as CodeBlockProps)} />;
},
...components,
}}
>
{children}