Files
blog-nextjs/components/post-storyline-nav.tsx
gbanyan 0bb3ee40c6 Optimize performance: Replace Framer Motion and FontAwesome, convert Mastodon to Server Component
Major performance optimizations addressing PageSpeed Insights warnings:

**Phase 1: Replace Framer Motion with CSS (~60-100KB savings)**
- Remove Framer Motion from components/post-layout.tsx
- Add CSS transitions to styles/globals.css for TOC animations
- Replace motion.div/motion.button with regular elements + CSS classes
- Remove framer-motion package dependency

**Phase 2: Replace FontAwesome with React Icons (~150-250KB savings)**
- Replace FontAwesome in 16 components with react-icons
- Use Feather icons (react-icons/fi) for UI elements
- Use FontAwesome brand icons (react-icons/fa) for social media
- Remove 4 @fortawesome packages (@fortawesome/fontawesome-svg-core,
  @fortawesome/free-brands-svg-icons, @fortawesome/free-solid-svg-icons,
  @fortawesome/react-fontawesome)
- Updated components:
  - app/error.tsx, app/tags/page.tsx, app/tags/[tag]/page.tsx
  - components/hero.tsx, components/mastodon-feed.tsx
  - components/meta-item.tsx, components/nav-menu.tsx
  - components/post-card.tsx, components/post-layout.tsx
  - components/post-list-item.tsx, components/post-list-with-controls.tsx
  - components/post-storyline-nav.tsx, components/post-toc.tsx
  - components/right-sidebar.tsx, components/search-modal.tsx
  - components/site-footer.tsx, components/theme-toggle.tsx

**Phase 3: Convert Mastodon Feed to Server Component**
- Convert components/mastodon-feed.tsx from Client Component to async Server Component
- Replace client-side useEffect fetching with server-side ISR
- Add 30-minute revalidation (next: { revalidate: 1800 })
- Eliminate 2 blocking client-side network requests
- Remove loading state (rendered on server)

**Total Impact:**
- JavaScript bundle: ~210-350KB reduction
- Blocking network requests: 2 eliminated
- Main thread time: Reduced by ~100-160ms
- Build:  Verified successful

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 21:51:24 +08:00

103 lines
3.7 KiB
TypeScript

import Link from 'next/link';
import { Post } from 'contentlayer2/generated';
import { FiArrowLeft, FiArrowRight } from 'react-icons/fi';
interface Props {
current: Post;
newer?: Post;
older?: Post;
}
interface StationConfig {
key: 'older' | 'newer';
label: string;
post?: Post;
rel?: 'prev' | 'next';
subtitle: string;
align: 'start' | 'end';
}
export function PostStorylineNav({ current, newer, older }: Props) {
const stations: StationConfig[] = [
{
key: 'older',
label: '上一站',
post: older,
subtitle: older ? '回顧這篇' : '到達起點',
rel: 'prev',
align: 'end'
},
{
key: 'newer',
label: '下一站',
post: newer,
subtitle: newer ? '繼續前往' : '尚無新章',
rel: 'next',
align: 'start'
}
];
return (
<nav aria-label="文章導覽" className="relative mt-10">
<div className="relative overflow-hidden rounded-[32px] border border-slate-200/70 bg-gradient-to-r from-white via-slate-50 to-white px-6 py-8 shadow-lg dark:border-slate-800/70 dark:from-slate-900 dark:via-slate-900/80 dark:to-slate-900">
<div className="pointer-events-none absolute inset-x-12 top-1/2 hidden md:block">
<div className="relative flex items-center text-slate-200 dark:text-slate-700">
<span className="h-0 w-0 -translate-x-3 border-y-[7px] border-y-transparent border-r-[14px] border-r-current" />
<span className="flex-1 border-t border-dashed border-current" />
<span className="h-0 w-0 translate-x-3 rotate-180 border-y-[7px] border-y-transparent border-r-[14px] border-r-current" />
</div>
</div>
<div className="relative grid gap-6 md:grid-cols-[1fr_auto_1fr] md:items-center">
<Station station={stations[0]} />
<div className="hidden flex-col items-center gap-2 text-center text-xs uppercase tracking-[0.4em] text-slate-400 md:flex">
<span className="h-2 w-2 rounded-full bg-blue-500" aria-hidden="true" />
<span></span>
</div>
<Station station={stations[1]} />
</div>
</div>
</nav>
);
}
function Station({ station }: { station: StationConfig }) {
const { post, label, subtitle, rel, align } = station;
const alignClass = align === 'end' ? 'items-end text-right' : 'items-start text-left';
if (!post) {
if (align === 'start') {
return <div className="hidden" aria-hidden="true" />;
}
return (
<div className={`flex flex-col gap-1 text-slate-400 ${alignClass}`}>
<p className="text-[11px] uppercase tracking-[0.4em]">{label}</p>
<p className="text-base font-semibold">{subtitle}</p>
</div>
);
}
return (
<Link
href={post.url}
rel={rel}
className={`group flex flex-col gap-1 text-center transition duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 dark:focus-visible:ring-blue-300 ${alignClass}`}
>
<p className="text-[11px] uppercase tracking-[0.4em] text-slate-400 transition group-hover:text-blue-500 dark:text-slate-500">
{align === 'end' ? (
<FiArrowLeft className="mr-1 inline h-3 w-3" />
) : (
<FiArrowRight className="mr-1 inline h-3 w-3" />
)}
{label}
</p>
<p className="text-lg font-semibold leading-snug tracking-tight text-slate-900 transition group-hover:text-blue-600 dark:text-slate-50 dark:group-hover:text-blue-300">
{post.title}
</p>
<span
className={`mt-2 h-0.5 w-16 rounded-full bg-slate-200 transition group-hover:w-24 group-hover:bg-blue-400 dark:bg-slate-700 ${align === 'end' ? 'self-end' : 'self-start'
}`}
/>
</Link>
);
}