feat: optimize docs pages and add 4 new doc articles (en + zh)

- Rewrote DocsListPage and DocDetailPage with landing.css aesthetic
  (icon cards, skeleton loader, prose styles, CTA box)
- Added docs-specific CSS to landing.css
- Created image-to-latex, copy-to-word, ocr-accuracy, pdf-extraction
  articles in both English and Chinese
- Updated DocsSeoSection guide cards to link to real doc slugs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 16:15:22 +08:00
parent dceb775a1b
commit 409bbf742e
14 changed files with 2855 additions and 67 deletions

View File

@@ -3,6 +3,7 @@ import { useLanguage } from '../../contexts/LanguageContext';
const GUIDES = [
{
slug: 'image-to-latex',
svgPaths: (
<>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
@@ -15,6 +16,7 @@ const GUIDES = [
metaZh: '5 分钟 · 最受欢迎',
},
{
slug: 'copy-to-word',
svgPaths: (
<>
<rect x="4" y="4" width="16" height="16" rx="2"/>
@@ -27,6 +29,7 @@ const GUIDES = [
metaZh: '4 分钟 · 扩展用户',
},
{
slug: 'ocr-accuracy',
svgPaths: (
<>
<circle cx="11" cy="11" r="8"/>
@@ -39,6 +42,7 @@ const GUIDES = [
metaZh: '6 分钟 · 进阶用户',
},
{
slug: 'pdf-extraction',
svgPaths: (
<>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
@@ -71,7 +75,7 @@ export default function DocsSeoSection() {
<div className="doc-cards reveal">
{GUIDES.map((g, i) => (
<Link key={i} to="/docs" className="doc-card">
<Link key={i} to={`/docs/${g.slug}`} className="doc-card">
<div className="doc-card-left">
<div className="doc-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">

View File

@@ -1,35 +1,78 @@
import { useEffect, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react';
import SEOHead from '../components/seo/SEOHead';
import { useLanguage } from '../contexts/LanguageContext';
import { loadContent, type ContentItem } from '../lib/content';
function estimateReadTime(html: string): number {
const text = html.replace(/<[^>]+>/g, '');
const words = text.split(/\s+/).filter(Boolean).length;
return Math.max(2, Math.round(words / 200));
}
function formatDate(dateStr: string, lang: string): string {
const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
return d.toLocaleDateString(lang === 'zh' ? 'zh-CN' : 'en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
export default function DocDetailPage() {
const { slug } = useParams<{ slug: string }>();
const { language } = useLanguage();
const [content, setContent] = useState<ContentItem | null>(null);
const [notFound, setNotFound] = useState(false);
useEffect(() => {
setContent(null);
setNotFound(false);
if (slug) {
loadContent('docs', language, slug)
.then(setContent)
.catch(() => setContent(null));
.catch(() => setNotFound(true));
}
}, [slug, language]);
if (!content) {
const zh = language === 'zh';
if (notFound) {
return (
<div className="max-w-3xl mx-auto py-20 px-6">
<div className="animate-pulse space-y-4">
<div className="h-4 bg-cream-200 rounded w-24" />
<div className="h-8 bg-cream-200 rounded w-3/4" />
<div className="h-4 bg-cream-200 rounded w-full" />
<div className="docs-detail">
<Link to="/docs" className="docs-back-link">
<svg viewBox="0 0 24 24"><path d="M15 18l-6-6 6-6" /></svg>
{zh ? '所有文档' : 'All docs'}
</Link>
<div style={{ textAlign: 'center', padding: '64px 0', color: 'var(--text-muted)' }}>
<p style={{ fontSize: '18px', marginBottom: '8px' }}>
{zh ? '文档未找到' : 'Doc not found'}
</p>
<Link to="/docs" style={{ color: 'var(--primary)', fontSize: '14px' }}>
{zh ? '返回文档中心 →' : 'Back to docs →'}
</Link>
</div>
</div>
);
}
if (!content) {
return (
<div className="docs-skeleton-wrap">
<div className="skeleton-line" style={{ width: '80px', height: '14px', marginBottom: '44px' }} />
<div className="skeleton-line" style={{ width: '60%', height: '44px', marginBottom: '16px' }} />
<div className="skeleton-line" style={{ width: '40%', height: '16px', marginBottom: '48px' }} />
<div className="skeleton-line" style={{ width: '100%', height: '16px' }} />
<div className="skeleton-line" style={{ width: '92%', height: '16px' }} />
<div className="skeleton-line" style={{ width: '96%', height: '16px' }} />
<div className="skeleton-line" style={{ width: '80%', height: '16px' }} />
</div>
);
}
const readTime = estimateReadTime(content.html);
return (
<>
<SEOHead
@@ -37,29 +80,54 @@ export default function DocDetailPage() {
description={content.meta.description}
path={`/docs/${slug}`}
/>
<div className="max-w-3xl mx-auto py-16 lg:py-20 px-6">
{/* Back link */}
<Link
to="/docs"
className="inline-flex items-center gap-1.5 text-sm text-ink-muted hover:text-ink transition-colors mb-8"
>
<ArrowLeft size={14} />
{language === 'en' ? 'All docs' : '所有文档'}
<div className="docs-detail">
{/* Back */}
<Link to="/docs" className="docs-back-link">
<svg viewBox="0 0 24 24"><path d="M15 18l-6-6 6-6" /></svg>
{zh ? '所有文档' : 'All docs'}
</Link>
{/* Doc body */}
<article
className="prose prose-lg prose-warm max-w-none
prose-headings:font-display prose-headings:tracking-tight
prose-h1:text-3xl prose-h1:font-bold
prose-h2:text-2xl prose-h2:font-semibold prose-h2:mt-10
prose-a:text-sage-700 prose-a:no-underline hover:prose-a:underline
prose-code:text-sage-700 prose-code:bg-sage-50 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-md prose-code:text-sm
prose-pre:bg-ink prose-pre:text-cream-100 prose-pre:rounded-xl
prose-img:rounded-xl
prose-blockquote:border-sage-300 prose-blockquote:bg-sage-50/30 prose-blockquote:rounded-r-xl prose-blockquote:py-1"
{/* Article header */}
<div className="docs-article-header">
{content.meta.tags.length > 0 && (
<div className="docs-article-tags">
{content.meta.tags.map(tag => (
<span key={tag} className="docs-tag">{tag}</span>
))}
</div>
)}
<h1 className="docs-article-h1">{content.meta.title}</h1>
<div className="docs-meta-row">
<span>{formatDate(content.meta.date, language)}</span>
<span className="docs-meta-sep">·</span>
<span>{readTime} {zh ? '分钟阅读' : 'min read'}</span>
</div>
</div>
{/* Article body */}
<div
className="docs-prose"
dangerouslySetInnerHTML={{ __html: content.html }}
/>
{/* CTA */}
<div className="docs-cta-box">
<div className="docs-cta-title">
{zh ? '准备好试试了吗?' : 'Ready to try it yourself?'}
</div>
<p className="docs-cta-desc">
{zh
? '上传一张公式图片,秒级获得 LaTeX 输出——无需注册。'
: 'Upload a formula image and get LaTeX output in under a second — no sign-up needed.'}
</p>
<Link to="/app" className="btn btn-primary" style={{ display: 'inline-flex' }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
<path d="M5 3l14 9-14 9V3z" />
</svg>
{zh ? '免费试用 TexPixel' : 'Try TexPixel Free'}
</Link>
</div>
</div>
</>
);

View File

@@ -1,10 +1,69 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { ArrowRight, BookOpen, FileText } from 'lucide-react';
import SEOHead from '../components/seo/SEOHead';
import { useLanguage } from '../contexts/LanguageContext';
import { loadManifest, type ContentMeta } from '../lib/content';
function estimateReadTime(desc: string): number {
return Math.max(2, Math.round(desc.split(/\s+/).length / 3));
}
const DOC_ICONS: Record<string, JSX.Element> = {
'getting-started': (
<>
<path d="M5 3l14 9-14 9V3z" />
</>
),
'image-to-latex': (
<>
<rect x="3" y="3" width="18" height="14" rx="2" />
<path d="M3 9h18" />
<path d="M9 21l3-3 3 3" />
</>
),
'supported-formats': (
<>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<path d="M8 13h8M8 17h5" />
</>
),
'copy-to-word': (
<>
<rect x="4" y="4" width="16" height="16" rx="2" />
<path d="M8 9h8M8 12h6M8 15h4" />
</>
),
'ocr-accuracy': (
<>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</>
),
'pdf-extraction': (
<>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6M10 12l2 2 4-4" />
</>
),
faq: (
<>
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</>
),
};
function DocIcon({ slug }: { slug: string }) {
const paths = DOC_ICONS[slug] ?? DOC_ICONS['supported-formats'];
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
{paths}
</svg>
);
}
export default function DocsListPage() {
const { language } = useLanguage();
const [docs, setDocs] = useState<ContentMeta[]>([]);
@@ -15,57 +74,63 @@ export default function DocsListPage() {
});
}, [language]);
const zh = language === 'zh';
return (
<>
<SEOHead title="Documentation" description="TexPixel documentation and guides" path="/docs" />
<SEOHead
title={zh ? 'TexPixel 文档中心' : 'TexPixel Documentation'}
description={zh ? '公式识别入门指南、格式说明与常见问题。' : 'Guides, format references, and answers for getting the most out of TexPixel.'}
path="/docs"
/>
<div className="max-w-5xl mx-auto py-16 lg:py-20 px-6">
<div className="docs-page">
{/* Header */}
<div className="mb-12">
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-sage-50 border border-sage-100 rounded-full text-xs font-medium text-sage-700 mb-6">
<BookOpen size={13} />
{language === 'en' ? 'Documentation' : '文档中心'}
</div>
<h1 className="font-display text-4xl lg:text-5xl font-bold text-ink tracking-tight mb-4">
{language === 'en' ? 'Learn TexPixel' : '了解 TexPixel'}
<div className="docs-page-header">
<div className="eyebrow">{zh ? '文档中心' : 'Documentation'}</div>
<h1 className="docs-page-title">
{zh ? '学习 TexPixel' : 'Learn TexPixel'}
</h1>
<p className="text-ink-secondary text-lg max-w-xl">
{language === 'en'
? 'Everything you need to get started with formula recognition.'
: '公式识别入门所需的一切。'}
<p className="docs-page-subtitle">
{zh
? '公式识别入门所需的一切——上传技巧、格式说明与常见问题。'
: 'Everything you need to get the most out of formula recognition — upload tips, format guides, and FAQs.'}
</p>
</div>
{/* Docs grid */}
<div className="space-y-4">
{/* List */}
<div className="docs-list">
{docs.map((doc) => (
<Link
key={doc.slug}
to={`/docs/${doc.slug}`}
className="card p-6 flex items-start gap-5 group hover:border-sage-200 block"
>
<div className="w-11 h-11 bg-sage-50 rounded-xl flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform">
<FileText size={20} className="text-sage-600" />
<Link key={doc.slug} to={`/docs/${doc.slug}`} className="docs-article-card">
<div className="docs-article-card-inner">
<div className="docs-article-icon">
<DocIcon slug={doc.slug} />
</div>
<div className="docs-article-body">
<div className="docs-article-title">{doc.title}</div>
<div className="docs-article-desc">{doc.description}</div>
<div className="docs-article-meta">
{doc.tags.slice(0, 2).map(tag => (
<span key={tag} className="docs-tag">{tag}</span>
))}
<span className="docs-read-time">
{estimateReadTime(doc.description)} {zh ? '分钟阅读' : 'min read'}
</span>
</div>
</div>
<svg className="docs-article-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 18l6-6-6-6" />
</svg>
</div>
<div className="flex-1 min-w-0">
<h2 className="font-display text-lg font-semibold text-ink mb-1 group-hover:text-sage-700 transition-colors">
{doc.title}
</h2>
<p className="text-ink-secondary text-sm leading-relaxed">{doc.description}</p>
</div>
<ArrowRight size={16} className="text-ink-muted group-hover:text-sage-600 transition-colors mt-1 flex-shrink-0" />
</Link>
))}
</div>
{/* Empty state */}
{docs.length === 0 && (
<div className="card p-12 text-center">
<p className="text-ink-muted text-sm">
{language === 'en' ? 'Documentation coming soon.' : '文档即将发布。'}
</p>
</div>
)}
{docs.length === 0 && (
<div className="docs-empty">
{zh ? '文档即将发布。' : 'Documentation coming soon.'}
</div>
)}
</div>
</div>
</>
);

View File

@@ -1571,3 +1571,402 @@
.reveal-delay-3 { transition-delay: 0.30s; }
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0} }
/* ═══════════════════════════════════════════════════════
DOCS PAGES
═══════════════════════════════════════════════════════ */
/* ── Docs list page ── */
.docs-page {
max-width: 820px;
margin: 0 auto;
padding: 64px 24px 96px;
}
.docs-page-header {
margin-bottom: 52px;
}
.docs-page-header .eyebrow {
margin-bottom: 14px;
}
.docs-page-title {
font-family: 'Lora', serif;
font-size: 44px;
font-weight: 700;
line-height: 1.1;
letter-spacing: -0.02em;
color: var(--text-strong);
margin-bottom: 14px;
}
.docs-page-subtitle {
font-size: 17px;
color: var(--text-body);
line-height: 1.65;
max-width: 480px;
}
.docs-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.docs-article-card {
background: var(--elevated);
border: 1.5px solid var(--border);
border-radius: var(--r-l);
padding: 28px 32px;
text-decoration: none;
display: block;
transition: all 0.2s ease;
box-shadow: var(--shadow-soft);
}
.docs-article-card:hover {
transform: translateY(-2px);
border-color: var(--primary-light);
box-shadow: var(--shadow-float);
}
.docs-article-card-inner {
display: flex;
align-items: flex-start;
gap: 20px;
}
.docs-article-icon {
width: 46px;
height: 46px;
background: var(--warm-wash);
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.docs-article-icon svg {
width: 22px;
height: 22px;
stroke: var(--primary);
fill: none;
stroke-width: 1.8;
}
.docs-article-body {
flex: 1;
min-width: 0;
}
.docs-article-title {
font-family: 'Lora', serif;
font-size: 19px;
font-weight: 700;
color: var(--text-strong);
margin-bottom: 6px;
letter-spacing: -0.01em;
transition: color 0.15s;
}
.docs-article-card:hover .docs-article-title {
color: var(--primary);
}
.docs-article-desc {
font-size: 14px;
color: var(--text-body);
line-height: 1.6;
margin-bottom: 12px;
}
.docs-article-meta {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.docs-tag {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 10px;
border-radius: 6px;
background: var(--warm-wash);
color: var(--primary);
font-size: 11px;
font-weight: 600;
text-transform: lowercase;
}
.docs-read-time {
font-size: 12px;
color: var(--text-muted);
}
.docs-article-arrow {
color: var(--text-muted);
flex-shrink: 0;
margin-top: 2px;
transition: color 0.15s, transform 0.15s;
}
.docs-article-card:hover .docs-article-arrow {
color: var(--primary);
transform: translateX(3px);
}
.docs-empty {
background: var(--elevated);
border: 1.5px solid var(--border);
border-radius: var(--r-l);
padding: 48px;
text-align: center;
color: var(--text-muted);
font-size: 15px;
}
/* ── Docs detail page ── */
.docs-detail {
max-width: 760px;
margin: 0 auto;
padding: 48px 24px 96px;
}
.docs-back-link {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: var(--text-muted);
text-decoration: none;
margin-bottom: 44px;
transition: color 0.15s;
font-weight: 500;
}
.docs-back-link:hover { color: var(--primary); }
.docs-back-link svg {
width: 15px;
height: 15px;
stroke: currentColor;
fill: none;
stroke-width: 2;
}
.docs-article-header {
margin-bottom: 44px;
padding-bottom: 36px;
border-bottom: 1px solid var(--border);
}
.docs-article-tags {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.docs-article-h1 {
font-family: 'Lora', serif;
font-size: 40px;
font-weight: 700;
line-height: 1.15;
letter-spacing: -0.02em;
color: var(--text-strong);
margin-bottom: 18px;
}
.docs-meta-row {
display: flex;
align-items: center;
gap: 16px;
font-size: 13px;
color: var(--text-muted);
flex-wrap: wrap;
}
.docs-meta-sep { opacity: 0.4; }
/* ── Docs prose body ── */
.docs-prose {
font-family: 'DM Sans', sans-serif;
font-size: 16px;
line-height: 1.8;
color: var(--text-body);
}
.docs-prose > h1:first-child { display: none; }
.docs-prose h2 {
font-family: 'Lora', serif;
font-size: 26px;
font-weight: 700;
color: var(--text-strong);
margin: 52px 0 16px;
letter-spacing: -0.01em;
padding-bottom: 10px;
border-bottom: 1px solid var(--border);
}
.docs-prose h3 {
font-family: 'Lora', serif;
font-size: 20px;
font-weight: 600;
color: var(--text-strong);
margin: 32px 0 10px;
}
.docs-prose h4 {
font-size: 16px;
font-weight: 700;
color: var(--text-strong);
margin: 24px 0 8px;
}
.docs-prose p { margin-bottom: 20px; }
.docs-prose ul,
.docs-prose ol {
padding-left: 24px;
margin-bottom: 20px;
}
.docs-prose li { margin-bottom: 8px; }
.docs-prose strong {
color: var(--text-strong);
font-weight: 600;
}
.docs-prose a {
color: var(--primary);
text-decoration: underline;
text-decoration-color: var(--primary-light);
text-underline-offset: 3px;
}
.docs-prose a:hover { text-decoration-color: var(--primary); }
.docs-prose code {
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
background: var(--warm-wash);
color: var(--primary);
padding: 2px 7px;
border-radius: 6px;
word-break: break-all;
}
.docs-prose pre {
background: var(--text-strong);
color: #f1e6d8;
border-radius: var(--r-m);
padding: 20px 24px;
overflow-x: auto;
margin: 28px 0;
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
line-height: 1.7;
}
.docs-prose pre code {
background: none;
color: inherit;
padding: 0;
font-size: inherit;
word-break: normal;
}
.docs-prose blockquote {
border-left: 3px solid var(--primary-light);
background: var(--warm-wash);
padding: 16px 20px;
border-radius: 0 var(--r-s) var(--r-s) 0;
margin: 28px 0;
color: var(--text-body);
font-style: italic;
}
.docs-prose table {
width: 100%;
border-collapse: collapse;
margin: 28px 0;
font-size: 14px;
}
.docs-prose th {
background: var(--bg);
color: var(--text-strong);
font-weight: 700;
padding: 10px 16px;
text-align: left;
border: 1px solid var(--border);
font-size: 13px;
}
.docs-prose td {
padding: 10px 16px;
border: 1px solid var(--border);
color: var(--text-body);
vertical-align: top;
}
.docs-prose tr:nth-child(even) td { background: var(--bg); }
.docs-prose .katex-display {
margin: 32px 0;
overflow-x: auto;
padding: 20px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--r-m);
}
/* ── Docs CTA box ── */
.docs-cta-box {
margin-top: 64px;
padding: 44px 48px;
background: linear-gradient(135deg, var(--warm-wash) 0%, var(--bg) 100%);
border: 1.5px solid var(--border);
border-radius: var(--r-xl);
text-align: center;
}
.docs-cta-title {
font-family: 'Lora', serif;
font-size: 26px;
font-weight: 700;
color: var(--text-strong);
margin-bottom: 10px;
}
.docs-cta-desc {
font-size: 15px;
color: var(--text-body);
margin-bottom: 28px;
line-height: 1.65;
}
/* ── Skeleton loader ── */
.docs-skeleton-wrap {
max-width: 760px;
margin: 0 auto;
padding: 48px 24px;
}
.skeleton-line {
background: var(--border);
border-radius: 6px;
margin-bottom: 14px;
animation: skeleton-pulse 1.6s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 0.7; }
50% { opacity: 0.35; }
}