diff --git a/README.md b/README.md index 8276d5d..f84285b 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Markdown content (posts & pages) lives in a separate repository and is consumed - **Blog index** (`/blog`) - Uses `PostListWithControls`: + - Keyword search filters posts by title, tags, and excerpt with instant feedback. - Sort order: new→old or old→new. - 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. - Body: `prose` typography with tuned light/dark colors, images, blockquotes, code. - Top bar: reading progress indicator. + - Bottom: "相關文章" cards suggesting up to three related posts that share overlapping tags. - **Right sidebar** (on large screens) - Top hero: @@ -77,6 +79,29 @@ Markdown content (posts & pages) lives in a separate repository and is consumed - **Misc** - Floating "back to top" button on long pages. +## Motion & Interaction Guidelines + +- Keep motion subtle and purposeful: + - Use small translations (±2–4px) and short durations (200–400ms, `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 - Node.js **18+** diff --git a/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx index 541bb50..3e22248 100644 --- a/app/blog/[slug]/page.tsx +++ b/app/blog/[slug]/page.tsx @@ -2,10 +2,12 @@ import Link from 'next/link'; import { notFound } from 'next/navigation'; import type { Metadata } from 'next'; import { allPosts } from 'contentlayer/generated'; -import { getPostBySlug } from '@/lib/posts'; +import { getPostBySlug, getRelatedPosts } from '@/lib/posts'; import { siteConfig } from '@/lib/config'; import { ReadingProgress } from '@/components/reading-progress'; import { PostToc } from '@/components/post-toc'; +import { ScrollReveal } from '@/components/scroll-reveal'; +import { PostCard } from '@/components/post-card'; export function generateStaticParams() { return allPosts.map((post) => ({ @@ -34,6 +36,8 @@ export default function BlogPostPage({ params }: Props) { if (!post) return notFound(); + const relatedPosts = getRelatedPosts(post, 3); + return ( <> @@ -41,46 +45,71 @@ export default function BlogPostPage({ params }: Props) { -
-
- {post.published_at && ( -

- {new Date(post.published_at).toLocaleDateString( - siteConfig.defaultLocale - )} -

- )} -

- {post.title} -

- {post.tags && ( -
- {post.tags.map((t) => ( - - #{t} - - ))} -
- )} -
-
- {post.feature_image && ( - // feature_image is stored as "../assets/xyz", serve from "/assets/xyz" - // eslint-disable-next-line @next/next/no-img-element - {post.title} - )} -
-
+
+ +
+ {post.published_at && ( +

+ {new Date(post.published_at).toLocaleDateString( + siteConfig.defaultLocale + )} +

+ )} +

+ {post.title} +

+ {post.tags && ( +
+ {post.tags.map((t) => ( + + #{t} + + ))} +
+ )} +
+
+ + +
+ {post.feature_image && ( + // feature_image is stored as "../assets/xyz", serve from "/assets/xyz" + // eslint-disable-next-line @next/next/no-img-element + {post.title} + )} +
+
+
+ + {relatedPosts.length > 0 && ( + +
+
+

+ 相關文章 +

+

+ 為你挑選相似主題 +

+
+
+ {relatedPosts.map((related) => ( + + ))} +
+
+
+ )}
diff --git a/components/post-list-with-controls.tsx b/components/post-list-with-controls.tsx index c06f34b..cbdf231 100644 --- a/components/post-list-with-controls.tsx +++ b/components/post-list-with-controls.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import type { Post } from 'contentlayer/generated'; import { siteConfig } from '@/lib/config'; import { PostListItem } from './post-list-item'; @@ -15,11 +15,31 @@ type SortOrder = 'new' | 'old'; export function PostListWithControls({ posts, pageSize }: Props) { const [sortOrder, setSortOrder] = useState('new'); const [page, setPage] = useState(1); + const [searchTerm, setSearchTerm] = useState(''); 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 arr = [...posts]; + const arr = [...filteredPosts]; arr.sort((a, b) => { const aDate = a.published_at ? new Date(a.published_at).getTime() @@ -30,13 +50,17 @@ export function PostListWithControls({ posts, pageSize }: Props) { return sortOrder === 'new' ? bDate - aDate : aDate - bDate; }); return arr; - }, [posts, sortOrder]); + }, [filteredPosts, sortOrder]); const totalPages = Math.max(1, Math.ceil(sortedPosts.length / size)); const currentPage = Math.min(page, totalPages); const start = (currentPage - 1) * size; const currentPosts = sortedPosts.slice(start, start + size); + useEffect(() => { + setPage(1); + }, [normalizedQuery]); + const handleChangeSort = (order: SortOrder) => { setSortOrder(order); setPage(1); @@ -49,7 +73,7 @@ export function PostListWithControls({ posts, pageSize }: Props) { return (
-
+
排序:
-
- 第 {currentPage} / {totalPages} 頁 +
+ + 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" + />
-
    - {currentPosts.map((post) => ( - - ))} -
+
+

+ 第 {currentPage} / {totalPages} 頁 · 共 {sortedPosts.length} 篇 + {normalizedQuery && `(搜尋「${searchTerm}」)`} +

+ {normalizedQuery && sortedPosts.length === 0 && ( + + )} +
- {totalPages > 1 && ( + {currentPosts.length === 0 ? ( +
+ 找不到符合關鍵字的文章,換個詞再試試? +
+ ) : ( +
    + {currentPosts.map((post) => ( + + ))} +
+ )} + + {totalPages > 1 && currentPosts.length > 0 && (