feat: refactor blog pages to match landing CSS aesthetic

- BlogListPage: removed Tailwind/lucide-react, added featured post card,
  2-col grid for remaining posts, formatDate helper, eyebrow + Lora titles
- BlogDetailPage: matches DocDetailPage (skeleton loader, not-found state,
  tags + Lora h1 + date/read-time meta, docs-prose body, CTA box)
- Added blog-specific CSS to landing.css (.blog-page, .blog-featured,
  .blog-grid, .blog-card); reuses .docs-back-link, .docs-prose, .docs-cta-box

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 16:40:06 +08:00
parent 409bbf742e
commit 012748fc3d
3 changed files with 347 additions and 105 deletions

View File

@@ -1,36 +1,78 @@
import { useEffect, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { ArrowLeft, Calendar } 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 BlogDetailPage() {
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('blog', 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="h-4 bg-cream-200 rounded w-5/6" />
<div className="docs-detail">
<Link to="/blog" className="docs-back-link">
<svg viewBox="0 0 24 24"><path d="M15 18l-6-6 6-6" /></svg>
{zh ? '所有文章' : 'All posts'}
</Link>
<div style={{ textAlign: 'center', padding: '64px 0', color: 'var(--text-muted)' }}>
<p style={{ fontSize: '18px', marginBottom: '8px' }}>
{zh ? '文章未找到' : 'Post not found'}
</p>
<Link to="/blog" style={{ color: 'var(--primary)', fontSize: '14px' }}>
{zh ? '返回博客 →' : 'Back to blog →'}
</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
@@ -40,46 +82,54 @@ export default function BlogDetailPage() {
type="article"
publishedTime={content.meta.date}
/>
<div className="max-w-3xl mx-auto py-16 lg:py-20 px-6">
{/* Back link */}
<Link
to="/blog"
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 posts' : '所有文章'}
<div className="docs-detail">
{/* Back */}
<Link to="/blog" className="docs-back-link">
<svg viewBox="0 0 24 24"><path d="M15 18l-6-6 6-6" /></svg>
{zh ? '所有文章' : 'All posts'}
</Link>
{/* Article header */}
<header className="mb-10">
<div className="flex items-center gap-2 text-sm text-ink-muted mb-4">
<Calendar size={14} />
<time>{content.meta.date}</time>
</div>
{content.meta.tags && content.meta.tags.length > 0 && (
<div className="flex gap-2 mb-4">
{content.meta.tags.map((tag: string) => (
<span key={tag} className="text-xs bg-coral-50 text-coral-600 px-2.5 py-1 rounded-full font-medium">
{tag}
</span>
<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>
)}
</header>
<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 */}
<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-coral-600 prose-a:no-underline hover:prose-a:underline
prose-code:text-coral-600 prose-code:bg-coral-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-coral-300 prose-blockquote:bg-coral-50/30 prose-blockquote:rounded-r-xl prose-blockquote:py-1"
<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,19 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { ArrowRight, Calendar } from 'lucide-react';
import SEOHead from '../components/seo/SEOHead';
import { useLanguage } from '../contexts/LanguageContext';
import { loadManifest, type ContentMeta } from '../lib/content';
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 BlogListPage() {
const { language } = useLanguage();
const [posts, setPosts] = useState<ContentMeta[]>([]);
@@ -15,80 +24,71 @@ export default function BlogListPage() {
});
}, [language]);
const zh = language === 'zh';
const featured = posts[0];
const rest = posts.slice(1);
return (
<>
<SEOHead title="Blog" description="TexPixel blog — updates, tutorials, and insights" path="/blog" />
<SEOHead
title={zh ? 'TexPixel 博客' : 'TexPixel Blog'}
description={zh ? '关于公式识别和 LaTeX 的更新、教程与见解。' : 'Updates, tutorials, and insights on formula recognition and LaTeX.'}
path="/blog"
/>
<div className="max-w-5xl mx-auto py-16 lg:py-20 px-6">
<div className="blog-page">
{/* Header */}
<div className="mb-12">
<h1 className="font-display text-4xl lg:text-5xl font-bold text-ink tracking-tight mb-4">
{language === 'en' ? 'Blog' : '博客'}
<div className="blog-page-header reveal">
<div className="eyebrow">{zh ? '博客' : 'Blog'}</div>
<h1 className="blog-page-title">
{zh ? '最新文章' : 'Latest Posts'}
</h1>
<p className="text-ink-secondary text-lg max-w-xl">
{language === 'en'
? 'Updates, tutorials, and insights on formula recognition and LaTeX.'
: '关于公式识别和 LaTeX 的更新、教程和见解。'}
<p className="blog-page-subtitle">
{zh
? '关于公式识别和 LaTeX 的更新、教程与见解。'
: 'Updates, tutorials, and insights on formula recognition and LaTeX.'}
</p>
</div>
{/* Featured post */}
{featured && (
<Link
to={`/blog/${featured.slug}`}
className="card block p-8 mb-8 group hover:border-coral-200"
>
<div className="flex items-center gap-2 text-xs text-ink-muted mb-3">
<Calendar size={13} />
<time>{featured.date}</time>
<Link to={`/blog/${featured.slug}`} className="blog-featured reveal">
<div className="blog-featured-eyebrow">
{zh ? '精选文章' : 'Featured'}
</div>
<h2 className="font-display text-2xl lg:text-3xl font-bold text-ink mb-3 group-hover:text-coral-600 transition-colors">
{featured.title}
</h2>
<p className="text-ink-secondary leading-relaxed mb-4 max-w-2xl">
{featured.description}
</p>
<div className="flex items-center justify-between">
<div className="flex gap-2">
{featured.tags.map(tag => (
<span key={tag} className="text-xs bg-coral-50 text-coral-600 px-2.5 py-1 rounded-full font-medium">
{tag}
</span>
<div className="blog-featured-title">{featured.title}</div>
<div className="blog-featured-desc">{featured.description}</div>
<div className="blog-featured-footer">
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap' }}>
{featured.tags.slice(0, 3).map(tag => (
<span key={tag} className="docs-tag">{tag}</span>
))}
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
{formatDate(featured.date, language)}
</span>
</div>
<span className="inline-flex items-center gap-1 text-sm font-medium text-coral-500 group-hover:gap-2 transition-all">
{language === 'en' ? 'Read more' : '阅读全文'}
<ArrowRight size={14} />
<span className="blog-featured-read">
{zh ? '阅读全文' : 'Read more'}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</span>
</div>
</Link>
)}
{/* Rest of posts */}
{/* Post grid */}
{rest.length > 0 && (
<div className="grid md:grid-cols-2 gap-5">
{rest.map((post) => (
<Link
key={post.slug}
to={`/blog/${post.slug}`}
className="card p-6 group hover:border-coral-200"
>
<div className="text-xs text-ink-muted mb-2">
<time>{post.date}</time>
</div>
<h2 className="font-display text-lg font-semibold text-ink mb-2 group-hover:text-coral-600 transition-colors">
{post.title}
</h2>
<p className="text-ink-secondary text-sm leading-relaxed mb-3">{post.description}</p>
<div className="blog-grid">
{rest.map(post => (
<Link key={post.slug} to={`/blog/${post.slug}`} className="blog-card reveal">
<div className="blog-card-date">{formatDate(post.date, language)}</div>
<div className="blog-card-title">{post.title}</div>
<div className="blog-card-desc">{post.description}</div>
{post.tags.length > 0 && (
<div className="flex gap-2">
{post.tags.map(tag => (
<span key={tag} className="text-[11px] bg-cream-200 text-ink-secondary px-2 py-0.5 rounded-full">
{tag}
</span>
<div className="blog-card-footer">
{post.tags.slice(0, 2).map(tag => (
<span key={tag} className="docs-tag">{tag}</span>
))}
</div>
)}
@@ -99,10 +99,8 @@ export default function BlogListPage() {
{/* Empty state */}
{posts.length === 0 && (
<div className="card p-12 text-center">
<p className="text-ink-muted text-sm">
{language === 'en' ? 'No posts yet. Check back soon!' : '暂无文章,敬请期待!'}
</p>
<div className="blog-empty">
{zh ? '暂无文章,敬请期待。' : 'No posts yet. Check back soon.'}
</div>
)}
</div>

View File

@@ -28,6 +28,7 @@
}
.marketing-page {
position: relative;
background: var(--bg);
color: var(--text-strong);
font-family: 'DM Sans', sans-serif;
@@ -39,7 +40,7 @@
/* ── GRID BACKGROUND ── */
.marketing-page::before {
content: '';
position: fixed;
position: absolute;
inset: 0;
background-image:
linear-gradient(var(--border) 1px, transparent 1px),
@@ -51,27 +52,33 @@
}
/* ── GLOW BLOBS ── */
/* position: fixed keeps blobs in-viewport at all times.
will-change + translateZ promotes each blob to its own GPU compositor
layer — the browser composites them independently so main-thread scroll
repaints do NOT include these elements. */
.glow-blob {
position: fixed;
border-radius: 50%;
filter: blur(80px);
pointer-events: none;
z-index: 0;
will-change: transform;
transform: translateZ(0);
}
.glow-blob-1 {
width: 480px; height: 380px;
background: rgba(200,98,42,0.09);
top: -80px; right: 80px;
width: 520px; height: 400px;
background: radial-gradient(circle, rgba(200,98,42,0.13) 0%, transparent 70%);
top: -100px; right: 60px;
}
.glow-blob-2 {
width: 360px; height: 320px;
background: rgba(140,201,190,0.10);
bottom: 20%; left: -80px;
width: 400px; height: 360px;
background: radial-gradient(circle, rgba(140,201,190,0.13) 0%, transparent 70%);
bottom: 25%; left: -100px;
}
.glow-blob-3 {
width: 320px; height: 280px;
background: rgba(243,201,106,0.08);
top: 55%; right: -60px;
width: 360px; height: 300px;
background: radial-gradient(circle, rgba(243,201,106,0.11) 0%, transparent 70%);
top: 50%; right: -80px;
}
/* ── LAYOUT ── */
@@ -1970,3 +1977,190 @@
0%, 100% { opacity: 0.7; }
50% { opacity: 0.35; }
}
/* ════════════════════════════════════════
BLOG PAGES
════════════════════════════════════════ */
/* ── Blog list page ── */
.blog-page {
max-width: 900px;
margin: 0 auto;
padding: 64px 24px 80px;
}
.blog-page-header {
text-align: center;
margin-bottom: 48px;
}
.blog-page-title {
font-family: 'Lora', serif;
font-size: clamp(2rem, 4vw, 2.75rem);
font-weight: 700;
color: var(--text-strong);
line-height: 1.2;
margin-bottom: 12px;
}
.blog-page-subtitle {
font-size: 1.0625rem;
color: var(--text-muted);
max-width: 480px;
margin: 0 auto;
}
/* ── Featured post ── */
.blog-featured {
display: block;
background: var(--card);
border: 1.5px solid var(--border);
border-radius: var(--r-l);
padding: 36px 40px;
margin-bottom: 32px;
text-decoration: none;
position: relative;
overflow: hidden;
transition: box-shadow 0.18s, border-color 0.18s, transform 0.18s;
}
.blog-featured::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(200,98,42,0.04) 0%, transparent 60%);
pointer-events: none;
}
.blog-featured:hover {
border-color: var(--warm-wash);
box-shadow: var(--shadow-float);
transform: translateY(-2px);
}
.blog-featured-eyebrow {
display: inline-block;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--primary);
margin-bottom: 14px;
}
.blog-featured-title {
font-family: 'Lora', serif;
font-size: clamp(1.375rem, 2.5vw, 1.875rem);
font-weight: 700;
color: var(--text-strong);
line-height: 1.3;
margin-bottom: 12px;
transition: color 0.15s;
}
.blog-featured:hover .blog-featured-title {
color: var(--primary);
}
.blog-featured-desc {
font-size: 1rem;
color: var(--text-body);
line-height: 1.7;
margin-bottom: 20px;
max-width: 680px;
}
.blog-featured-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.blog-featured-read {
font-size: 0.875rem;
font-weight: 600;
color: var(--primary);
display: inline-flex;
align-items: center;
gap: 4px;
transition: gap 0.15s;
}
.blog-featured:hover .blog-featured-read {
gap: 8px;
}
/* ── Blog grid ── */
.blog-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
.blog-card {
display: block;
background: var(--card);
border: 1.5px solid var(--border);
border-radius: var(--r-m);
padding: 24px 28px;
text-decoration: none;
transition: box-shadow 0.18s, border-color 0.18s, transform 0.18s;
}
.blog-card:hover {
border-color: var(--warm-wash);
box-shadow: var(--shadow-soft);
transform: translateY(-2px);
}
.blog-card-date {
font-size: 0.75rem;
color: var(--text-muted);
margin-bottom: 10px;
}
.blog-card-title {
font-family: 'Lora', serif;
font-size: 1.0625rem;
font-weight: 600;
color: var(--text-strong);
line-height: 1.4;
margin-bottom: 8px;
transition: color 0.15s;
}
.blog-card:hover .blog-card-title {
color: var(--primary);
}
.blog-card-desc {
font-size: 0.875rem;
color: var(--text-body);
line-height: 1.65;
margin-bottom: 16px;
}
.blog-card-footer {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.blog-empty {
text-align: center;
padding: 64px 0;
color: var(--text-muted);
font-size: 0.9375rem;
}
@media (max-width: 640px) {
.blog-grid { grid-template-columns: 1fr; }
.blog-featured { padding: 24px; }
.blog-page { padding: 40px 16px 64px; }
}
/* ── Blog detail page (reuses .docs-detail, .docs-back-link,
.docs-prose, .docs-cta-box, .docs-skeleton-wrap) ── */