Skip to content

Commit ed73f13

Browse files
committed
feat: redesign posts page with timeline view
1 parent 7974037 commit ed73f13

File tree

2 files changed

+131
-68
lines changed

2 files changed

+131
-68
lines changed
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
'use client';
2+
3+
import { useMemo, useState } from 'react';
4+
import dayjs from 'dayjs';
5+
import Link from 'next/link';
6+
import { Search } from 'lucide-react';
7+
import type { Post } from 'contentlayer/generated';
8+
9+
import { Tag } from './TagItem';
10+
11+
interface PostsTimelineProps {
12+
posts: Post[];
13+
}
14+
15+
interface GroupedPosts {
16+
year: string;
17+
items: Post[];
18+
}
19+
20+
export function PostsTimeline({ posts }: PostsTimelineProps) {
21+
const [query, setQuery] = useState('');
22+
const normalizedQuery = query.trim().toLowerCase();
23+
24+
const filteredPosts = useMemo(() => {
25+
if (!normalizedQuery) return posts;
26+
27+
return posts.filter((post) => {
28+
const haystack = [post.title, post.description, ...(post.tags ?? [])]
29+
.join(' ')
30+
.toLowerCase();
31+
return haystack.includes(normalizedQuery);
32+
});
33+
}, [posts, normalizedQuery]);
34+
35+
const groupedPosts = useMemo<GroupedPosts[]>(() => {
36+
const map = new Map<string, Post[]>();
37+
38+
filteredPosts.forEach((post) => {
39+
const year = dayjs(post.date).format('YYYY');
40+
if (!map.has(year)) {
41+
map.set(year, []);
42+
}
43+
map.get(year)!.push(post);
44+
});
45+
46+
return Array.from(map.entries())
47+
.map(([year, items]) => ({
48+
year,
49+
items
50+
}))
51+
.sort((a, b) => Number(b.year) - Number(a.year));
52+
}, [filteredPosts]);
53+
54+
return (
55+
<div className="space-y-10">
56+
<div className="relative max-w-xl">
57+
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
58+
<input
59+
type="search"
60+
value={query}
61+
onChange={(event) => setQuery(event.target.value)}
62+
placeholder="搜索文章标题、标签或简介"
63+
aria-label="搜索文章"
64+
className="w-full rounded-md border border-zinc-200 bg-background py-2 pl-10 pr-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-500/30 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100"
65+
/>
66+
</div>
67+
68+
{filteredPosts.length === 0 ? (
69+
<p className="text-sm text-muted-foreground">
70+
没有找到与“
71+
<span className="text-foreground">{query}</span>
72+
”相关的文章。
73+
</p>
74+
) : (
75+
<div className="space-y-12">
76+
{groupedPosts.map(({ year, items }) => (
77+
<section key={year} className="space-y-6">
78+
<div className="flex items-center gap-3">
79+
<div className="relative z-10 flex h-10 w-10 items-center justify-center rounded-full border border-border bg-background text-sm font-semibold text-foreground">
80+
{year}
81+
</div>
82+
<div className="flex-1 border-t border-dashed border-border/70" />
83+
</div>
84+
<div className="space-y-8 border-l border-border/60 pl-6 dark:border-border/40">
85+
{items.map((post) => (
86+
<article key={post.slug} className="relative">
87+
<span className="absolute -left-[9px] top-1.5 inline-flex h-3 w-3 items-center justify-center rounded-full border-2 border-background bg-violet-500 shadow dark:border-zinc-950 dark:bg-violet-400" />
88+
<div className="rounded-lg border border-border/60 bg-card/40 p-4 transition-colors hover:border-violet-500/40 hover:bg-card">
89+
<div className="flex flex-col gap-2">
90+
<time
91+
dateTime={post.date}
92+
className="text-xs uppercase tracking-wide text-muted-foreground"
93+
>
94+
{dayjs(post.date).format('YYYY-MM-DD')}
95+
</time>
96+
<Link
97+
href={`/posts/${post.slug}`}
98+
className="text-lg font-semibold text-foreground transition-colors hover:text-violet-600 dark:hover:text-violet-400"
99+
>
100+
{post.title}
101+
</Link>
102+
{post.description ? (
103+
<p className="text-sm leading-relaxed text-muted-foreground">
104+
{post.description}
105+
</p>
106+
) : null}
107+
{post.tags?.length ? (
108+
<div className="mt-1 flex flex-wrap gap-2">
109+
{post.tags.map((tag) => (
110+
<Tag key={tag}>{tag}</Tag>
111+
))}
112+
</div>
113+
) : null}
114+
</div>
115+
</div>
116+
</article>
117+
))}
118+
</div>
119+
</section>
120+
))}
121+
</div>
122+
)}
123+
</div>
124+
);
125+
}

