From 96ebca37d66d39620d570c67de206404a81f28d7 Mon Sep 17 00:00:00 2001 From: gbanyan Date: Tue, 18 Nov 2025 16:45:46 +0800 Subject: [PATCH] Add storyline navigation rail for posts --- README.md | 1 + app/blog/[slug]/page.tsx | 12 +- components/post-storyline-nav.tsx | 179 ++++++++++++++++++++++++++++++ lib/posts.ts | 15 +++ 4 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 components/post-storyline-nav.tsx diff --git a/README.md b/README.md index f84285b..ca4a79a 100644 --- a/README.md +++ b/README.md @@ -63,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. + - Metro-inspired "storyline" rail that shows上一站 / 你在這裡 / 下一站,with glowing capsules linking to adjacent posts. - Bottom: "相關文章" cards suggesting up to three related posts that share overlapping tags. - **Right sidebar** (on large screens) diff --git a/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx index 3e22248..88594ee 100644 --- a/app/blog/[slug]/page.tsx +++ b/app/blog/[slug]/page.tsx @@ -2,12 +2,13 @@ import Link from 'next/link'; import { notFound } from 'next/navigation'; import type { Metadata } from 'next'; import { allPosts } from 'contentlayer/generated'; -import { getPostBySlug, getRelatedPosts } from '@/lib/posts'; +import { getPostBySlug, getRelatedPosts, getPostNeighbors } 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'; +import { PostStorylineNav } from '@/components/post-storyline-nav'; export function generateStaticParams() { return allPosts.map((post) => ({ @@ -37,6 +38,7 @@ export default function BlogPostPage({ params }: Props) { if (!post) return notFound(); const relatedPosts = getRelatedPosts(post, 3); + const neighbors = getPostNeighbors(post); return ( <> @@ -91,6 +93,14 @@ export default function BlogPostPage({ params }: Props) { + + + + {relatedPosts.length > 0 && (
diff --git a/components/post-storyline-nav.tsx b/components/post-storyline-nav.tsx new file mode 100644 index 0000000..ab45c3d --- /dev/null +++ b/components/post-storyline-nav.tsx @@ -0,0 +1,179 @@ +import Link from 'next/link'; +import type { Post } from 'contentlayer/generated'; +import { siteConfig } from '@/lib/config'; + +interface Props { + current: Post; + newer?: Post; + older?: Post; +} + +interface StationConfig { + key: 'older' | 'current' | 'newer'; + label: string; + post?: Post; + hint: string; + align: 'start' | 'center' | 'end'; + rel?: 'prev' | 'next'; +} + +export function PostStorylineNav({ current, newer, older }: Props) { + const stations: StationConfig[] = [ + { + key: 'older', + label: '回程站', + post: older, + hint: older + ? `發表於 ${formatDate(older.published_at)}` + : '這裡已是最早的文章', + align: 'start', + rel: 'prev' + }, + { + key: 'current', + label: '你在這裡', + post: current, + hint: current.published_at + ? `本篇發表於 ${formatDate(current.published_at)}` + : '草稿狀態', + align: 'center' + }, + { + key: 'newer', + label: '前進站', + post: newer, + hint: newer + ? `發表於 ${formatDate(newer.published_at)}` + : '還沒有更新的文章', + align: 'end', + rel: 'next' + } + ]; + + return ( + + ); +} + +function Station({ station }: { station: StationConfig }) { + const { post, label, hint, align, key, rel } = station; + const alignClass = + align === 'start' + ? 'items-start text-left' + : align === 'end' + ? 'items-end text-right' + : 'items-center text-center'; + + const baseCard = `group relative flex w-full flex-col gap-2 rounded-3xl border border-slate-200/70 bg-white/95 px-4 py-5 text-slate-800 shadow-sm transition duration-300 hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/70 dark:border-slate-800/70 dark:bg-slate-900/80 dark:text-slate-100 ${alignClass}`; + + const circleClass = (() => { + if (!post && key !== 'current') { + return 'border-dashed border-slate-300 dark:border-slate-700'; + } + if (key === 'current') { + return 'border-blue-500 bg-blue-500 shadow-[0_0_0_6px_rgba(59,130,246,0.15)] dark:border-blue-400 dark:bg-blue-400'; + } + return 'border-blue-500 bg-white shadow-[0_0_0_4px_rgba(59,130,246,0.08)] dark:border-blue-400 dark:bg-slate-900'; + })(); + + const content = post ? ( + + +

+ {post.title} +

+ {post.published_at && ( +

{formatDate(post.published_at)}

+ )} + {post.tags && post.tags.length > 0 && ( +
+ {post.tags.slice(0, 3).map((tag) => ( + + #{tag} + + ))} +
+ )} + + + ) : ( +
+ +

{hint}

+
+ ); + + return ( +
+ {content} +
+ ); +} + +function StationHeader({ + label, + hint, + circleClass, + align, + hasPost +}: { + label: string; + hint: string; + circleClass: string; + align: 'start' | 'center' | 'end'; + hasPost?: boolean; +}) { + return ( +
+ {align === 'end' && ( + + {hint} + + )} + + {hasPost && } + + {label} + {align !== 'end' && ( + + {hint} + + )} +
+ ); +} + +function formatDate(input?: string | Date) { + if (!input) return ''; + const date = input instanceof Date ? input : new Date(input); + return date.toLocaleDateString(siteConfig.defaultLocale ?? 'zh-TW', { + year: 'numeric', + month: '2-digit', + day: '2-digit' + }); +} + +function getJustifyClass(align: 'start' | 'center' | 'end') { + if (align === 'end') return 'justify-end'; + if (align === 'center') return 'justify-center'; + return 'justify-start'; +} diff --git a/lib/posts.ts b/lib/posts.ts index e3eeda7..b9c22b4 100644 --- a/lib/posts.ts +++ b/lib/posts.ts @@ -87,3 +87,18 @@ export function getRelatedPosts(target: Post, limit = 3): Post[] { return [...scored, ...fallback.slice(0, limit - scored.length)].slice(0, limit); } + +export function getPostNeighbors(target: Post): { + newer?: Post; + older?: Post; +} { + const sorted = getAllPostsSorted(); + const index = sorted.findIndex((post) => post._id === target._id); + + if (index === -1) return {}; + + return { + newer: index > 0 ? sorted[index - 1] : undefined, + older: index < sorted.length - 1 ? sorted[index + 1] : undefined + }; +}