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}
+
+ ))}
+
+ )}
+
+ {align === 'end' ? '→ 前往下一站' : '探索此站 →'}
+
+
+ ) : (
+
+ );
+
+ 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
+ };
+}