Improve layout with hero, cards, typography, TOC and reading progress

This commit is contained in:
2025-11-17 17:07:01 +08:00
parent 237bb083cd
commit 603274d025
8 changed files with 338 additions and 99 deletions

View File

@@ -1,8 +1,10 @@
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { allPosts } from 'contentlayer/generated';
import { getPostBySlug } from '@/lib/posts';
import { getPostBySlug } from '@/lib.posts';
import { siteConfig } from '@/lib/config';
import { ReadingProgress } from '@/components/reading-progress';
import { PostToc } from '@/components/post-toc';
export function generateStaticParams() {
return allPosts.map((post) => ({
@@ -32,7 +34,13 @@ export default function BlogPostPage({ params }: Props) {
if (!post) return notFound();
return (
<article className="prose dark:prose-invert max-w-none">
<>
<ReadingProgress />
<div className="mx-auto flex max-w-5xl gap-8 pt-4">
<aside className="hidden w-56 shrink-0 lg:block">
<PostToc />
</aside>
<article className="prose dark:prose-invert max-w-none flex-1">
<h1>{post.title}</h1>
{post.feature_image && (
// feature_image is stored as "../assets/xyz", serve from "/assets/xyz"
@@ -64,5 +72,7 @@ export default function BlogPostPage({ params }: Props) {
)}
<div dangerouslySetInnerHTML={{ __html: post.body.html }} />
</article>
</div>
</>
);
}

View File

@@ -1,6 +1,5 @@
import Link from 'next/link';
import { getAllPostsSorted } from '@/lib/posts';
import { siteConfig } from '@/lib/config';
import { PostCard } from '@/components/post-card';
export const metadata = {
title: 'Blog'
@@ -12,41 +11,11 @@ export default function BlogIndexPage() {
return (
<section className="space-y-6">
<h1 className="text-2xl font-bold">Blog</h1>
<ul className="space-y-3">
<div className="space-y-4">
{posts.map((post) => (
<li key={post._id}>
<Link
href={post.url}
className="text-lg font-medium hover:underline"
>
{post.title}
</Link>
<div className="text-xs text-gray-500">
{post.published_at &&
new Date(post.published_at).toLocaleDateString(
siteConfig.defaultLocale
)}
{post.tags && post.tags.length > 0 && (
<span className="ml-2">
{post.tags.map((t) => (
<span
key={t}
className="mr-1 rounded bg-gray-200 px-1 dark:bg-gray-800"
>
#{t}
</span>
<PostCard key={post._id} post={post} />
))}
</span>
)}
</div>
{post.description && (
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">
{post.description}
</p>
)}
</li>
))}
</ul>
</section>
);
}

View File

@@ -1,46 +1,32 @@
import Link from 'next/link';
import { getAllPostsSorted } from '@/lib/posts';
import { siteConfig } from '@/lib/config';
import { Hero } from '@/components/hero';
import { PostCard } from '@/components/post-card';
export default function HomePage() {
const posts = getAllPostsSorted().slice(0, siteConfig.postsPerPage);
return (
<section className="space-y-6">
<div>
<h1 className="text-3xl font-bold">
{siteConfig.name}
</h1>
<p className="mt-2 text-gray-600 dark:text-gray-300">
{siteConfig.tagline}
</p>
</div>
<Hero />
<div>
<div className="mb-3 flex items-baseline justify-between">
<h2 className="text-xl font-semibold"></h2>
<ul className="mt-3 space-y-2">
{posts.map((post) => (
<li key={post._id}>
<Link href={post.url} className="hover:underline">
{post.title}
</Link>
{post.published_at && (
<span className="ml-2 text-xs text-gray-500">
{new Date(post.published_at).toLocaleDateString(
siteConfig.defaultLocale
)}
</span>
)}
</li>
))}
</ul>
<Link
href="/blog"
className="mt-4 inline-block text-sm text-blue-600 hover:underline"
className="text-sm text-blue-600 hover:underline dark:text-blue-400"
>
</Link>
</div>
<div className="space-y-4">
{posts.map((post) => (
<PostCard key={post._id} post={post} />
))}
</div>
</div>
</section>
);
}

67
components/hero.tsx Normal file
View File

@@ -0,0 +1,67 @@
import { siteConfig } from '@/lib/config';
export function Hero() {
const { name, tagline, social } = siteConfig;
const initial = name?.charAt(0)?.toUpperCase() || 'G';
return (
<section className="mb-8 rounded-xl border bg-gradient-to-r from-slate-50 to-slate-100 px-6 py-6 shadow-sm dark:border-slate-800 dark:from-slate-900 dark:to-slate-950">
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-slate-900 text-2xl font-semibold text-slate-50 dark:bg-slate-100 dark:text-slate-900">
{initial}
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
{name}
</h1>
<p className="mt-1 max-w-2xl text-sm text-slate-600 dark:text-slate-300">
{tagline}
</p>
{(social.github || social.twitter || social.linkedin || social.email) && (
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-slate-500">
{social.github && (
<a
href={social.github}
target="_blank"
rel="noopener noreferrer"
className="rounded-full bg-white/70 px-3 py-1 shadow-sm ring-1 ring-slate-200 hover:bg-slate-50 dark:bg-slate-900/60 dark:ring-slate-700"
>
GitHub
</a>
)}
{social.twitter && (
<a
href={`https://twitter.com/${social.twitter.replace('@', '')}`}
target="_blank"
rel="noopener noreferrer"
className="rounded-full bg-white/70 px-3 py-1 shadow-sm ring-1 ring-slate-200 hover:bg-slate-50 dark:bg-slate-900/60 dark:ring-slate-700"
>
Twitter
</a>
)}
{social.linkedin && (
<a
href={social.linkedin}
target="_blank"
rel="noopener noreferrer"
className="rounded-full bg-white/70 px-3 py-1 shadow-sm ring-1 ring-slate-200 hover:bg-slate-50 dark:bg-slate-900/60 dark:ring-slate-700"
>
LinkedIn
</a>
)}
{social.email && (
<a
href={`mailto:${social.email}`}
className="rounded-full bg-white/70 px-3 py-1 shadow-sm ring-1 ring-slate-200 hover:bg-slate-50 dark:bg-slate-900/60 dark:ring-slate-700"
>
Email
</a>
)}
</div>
)}
</div>
</div>
</section>
);
}

64
components/post-card.tsx Normal file
View File

@@ -0,0 +1,64 @@
import Link from 'next/link';
import type { Post } from 'contentlayer/generated';
import { siteConfig } from '@/lib/config';
interface PostCardProps {
post: Post;
}
export function PostCard({ post }: PostCardProps) {
const cover =
post.feature_image && post.feature_image.startsWith('../assets')
? post.feature_image.replace('../assets', '/assets')
: undefined;
return (
<article className="group overflow-hidden rounded-xl border bg-white shadow-sm transition hover:-translate-y-0.5 hover:shadow-md dark:border-slate-800 dark:bg-slate-900">
{cover && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={cover}
alt={post.title}
className="h-44 w-full object-cover"
/>
)}
<div className="space-y-2 px-4 py-4">
<h2 className="text-lg font-semibold leading-snug">
<Link
href={post.url}
className="hover:text-blue-600 dark:hover:text-blue-400"
>
{post.title}
</Link>
</h2>
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-500">
{post.published_at && (
<span>
{new Date(post.published_at).toLocaleDateString(
siteConfig.defaultLocale
)}
</span>
)}
{post.tags && post.tags.length > 0 && (
<span className="flex flex-wrap gap-1">
{post.tags.slice(0, 3).map((t) => (
<span
key={t}
className="rounded bg-slate-100 px-1.5 py-0.5 text-[11px] dark:bg-slate-800"
>
#{t}
</span>
))}
</span>
)}
</div>
{post.description && (
<p className="line-clamp-3 text-sm text-slate-600 dark:text-slate-300">
{post.description}
</p>
)}
</div>
</article>
);
}

50
components/post-toc.tsx Normal file
View File

@@ -0,0 +1,50 @@
'use client';
import { useEffect, useState } from 'react';
interface TocItem {
id: string;
text: string;
depth: number;
}
export function PostToc() {
const [items, setItems] = useState<TocItem[]>([]);
useEffect(() => {
const headings = Array.from(
document.querySelectorAll<HTMLElement>('article h2, article h3')
);
const mapped = headings
.filter((el) => el.id)
.map((el) => ({
id: el.id,
text: el.innerText,
depth: el.tagName === 'H3' ? 3 : 2
}));
setItems(mapped);
}, []);
if (items.length === 0) return null;
return (
<nav className="sticky top-20 text-xs text-slate-500">
<div className="mb-2 font-semibold text-slate-700 dark:text-slate-200">
</div>
<ul className="space-y-1">
{items.map((item) => (
<li key={item.id} className={item.depth === 3 ? 'pl-3' : ''}>
<a
href={`#${item.id}`}
className="line-clamp-2 hover:text-blue-600 dark:hover:text-blue-400"
>
{item.text}
</a>
</li>
))}
</ul>
</nav>
);
}

View File

@@ -0,0 +1,43 @@
'use client';
import { useEffect, useState } from 'react';
export function ReadingProgress() {
const [mounted, setMounted] = useState(false);
const [progress, setProgress] = useState(0);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!mounted) return;
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
const total = scrollHeight - clientHeight;
if (total <= 0) {
setProgress(0);
return;
}
const value = Math.min(100, Math.max(0, (scrollTop / total) * 100));
setProgress(value);
};
handleScroll();
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, [mounted]);
if (!mounted) return null;
return (
<div className="pointer-events-none fixed inset-x-0 top-0 z-40 h-1 bg-slate-200/60 dark:bg-slate-900/80">
<div
className="h-full origin-left bg-blue-500 transition-transform dark:bg-blue-400"
style={{ transform: `scaleX(${progress / 100})` }}
/>
</div>
);
}

View File

@@ -4,11 +4,61 @@ module.exports = {
content: [
"./app/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"../Blog 文章原稿/**/*.{md,mdx}"
"./content/**/*.{md,mdx}"
],
theme: {
extend: {},
extend: {
typography: (theme) => ({
DEFAULT: {
css: {
color: theme('colors.slate.700'),
a: {
color: theme('colors.blue.600'),
'&:hover': {
color: theme('colors.blue.700')
}
},
h1: {
fontWeight: '700',
letterSpacing: '-0.03em'
},
h2: {
fontWeight: '600',
letterSpacing: '-0.02em'
},
blockquote: {
fontStyle: 'normal',
borderLeftColor: theme('colors.blue.200'),
color: theme('colors.slate.700'),
backgroundColor: theme('colors.slate.50')
},
code: {
backgroundColor: theme('colors.slate.100'),
padding: '0.15rem 0.35rem',
borderRadius: '0.25rem'
}
}
},
dark: {
css: {
color: theme('colors.slate.100'),
a: {
color: theme('colors.blue.400'),
'&:hover': {
color: theme('colors.blue.300')
}
},
blockquote: {
borderLeftColor: theme('colors.blue.500'),
backgroundColor: theme('colors.slate.900')
},
code: {
backgroundColor: theme('colors.slate.800')
}
}
}
})
},
},
plugins: [require('@tailwindcss/typography')],
};