码桶
发现社区成员的开源项目
DocsModal.tsx3.4 KB
import { useEffect, useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Loader2 } from 'lucide-react'
function escapeHtml(s: string) {
return s
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
}
/** 行内语法:加粗 / 行内代码 / 链接 */
function inline(s: string): string {
let t = escapeHtml(s)
t = t.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
t = t.replace(/`([^`]+?)`/g, '<code class="rounded bg-muted px-1 py-0.5 text-[11px]">$1</code>')
t = t.replace(/\[(.+?)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer" class="text-sky-600 underline">$1</a>')
return t
}
export function DocsModal({ open, onOpenChange, file, title }: {
open: boolean
onOpenChange: (v: boolean) => void
file: string
title: string
}) {
const [text, setText] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (!open) return
let active = true
setLoading(true); setError(''); setText('')
fetch(`/docs/${encodeURIComponent(file)}`)
.then(r => { if (!r.ok) throw new Error('文档加载失败 (' + r.status + ')'); return r.text() })
.then(t => { if (active) setText(t) })
.catch(e => { if (active) setError(e.message) })
.finally(() => { if (active) setLoading(false) })
return () => { active = false }
}, [open, file])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
{loading && (
<div className="flex items-center justify-center py-10 text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> 加载中…
</div>
)}
{error && <p className="py-6 text-center text-sm text-destructive">{error}</p>}
{!loading && !error && (
<div className="space-y-3 overflow-y-auto no-scrollbar pr-1 text-sm leading-relaxed text-foreground/90">
{text.split('\n').map((line, i) => {
if (line.startsWith('```')) return null
if (line.startsWith('### ')) return <h3 key={i} className="mt-3 text-base font-semibold">{inline(line.slice(4))}</h3>
if (line.startsWith('## ')) return <h2 key={i} className="mt-4 border-b pb-1 text-lg font-bold">{inline(line.slice(3))}</h2>
if (line.startsWith('# ')) return <h1 key={i} className="mt-2 text-xl font-bold">{inline(line.slice(2))}</h1>
if (line.startsWith('> ')) return <blockquote key={i} className="border-l-2 border-muted-foreground/30 pl-3 text-muted-foreground">{inline(line.slice(2))}</blockquote>
if (line.startsWith('- ') || line.startsWith('* ')) return <div key={i} className="flex gap-2 pl-2"><span className="text-muted-foreground">•</span><span dangerouslySetInnerHTML={{ __html: inline(line.slice(2)) }} /></div>
if (line.trim() === '---') return <hr key={i} className="my-3 border-muted" />
if (line.trim() === '') return <div key={i} className="h-1" />
return <p key={i} dangerouslySetInnerHTML={{ __html: inline(line) }} />
})}
</div>
)}
</DialogContent>
</Dialog>
)
}