Add related posts section and blog search

This commit is contained in:
2025-11-18 16:43:52 +08:00
parent 0df0a85579
commit 4b3329d66f
4 changed files with 203 additions and 54 deletions

View File

@@ -54,6 +54,7 @@ Markdown content (posts & pages) lives in a separate repository and is consumed
- **Blog index** (`/blog`) - **Blog index** (`/blog`)
- Uses `PostListWithControls`: - Uses `PostListWithControls`:
- Keyword search filters posts by title, tags, and excerpt with instant feedback.
- Sort order: new→old or old→new. - Sort order: new→old or old→new.
- Pagination using `siteConfig.postsPerPage`. - Pagination using `siteConfig.postsPerPage`.
@@ -62,6 +63,7 @@ Markdown content (posts & pages) lives in a separate repository and is consumed
- Top: published date, large title, colored tags. - Top: published date, large title, colored tags.
- Body: `prose` typography with tuned light/dark colors, images, blockquotes, code. - Body: `prose` typography with tuned light/dark colors, images, blockquotes, code.
- Top bar: reading progress indicator. - Top bar: reading progress indicator.
- Bottom: "相關文章" cards suggesting up to three related posts that share overlapping tags.
- **Right sidebar** (on large screens) - **Right sidebar** (on large screens)
- Top hero: - Top hero:
@@ -77,6 +79,29 @@ Markdown content (posts & pages) lives in a separate repository and is consumed
- **Misc** - **Misc**
- Floating "back to top" button on long pages. - Floating "back to top" button on long pages.
## Motion & Interaction Guidelines
- Keep motion subtle and purposeful:
- Use small translations (±24px) and short durations (200400ms, `ease-out`).
- Prefer fade/slide-in over large bounces or rotations.
- Respect user preferences:
- Animations that run on their own are wrapped with `motion-safe:` so they are disabled when `prefers-reduced-motion` is enabled.
- Reading experience first:
- Scroll-based reveals are used sparingly (e.g. post header and article body), not on every small element.
- TOC and reading progress bar emphasize orientation, not decoration.
- Hover & focus:
- Use light elevation (shadow + tiny translateY) and accent color changes to indicate interactivity.
- Focus states remain visible and are not replaced by motion-only cues.
### Implemented Visual Touches
- Reading progress bar with a soft gradient glow at the top of post pages.
- Scroll reveal for post header + article body (`ScrollReveal` component).
- Hover elevation + gradient accents for post cards, list items, sidebar author card, and tag chips.
- Smooth theme toggle with icon rotation and global `transition-colors` on the page background.
- TOC smooth scrolling + short-lived highlight on the target heading.
- Subtle hover elevation for `blockquote` and `pre` blocks inside `.prose` content.
## Prerequisites ## Prerequisites
- Node.js **18+** - Node.js **18+**

View File