src/app/(app)/posts/page.tsx

Lines changed: 6 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,7 @@
11
import { Container } from '@/components/Container';
2-
import { AspectRatio } from '@/components/ui/aspect-ratio';
3-
import { Separator } from '@/components/ui/separator';
4-
import { cn } from '@/lib/utils';
5-
import { allPosts, type Post } from 'contentlayer/generated';
6-
import dayjs from 'dayjs';
7-
import Image from 'next/image';
8-
import Link from 'next/link';
2+
import { allPosts } from 'contentlayer/generated';
93
import CoverSwitch from './CoverSwitch';
10-
import { Tag } from './TagItem';
11-
12-
export interface PostItem {
13-
title: string;
14-
date: string;
15-
url: string;
16-
slug: string;
17-
tags: string[];
18-
description: string;
19-
author: string;
20-
cover?: string;
21-
} // components/Tag.js
22-
23-
function PostCard({ post, showCover }: { post: Post; showCover?: boolean }) {
24-
return (
25-
<Link
26-
href={`/posts/${post.slug}`}
27-
className={cn(
28-
'text-violet-500 relative dark:text-violet-400',
29-
showCover ? 'hover:drop-shadow-2xl' : ' hover:text-violet-700'
30-
)}
31-
>
32-
{showCover && (
33-
<AspectRatio
34-
ratio={16 / 9}
35-
className="bg-muted absolute left-0 top-0 rounded-md overflow-hidden"
36-
>
37-
<Image
38-
unoptimized
39-
src={post.cover ?? ''}
40-
alt={post.title}
41-
fill
42-
className=" object-cover"
43-
/>
44-
</AspectRatio>
45-
)}
46-
<div className="px-4 py-4 rounded-sm border-b-[1px] border-violet-200 sm:border-none cursor-pointer">
47-
<h2 className="mb-1 text-xl font-medium">
48-
<span>{post.title}</span>
49-
</h2>
50-
<div className="hidden sm:flex flex-wrap mt-2 justify-start items-center space-x-4 text-sm">
51-
<time dateTime={post.date} className=" block text-xs text-gray-600">
52-
{dayjs(post.date).format('YYYY-MM-DD')}
53-
</time>
54-
<Separator orientation="vertical" className="h-5" />
55-
{post.tags.map((tag) => (
56-
<Tag key={tag}>{tag}</Tag>
57-
))}
58-
</div>
59-
</div>
60-
</Link>
61-
);
62-
}
4+
import { PostsTimeline } from './PostsTimeline';
635
const title = '我的博客列表 | ';
646
const description =
657
'记录在编程学习、工作中遇到的问题。我精心整理为技术博客文章合集,涵盖前端开发、React、Next.js等热门话题。发现实用的开发技巧、最佳实践和行业动态,提升您的开发技能。立即浏览最新文章!';
@@ -92,23 +34,19 @@ export default function Posts() {
9234
.slice(0, allPosts.length - 1);
9335
return (
9436
<Container className="min-h-[50vh] mt-16">
95-
<header className="max-w-2xl mb-4">
96-
<div className="flex items-center ">
37+
<header className="mb-6 max-w-2xl">
38+
<div className="flex items-center">
9739
<h1 className="text-xl font-bold tracking-tight text-zinc-800 dark:text-zinc-100 sm:text-xl">
9840
我的 blog
9941
</h1>
10042
<CoverSwitch />
10143
</div>
102-
<p className="mt-4 mb-6 text-base text-zinc-600 dark:text-zinc-400">
44+
<p className="mt-4 text-base text-zinc-600 dark:text-zinc-400">
10345
记录工作,学习,生活中的所见所闻所想,主要分享领域 <b>前端开发</b>
10446
,偶尔也会记录 <b>其他内容</b>
10547
</p>
10648
</header>
107-
<div className={cn('grid grid-cols-1 gap-4', false ? 'grid-cols-2' : '')}>
108-
{sortedPosts.map((post, idx) => (
109-
<PostCard showCover={false} key={idx} post={post} />
110-
))}
111-
</div>
49+
<PostsTimeline posts={sortedPosts} />
11250
</Container>
11351
);
11452
}

0 commit comments

Comments
 (0)