|  | 
|  | 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 | +} | 
0 commit comments