@@ -2,10 +2,12 @@ import Link from 'next/link';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { allPosts } from 'contentlayer/generated'; import { allPosts } from 'contentlayer/generated';
import { getPostBySlug } from '@/lib/posts'; import { getPostBySlug, getRelatedPosts } from '@/lib/posts';
import { siteConfig } from '@/lib/config'; import { siteConfig } from '@/lib/config';
import { ReadingProgress } from '@/components/reading-progress'; import { ReadingProgress } from '@/components/reading-progress';
import { PostToc } from '@/components/post-toc'; import { PostToc } from '@/components/post-toc';
import { ScrollReveal } from '@/components/scroll-reveal';
import { PostCard } from '@/components/post-card';
export function generateStaticParams() { export function generateStaticParams() {
return allPosts.map((post) => ({ return allPosts.map((post) => ({
@@ -34,6 +36,8 @@ export default function BlogPostPage({ params }: Props) {
if (!post) return notFound(); if (!post) return notFound();
const relatedPosts = getRelatedPosts(post, 3);
return ( return (
<> <>
<ReadingProgress /> <ReadingProgress />
@@ -41,46 +45,71 @@ export default function BlogPostPage({ params }: Props) {
<aside className="hidden shrink-0 lg:block lg:w-44"> <aside className="hidden shrink-0 lg:block lg:w-44">
<PostToc /> <PostToc />
</aside> </aside>
<div className="flex-1"> <div className="flex-1 space-y-6">
<header className="mb-6 space-y-2"> <ScrollReveal>
{post.published_at && ( <header className="mb-2 space-y-2">
<p className="text-xs text-slate-500 dark:text-slate-500"> {post.published_at && (
{new Date(post.published_at).toLocaleDateString( <p className="text-xs text-slate-500 dark:text-slate-500">
siteConfig.defaultLocale {new Date(post.published_at).toLocaleDateString(
)} siteConfig.defaultLocale
</p> )}
)} </p>
<h1 className="text-2xl font-bold leading-tight text-slate-900 sm:text-3xl dark:text-slate-50"> )}
{post.title} <h1 className="text-2xl font-bold leading-tight text-slate-900 sm:text-3xl dark:text-slate-50">
</h1> {post.title}
{post.tags && ( </h1>
<div className="flex flex-wrap gap-2 pt-1"> {post.tags && (
{post.tags.map((t) => ( <div className="flex flex-wrap gap-2 pt-1">
<Link {post.tags.map((t) => (
key={t} <Link
href={`/tags/${encodeURIComponent( key={t}
t.toLowerCase().replace(/\s+/g, '-') href={`/tags/${encodeURIComponent(
)}`} t.toLowerCase().replace(/\s+/g, '-')
className="rounded-full bg-accent-soft px-2 py-0.5 text-xs text-accent-textLight transition hover:bg-accent hover:text-white dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700" )}`}
> className="rounded-full bg-accent-soft px-2 py-0.5 text-xs text-accent-textLight transition hover:bg-accent hover:text-white dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700"
#{t} >
</Link> #{t}
))} </Link>
</div> ))}
)} </div>
</header> )}
<article className="prose prose-lg prose-slate max-w-none dark:prose-dark"> </header>
{post.feature_image && ( </ScrollReveal>
// feature_image is stored as "../assets/xyz", serve from "/assets/xyz"
// eslint-disable-next-line @next/next/no-img-element <ScrollReveal>
<img <article className="prose prose-lg prose-slate max-w-none dark:prose-dark">
src={post.feature_image.replace('../assets', '/assets')} {post.feature_image && (
alt={post.title} // feature_image is stored as "../assets/xyz", serve from "/assets/xyz"
className="my-4 rounded" // eslint-disable-next-line @next/next/no-img-element
/> <img
)} src={post.feature_image.replace('../assets', '/assets')}
<div dangerouslySetInnerHTML={{ __html: post.body.html }} /> alt={post.title}
</article> className="my-4 rounded"
/>
)}
<div dangerouslySetInnerHTML={{ __html: post.body.html }} />
</article>
</ScrollReveal>
{relatedPosts.length > 0 && (
<ScrollReveal>
<section className="space-y-4 rounded-xl border border-slate-200 bg-white/80 p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900/50">
<div className="flex items-center justify-between gap-2">
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-50">
</h2>
<p className="text-xs text-slate-500 dark:text-slate-400">
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{relatedPosts.map((related) => (
<PostCard key={related._id} post={related} />
))}
</div>
</section>
</ScrollReveal>
)}
</div> </div>
</div> </div>
</> </>

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import type { Post } from 'contentlayer/generated'; import type { Post } from 'contentlayer/generated';
import { siteConfig } from '@/lib/config'; import { siteConfig } from '@/lib/config';
import { PostListItem } from './post-list-item'; import { PostListItem } from './post-list-item';
@@ -15,11 +15,31 @@ type SortOrder = 'new' | 'old';
export function PostListWithControls({ posts, pageSize }: Props) { export function PostListWithControls({ posts, pageSize }: Props) {
const [sortOrder, setSortOrder] = useState<SortOrder>('new'); const [sortOrder, setSortOrder] = useState<SortOrder>('new');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [searchTerm, setSearchTerm] = useState('');
const size = pageSize ?? siteConfig.postsPerPage ?? 5; const size = pageSize ?? siteConfig.postsPerPage ?? 5;
const normalizedQuery = searchTerm.trim().toLowerCase();
const filteredPosts = useMemo(() => {
if (!normalizedQuery) return posts;
return posts.filter((post) => {
const haystack = [
post.title,
post.description,
post.custom_excerpt,
post.tags?.join(' ')
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return haystack.includes(normalizedQuery);
});
}, [posts, normalizedQuery]);
const sortedPosts = useMemo(() => { const sortedPosts = useMemo(() => {
const arr = [...posts]; const arr = [...filteredPosts];
arr.sort((a, b) => { arr.sort((a, b) => {
const aDate = a.published_at const aDate = a.published_at
? new Date(a.published_at).getTime() ? new Date(a.published_at).getTime()
@@ -30,13 +50,17 @@ export function PostListWithControls({ posts, pageSize }: Props) {
return sortOrder === 'new' ? bDate - aDate : aDate - bDate; return sortOrder === 'new' ? bDate - aDate : aDate - bDate;
}); });
return arr; return arr;
}, [posts, sortOrder]); }, [filteredPosts, sortOrder]);
const totalPages = Math.max(1, Math.ceil(sortedPosts.length / size)); const totalPages = Math.max(1, Math.ceil(sortedPosts.length / size));
const currentPage = Math.min(page, totalPages); const currentPage = Math.min(page, totalPages);
const start = (currentPage - 1) * size; const start = (currentPage - 1) * size;
const currentPosts = sortedPosts.slice(start, start + size); const currentPosts = sortedPosts.slice(start, start + size);
useEffect(() => {
setPage(1);
}, [normalizedQuery]);
const handleChangeSort = (order: SortOrder) => { const handleChangeSort = (order: SortOrder) => {
setSortOrder(order); setSortOrder(order);
setPage(1); setPage(1);
@@ -49,7 +73,7 @@ export function PostListWithControls({ posts, pageSize }: Props) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between gap-4 text-xs text-slate-500 dark:text-slate-400"> <div className="flex flex-col gap-4 text-xs text-slate-500 dark:text-slate-400 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span></span> <span></span>
<button <button
@@ -75,18 +99,50 @@ export function PostListWithControls({ posts, pageSize }: Props) {
</button> </button>
</div> </div>
<div> <div className="flex w-full items-center gap-2 text-sm sm:w-auto">
{currentPage} / {totalPages} <label htmlFor="post-search" className="text-xs">
</label>
<input
id="post-search"
type="search"
placeholder="輸入標題或標籤"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
className="w-full rounded-full border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-sm transition focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:focus:ring-blue-500 sm:w-56"
/>
</div> </div>
</div> </div>
<ul className="space-y-3"> <div className="flex items-center justify-between text-xs text-slate-500 dark:text-slate-400">
{currentPosts.map((post) => ( <p>
<PostListItem key={post._id} post={post} /> {currentPage} / {totalPages} · {sortedPosts.length}
))} {normalizedQuery && `(搜尋「${searchTerm}」)`}
</ul> </p>
{normalizedQuery && sortedPosts.length === 0 && (
<button
type="button"
onClick={() => setSearchTerm('')}
className="text-blue-600 underline-offset-2 hover:underline dark:text-blue-400"
>
</button>
)}
</div>
{totalPages > 1 && ( {currentPosts.length === 0 ? (
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
</div>
) : (
<ul className="space-y-3">
{currentPosts.map((post) => (
<PostListItem key={post._id} post={post} />
))}
</ul>
)}
{totalPages > 1 && currentPosts.length > 0 && (
<nav className="flex items-center justify-center gap-3 text-xs text-slate-600 dark:text-slate-300"> <nav className="flex items-center justify-center gap-3 text-xs text-slate-600 dark:text-slate-300">
<button <button
type="button" type="button"
@@ -129,4 +185,3 @@ export function PostListWithControls({ posts, pageSize }: Props) {
</div> </div>
); );
} }

View File

@@ -47,3 +47,43 @@ export function getAllTagsWithCount(): { tag: string; slug: string; count: numbe
return b.count - a.count; return b.count - a.count;
}); });
} }
export function getRelatedPosts(target: Post, limit = 3): Post[] {
const targetTags = new Set(target.tags?.map((tag) => tag.toLowerCase()) ?? []);
const candidates = getAllPostsSorted().filter((post) => post._id !== target._id);
if (candidates.length === 0) return [];
const scored = candidates
.map((post) => {
const sharedTags = (post.tags ?? []).reduce((acc, tag) => {
return acc + (targetTags.has(tag.toLowerCase()) ? 1 : 0);
}, 0);
return { post, score: sharedTags };
})
.filter((entry) => entry.score > 0)
.sort((a, b) => {
if (b.score === a.score) {
const aDate = a.post.published_at
? new Date(a.post.published_at).getTime()
: 0;
const bDate = b.post.published_at
? new Date(b.post.published_at).getTime()
: 0;
return bDate - aDate;
}
return b.score - a.score;
})
.slice(0, limit)
.map((entry) => entry.post);
if (scored.length >= limit) {
return scored;
}
const fallback = candidates.filter(
(post) => !scored.some((existing) => existing._id === post._id)
);
return [...scored, ...fallback.slice(0, limit - scored.length)].slice(0, limit);
}