From 80d0b236c52a50ac844e0d8dddb1ac0e9fb4f64f Mon Sep 17 00:00:00 2001 From: gbanyan Date: Tue, 18 Nov 2025 17:34:05 +0800 Subject: [PATCH] Refine navigation and post UI --- app/blog/[slug]/page.tsx | 2 +- app/blog/page.tsx | 11 ++- app/tags/page.tsx | 8 ++- components/hero.tsx | 22 +++--- components/meta-item.tsx | 26 +++++++ components/nav-menu.tsx | 98 ++++++++++++++++++++++++++ components/post-card.tsx | 47 ++++++------ components/post-list-item.tsx | 45 ++++++------ components/post-list-with-controls.tsx | 50 ++++++++----- components/post-storyline-nav.tsx | 6 ++ components/post-toc.tsx | 33 ++++++++- components/reading-progress.tsx | 13 ++-- components/right-sidebar.tsx | 40 +++++++---- components/scroll-reveal.tsx | 59 ++++++++++++++++ components/site-header.tsx | 78 +++++++++++++++----- components/theme-toggle.tsx | 14 ++-- lib/config.ts | 8 +++ styles/globals.css | 60 +++++++++++++++- tailwind.config.js | 26 +++++++ 19 files changed, 518 insertions(+), 128 deletions(-) create mode 100644 components/meta-item.tsx create mode 100644 components/nav-menu.tsx create mode 100644 components/scroll-reveal.tsx diff --git a/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx index 88594ee..d18a69d 100644 --- a/app/blog/[slug]/page.tsx +++ b/app/blog/[slug]/page.tsx @@ -114,7 +114,7 @@ export default function BlogPostPage({ params }: Props) {
{relatedPosts.map((related) => ( - + ))}
diff --git a/app/blog/page.tsx b/app/blog/page.tsx index f3fe372..c30315a 100644 --- a/app/blog/page.tsx +++ b/app/blog/page.tsx @@ -10,9 +10,14 @@ export default function BlogIndexPage() { return (
-

- 所有文章 -

+
+

+ 所有文章 +

+

+ 繼續往下滑,慢慢逛逛。 +

+
); diff --git a/app/tags/page.tsx b/app/tags/page.tsx index 079f878..5f86f6b 100644 --- a/app/tags/page.tsx +++ b/app/tags/page.tsx @@ -1,5 +1,7 @@ import Link from 'next/link'; import type { Metadata } from 'next'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faTags } from '@fortawesome/free-solid-svg-icons'; import { getAllTagsWithCount } from '@/lib/posts'; export const metadata: Metadata = { @@ -19,7 +21,8 @@ export default function TagIndexPage() { return (
-

+

+ 標籤索引

@@ -32,7 +35,7 @@ export default function TagIndexPage() { {tag} ({count}) @@ -43,4 +46,3 @@ export default function TagIndexPage() {

); } - diff --git a/components/hero.tsx b/components/hero.tsx index 6c4ee35..b313cf9 100644 --- a/components/hero.tsx +++ b/components/hero.tsx @@ -7,7 +7,8 @@ import { faGitAlt, faLinkedin } from '@fortawesome/free-brands-svg-icons'; -import { faEnvelope } from '@fortawesome/free-solid-svg-icons'; +import { faEnvelope, faPenNib } from '@fortawesome/free-solid-svg-icons'; +import { MetaItem } from './meta-item'; export function Hero() { const { name, tagline, social } = siteConfig; @@ -58,18 +59,23 @@ export function Hero() { }[]; return ( -
-
-
+
+
+
+ +
+
{initial}

{name}

-

- {tagline} -

+
+ + {tagline} + +
{items.length > 0 && (
{items.map((item) => ( @@ -78,7 +84,7 @@ export function Hero() { href={item.href} target="_blank" rel="noopener noreferrer" - className="flex items-center gap-2 rounded-full bg-white/80 px-3 py-1 shadow-sm ring-1 ring-slate-200 hover:bg-accent-soft dark:bg-slate-900/80 dark:ring-slate-700" + className="motion-link flex items-center gap-2 rounded-full bg-white/80 px-3 py-1 text-slate-600 shadow-sm ring-1 ring-slate-200 hover:-translate-y-0.5 hover:bg-accent-soft hover:text-accent hover:shadow-md dark:bg-slate-900/80 dark:text-slate-200 dark:ring-slate-700" > {item.label} diff --git a/components/meta-item.tsx b/components/meta-item.tsx new file mode 100644 index 0000000..c99f996 --- /dev/null +++ b/components/meta-item.tsx @@ -0,0 +1,26 @@ +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import type { IconDefinition } from '@fortawesome/free-solid-svg-icons'; +import { ReactNode } from 'react'; +import clsx from 'clsx'; + +interface MetaItemProps { + icon: IconDefinition; + children: ReactNode; + className?: string; + tone?: 'default' | 'muted'; +} + +export function MetaItem({ icon, children, className, tone = 'default' }: MetaItemProps) { + return ( + + + {children} + + ); +} diff --git a/components/nav-menu.tsx b/components/nav-menu.tsx new file mode 100644 index 0000000..d14e4dd --- /dev/null +++ b/components/nav-menu.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faBars, + faXmark, + faHouse, + faNewspaper, + faFileLines, + faUser, + faEnvelope, + faLocationDot, + faPenNib, + faTags, + faServer, + faMicrochip, + faBarsStaggered +} from '@fortawesome/free-solid-svg-icons'; +import Link from 'next/link'; + +export type IconKey = + | 'home' + | 'blog' + | 'file' + | 'user' + | 'contact' + | 'location' + | 'pen' + | 'tags' + | 'server' + | 'device' + | 'menu'; + +const ICON_MAP: Record = { + home: faHouse, + blog: faNewspaper, + file: faFileLines, + user: faUser, + contact: faEnvelope, + location: faLocationDot, + pen: faPenNib, + tags: faTags, + server: faServer, + device: faMicrochip, + menu: faBarsStaggered +}; + +export interface NavLinkItem { + key: string; + href: string; + label?: string; + iconKey: IconKey; +} + +interface NavMenuProps { + items: NavLinkItem[]; +} + +export function NavMenu({ items }: NavMenuProps) { + const [open, setOpen] = useState(false); + + const toggle = () => setOpen((val) => !val); + const close = () => setOpen(false); + + return ( +
+ + +
+ ); +} diff --git a/components/post-card.tsx b/components/post-card.tsx index 8d35fb6..70e3d54 100644 --- a/components/post-card.tsx +++ b/components/post-card.tsx @@ -1,30 +1,48 @@ import Link from 'next/link'; import type { Post } from 'contentlayer/generated'; import { siteConfig } from '@/lib/config'; +import { faCalendarDays, faTags } from '@fortawesome/free-solid-svg-icons'; +import { MetaItem } from './meta-item'; interface PostCardProps { post: Post; + showTags?: boolean; } -export function PostCard({ post }: PostCardProps) { +export function PostCard({ post, showTags = true }: PostCardProps) { const cover = post.feature_image && post.feature_image.startsWith('../assets') ? post.feature_image.replace('../assets', '/assets') : undefined; return ( -
+
+
{cover && (
{/* eslint-disable-next-line @next/next/no-img-element */} {post.title}
)} -
+
+
+ {post.published_at && ( + + {new Date(post.published_at).toLocaleDateString( + siteConfig.defaultLocale + )} + + )} + {showTags && post.tags && post.tags.length > 0 && ( + + {post.tags.slice(0, 3).join(', ')} + + )} +

-
- {post.published_at && ( - - {new Date(post.published_at).toLocaleDateString( - siteConfig.defaultLocale - )} - - )} - {post.tags && post.tags.length > 0 && ( - - {post.tags.slice(0, 3).map((t) => ( - - #{t} - - ))} - - )} -
{post.description && (

{post.description} diff --git a/components/post-list-item.tsx b/components/post-list-item.tsx index f1187e7..922fe55 100644 --- a/components/post-list-item.tsx +++ b/components/post-list-item.tsx @@ -1,6 +1,8 @@ import Link from 'next/link'; import type { Post } from 'contentlayer/generated'; import { siteConfig } from '@/lib/config'; +import { faCalendarDays, faTags } from '@fortawesome/free-solid-svg-icons'; +import { MetaItem } from './meta-item'; interface Props { post: Post; @@ -17,43 +19,36 @@ export function PostListItem({ post }: Props) { return (

  • -
    +
    +
    {cover && ( -
    +
    {/* eslint-disable-next-line @next/next/no-img-element */} {post.title}
    )}
    - {post.published_at && ( -

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

    - )} +
    + {post.published_at && ( + + {new Date(post.published_at).toLocaleDateString( + siteConfig.defaultLocale + )} + + )} + {post.tags && post.tags.length > 0 && ( + + {post.tags.slice(0, 3).join(', ')} + + )} +

    {post.title}

    - {post.tags && post.tags.length > 0 && ( -
    - {post.tags.slice(0, 4).map((t) => ( - - #{t} - - ))} -
    - )} {excerpt && (

    {excerpt} diff --git a/components/post-list-with-controls.tsx b/components/post-list-with-controls.tsx index cbdf231..4812f38 100644 --- a/components/post-list-with-controls.tsx +++ b/components/post-list-with-controls.tsx @@ -2,6 +2,13 @@ import { useEffect, useMemo, useState } from 'react'; import type { Post } from 'contentlayer/generated'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faArrowDownWideShort, + faArrowUpWideShort, + faMagnifyingGlass, + faListUl +} from '@fortawesome/free-solid-svg-icons'; import { siteConfig } from '@/lib/config'; import { PostListItem } from './post-list-item'; @@ -74,43 +81,52 @@ export function PostListWithControls({ posts, pageSize }: Props) { return (

    -
    - 排序: +
    + + 排序
    -
    -
    diff --git a/components/post-storyline-nav.tsx b/components/post-storyline-nav.tsx index 1ea9299..8ee9242 100644 --- a/components/post-storyline-nav.tsx +++ b/components/post-storyline-nav.tsx @@ -1,5 +1,7 @@ import Link from 'next/link'; import type { Post } from 'contentlayer/generated'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faArrowLeftLong, faArrowRightLong } from '@fortawesome/free-solid-svg-icons'; interface Props { current: Post; @@ -79,6 +81,10 @@ function Station({ station }: { station: StationConfig }) { 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}`} >

    + {label}

    diff --git a/components/post-toc.tsx b/components/post-toc.tsx index f369e2d..0483429 100644 --- a/components/post-toc.tsx +++ b/components/post-toc.tsx @@ -1,6 +1,8 @@ 'use client'; import { useEffect, useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faListUl, faCircle } from '@fortawesome/free-solid-svg-icons'; interface TocItem { id: string; @@ -48,11 +50,36 @@ export function PostToc() { return () => observer.disconnect(); }, []); + const handleClick = (id: string) => (event: React.MouseEvent) => { + event.preventDefault(); + const el = document.getElementById(id); + if (!el) return; + + el.scrollIntoView({ + behavior: 'smooth', + block: 'start' + }); + + // Temporary highlight + el.classList.add('toc-target-highlight'); + setTimeout(() => { + el.classList.remove('toc-target-highlight'); + }, 700); + + // Update hash without instant jump + if (history.replaceState) { + const url = new URL(window.location.href); + url.hash = id; + history.replaceState(null, '', url.toString()); + } + }; + if (items.length === 0) return null; return (