diff --git a/apps/user/app/(main)/(user)/document/document-button.tsx b/apps/user/app/(main)/(user)/document/document-button.tsx new file mode 100644 index 0000000..113ea8c --- /dev/null +++ b/apps/user/app/(main)/(user)/document/document-button.tsx @@ -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(null); + const id = useId(); + const ref = useRef(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 ( + <> + + {active && typeof active === 'object' && ( + + )} + + + {active && typeof active === 'object' ? ( +
+ setActive(null)} + > + + + + { + return ( + // eslint-disable-next-line @next/next/no-img-element + + ); + }, + }} + > + {data || ''} + + +
+ ) : null} +
+ + + ); +} + +export const CloseIcon = () => { + return ( + + + + + + ); +}; diff --git a/apps/user/app/(main)/(user)/document/page.tsx b/apps/user/app/(main)/(user)/document/page.tsx index 8e19d78..66058a2 100644 --- a/apps/user/app/(main)/(user)/document/page.tsx +++ b/apps/user/app/(main)/(user)/document/page.tsx @@ -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([]); - const [selected, setSelected] = useState(); 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 ( - - {selected ? ( - - -
- - {data?.title} - {formatDate(data?.updated_at)} -
-
- - {data?.content || ''} - -
- ) : ( - - 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 ( - - - {item.title} - - - - - - -
    -
  • - {t('tags')} - {item.tags.join(', ')} -
  • -
  • - {t('updatedAt')} - -
  • -
-
-
- ); - }} - /> +
+ {DocumentList?.length > 0 && ( + <> +

{t('document')}

+ + + {t('all')} + {tags?.map((item) => ( + + {item} + + ))} + + + + + {tags?.map((item) => ( + + (item ? docs.tags.includes(item) : true))} + /> + + ))} + + )} - + + {TutorialList && TutorialList?.length > 0 && ( + <> +

{t('tutorial')}

+ + + {TutorialList?.map((tutorial) => ( + + {tutorial.title} + + ))} + + {TutorialList?.map((tutorial) => ( + + 0 + ? tutorial.subItems + : [tutorial] + } + /> + + ))} + + + )} +
); } diff --git a/apps/user/app/(main)/(user)/document/tutorial-button.tsx b/apps/user/app/(main)/(user)/document/tutorial-button.tsx new file mode 100644 index 0000000..f5fb774 --- /dev/null +++ b/apps/user/app/(main)/(user)/document/tutorial-button.tsx @@ -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(null); + const id = useId(); + const ref = useRef(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 ( + <> + + {active && typeof active === 'object' && ( + + )} + + + {active && typeof active === 'object' ? ( +
+ setActive(null)} + > + + + + { + return ( + // eslint-disable-next-line @next/next/no-img-element + + ); + }, + }} + > + {data || ''} + + +
+ ) : null} +
+
    + {items.map((item) => ( + setActive(item)} + className='flex cursor-pointer flex-col rounded-xl' + > + + + ))} +
+ + ); +} + +export const CloseIcon = () => { + return ( + + + + + + ); +}; diff --git a/apps/user/app/(main)/(user)/layout.tsx b/apps/user/app/(main)/(user)/layout.tsx index 1a2b24e..3878efa 100644 --- a/apps/user/app/(main)/(user)/layout.tsx +++ b/apps/user/app/(main)/(user)/layout.tsx @@ -6,11 +6,11 @@ export default async function DashboardLayout({ children }: { children: React.Re return ( - + {/*
*/} -
{children}
+ {children} - + ); } diff --git a/apps/user/locales/zh-CN/document.json b/apps/user/locales/zh-CN/document.json index 9fc6910..143ba40 100644 --- a/apps/user/locales/zh-CN/document.json +++ b/apps/user/locales/zh-CN/document.json @@ -1,8 +1,5 @@ { - "back": "返回", - "category": "分类", - "read": "阅读", - "tags": "分类", - "title": "标题", - "updatedAt": "更新时间" + "all": "全部", + "document": "文档", + "tutorial": "教程" } diff --git a/apps/user/utils/tutorial.ts b/apps/user/utils/tutorial.ts new file mode 100644 index 0000000..f2cec5c --- /dev/null +++ b/apps/user/utils/tutorial.ts @@ -0,0 +1,80 @@ +const BASE_URL = 'https://cdn.jsdelivr.net/gh/perfect-panel/ppanel-tutorial@main'; + +export async function getTutorial(path: string): Promise { + 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 { + const map = new Map(); + 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}`})`; + }); +} diff --git a/packages/ui/src/markdown.tsx b/packages/ui/src/markdown.tsx index c5e5bca..1a68b4b 100644 --- a/packages/ui/src/markdown.tsx +++ b/packages/ui/src/markdown.tsx @@ -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 ( ), code(props) { - return ; + return ; }, + ...components, }} > {children}