Files
NewBlog/src/components/article-list.tsx
T

569 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useMemo, useState, useEffect, useRef, useCallback } from "react"
import type { MouseEvent } from "react"
import { ArticleCard } from "./article-card"
import { Sidebar } from "./sidebar"
import { ThoughtPreview, type ThoughtPreviewItem } from "./thought-preview"
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
} from "@/components/ui/pagination"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { cn } from "@/lib/utils"
export type ArticleMeta = {
slug: string
title: string
excerpt?: string
category: string
categoryLabel: string
tags: string[]
date: string
wordCount?: number
readTime?: string
image?: string
pinned?: boolean
}
type SidebarCategory = { id: string; name: string; count: number }
type PaginationMeta = {
currentPage: number
basePath?: string
pageSize?: number
}
const HIDDEN = -1
const ADJACENT_DISTANCE = 2
const VISIBLE_PAGES = ADJACENT_DISTANCE * 2 + 1
const normalizeBasePath = (basePath: string) => {
if (!basePath.startsWith("/")) return `/${basePath}`.replace(/\/+$/, "")
return basePath === "/" ? "" : basePath.replace(/\/+$/, "")
}
const getPageHref = (pageNumber: number, basePath: string) => {
const base = normalizeBasePath(basePath)
if (pageNumber === 1) return base || "/"
return `${base}/${pageNumber}/`
}
const buildPageRange = (currentPage: number, totalPages: number) => {
if (totalPages <= 1) return []
let count = 1
let left = currentPage
let right = currentPage
while (left - 1 > 0 && right + 1 <= totalPages && count + 2 <= VISIBLE_PAGES) {
count += 2
left -= 1
right += 1
}
while (left - 1 > 0 && count < VISIBLE_PAGES) {
count += 1
left -= 1
}
while (right + 1 <= totalPages && count < VISIBLE_PAGES) {
count += 1
right += 1
}
const pages: number[] = []
if (left > 1) pages.push(1)
if (left === 3) pages.push(2)
if (left > 3) pages.push(HIDDEN)
for (let page = left; page <= right; page += 1) pages.push(page)
if (right < totalPages - 2) pages.push(HIDDEN)
if (right === totalPages - 2) pages.push(totalPages - 1)
if (right < totalPages) pages.push(totalPages)
return pages
}
export function ArticleList({
articles,
title = "近期文章",
showViewAll = true,
pagination,
sidebarCategories,
sidebarTags,
sidebarThoughts,
}: {
articles: ArticleMeta[]
title?: string
showViewAll?: boolean
pagination?: PaginationMeta
sidebarCategories?: SidebarCategory[]
sidebarTags?: string[]
sidebarThoughts?: ThoughtPreviewItem[]
}) {
const sectionRef = useRef<HTMLElement | null>(null)
const [activeCategory, setActiveCategory] = useState("all")
const [activeTag, setActiveTag] = useState<string | null>(null)
const [currentPage, setCurrentPage] = useState(pagination?.currentPage ?? 1)
const [categoriesExpanded, setCategoriesExpanded] = useState(false)
const basePath = pagination?.basePath ?? "/"
const computedCategories = useMemo(() => {
const counts = new Map<string, { id: string; name: string; count: number }>()
articles.forEach((article) => {
if (!counts.has(article.category)) {
counts.set(article.category, { id: article.category, name: article.categoryLabel, count: 0 })
}
counts.get(article.category)!.count += 1
})
return [
{ id: "all", name: "全部", count: articles.length },
...Array.from(counts.values()).sort((a, b) => a.name.localeCompare(b.name)),
]
}, [articles])
const categories = useMemo(() => {
if (!sidebarCategories || sidebarCategories.length === 0) {
return computedCategories
}
const hasAll = sidebarCategories.some((category) => category.id === "all")
if (hasAll) return sidebarCategories
const total = sidebarCategories.reduce((sum, category) => sum + category.count, 0)
return [{ id: "all", name: "全部", count: total }, ...sidebarCategories]
}, [computedCategories, sidebarCategories])
const computedTags = useMemo(() => {
const allTags = articles.flatMap((article) => article.tags || [])
return Array.from(new Set(allTags)).sort((a, b) => a.localeCompare(b))
}, [articles])
const tags = sidebarTags && sidebarTags.length > 0 ? sidebarTags : computedTags
const syncFromLocation = useCallback(() => {
if (typeof window === "undefined") return
const params = new URLSearchParams(window.location.search)
const queryCategory = params.get("category")?.trim()
const queryTag = params.get("tag")?.trim()
let nextCategory = "all"
let nextTag: string | null = null
let nextExpanded = false
if (queryTag && tags.includes(queryTag)) {
nextTag = queryTag
} else if (queryCategory && categories.some((category) => category.id === queryCategory)) {
nextCategory = queryCategory
nextExpanded = true
}
setActiveCategory(nextCategory)
setActiveTag(nextTag)
setCategoriesExpanded(nextExpanded)
const shouldResetPage = Boolean(nextTag) || nextCategory !== "all"
setCurrentPage(shouldResetPage ? 1 : pagination?.currentPage ?? 1)
if (document.documentElement.hasAttribute("data-prefilter")) {
requestAnimationFrame(() => {
document.documentElement.removeAttribute("data-prefilter")
})
}
}, [categories, tags, pagination?.currentPage])
useEffect(() => {
if (typeof window === "undefined") return
syncFromLocation()
const handleLocationChange = () => {
syncFromLocation()
}
window.addEventListener("popstate", handleLocationChange)
window.addEventListener("hashchange", handleLocationChange)
document.addEventListener("astro:page-load", handleLocationChange)
document.addEventListener("astro:after-swap", handleLocationChange)
return () => {
window.removeEventListener("popstate", handleLocationChange)
window.removeEventListener("hashchange", handleLocationChange)
document.removeEventListener("astro:page-load", handleLocationChange)
document.removeEventListener("astro:after-swap", handleLocationChange)
}
}, [syncFromLocation])
const filteredArticles = useMemo(() => {
return articles.filter((article) => {
const categoryMatch = activeCategory === "all" || article.category === activeCategory
const tagMatch = !activeTag || article.tags.includes(activeTag)
return categoryMatch && tagMatch
})
}, [articles, activeCategory, activeTag])
const headerMeta = useMemo(() => {
if (activeTag) {
return {
eyebrow: "标签",
heading: activeTag,
}
}
if (activeCategory !== "all") {
const currentCategory = categories.find((category) => category.id === activeCategory)
return {
eyebrow: "分类",
heading: currentCategory?.name ?? activeCategory,
}
}
return {
heading: title,
}
}, [activeTag, activeCategory, categories, title])
const headerAnimationKey = activeTag ? `tag:${activeTag}` : `category:${activeCategory}`
const isFiltering = activeCategory !== "all" || Boolean(activeTag)
const pageSize = pagination?.pageSize ?? Math.max(1, articles.length)
const totalPages = pagination ? Math.max(1, Math.ceil(filteredArticles.length / pageSize)) : 1
useEffect(() => {
if (!pagination) return
if (currentPage > totalPages) {
setCurrentPage(1)
}
}, [pagination, currentPage, totalPages])
const pagedArticles = pagination
? filteredArticles.slice((currentPage - 1) * pageSize, currentPage * pageSize)
: filteredArticles
const pageRange = useMemo(() => {
if (!pagination) return []
return buildPageRange(currentPage, totalPages)
}, [pagination, currentPage, totalPages])
const withHomeHash = (href?: string, pageNumber?: number) => {
if (!href) return undefined
if (pageNumber === 1) return `${href}#home-main`
return href
}
const previousUrl = pagination && currentPage > 1 ? getPageHref(currentPage - 1, basePath) : undefined
const nextUrl = pagination && currentPage < totalPages ? getPageHref(currentPage + 1, basePath) : undefined
const scrollToListTop = () => {
const target = document.getElementById("home-main") ?? sectionRef.current
if (!target) return
target.scrollIntoView({ behavior: "smooth", block: "start" })
}
const updateSearchParams = (nextCategory: string, nextTag: string | null) => {
if (typeof window === "undefined") return
const url = new URL(window.location.href)
const targetPath = pagination ? getPageHref(1, basePath) : url.pathname
if (nextCategory !== "all") {
url.searchParams.set("category", nextCategory)
} else {
url.searchParams.delete("category")
}
if (nextTag) {
url.searchParams.set("tag", nextTag)
} else {
url.searchParams.delete("tag")
}
const search = url.searchParams.toString()
window.history.replaceState({}, "", `${targetPath}${search ? `?${search}` : ""}#home-main`)
}
const handleCategoryChange = (category: string) => {
setActiveCategory(category)
setActiveTag(null)
setCurrentPage(1)
setCategoriesExpanded(true)
updateSearchParams(category, null)
requestAnimationFrame(scrollToListTop)
}
const handleTagChange = (tag: string | null) => {
setActiveTag(tag)
setActiveCategory("all")
setCategoriesExpanded(false)
setCurrentPage(1)
updateSearchParams("all", tag)
requestAnimationFrame(scrollToListTop)
}
const handlePageClick =
(page: number) => (event: MouseEvent<HTMLAnchorElement>) => {
if (!pagination || !isFiltering) return
event.preventDefault()
setCurrentPage(page)
}
/* ---------- 移动端分类胶囊条 ---------- */
// 移动端侧栏被推到文章列表下面(实测第一篇文章 y=999、侧栏 y=3212),
// 所以手机上看不到分类筛选。这里在页眉下面再挂一条吸附的胶囊条,
// 复用同一份 categories / activeCategory / handleCategoryChange,不写第二套逻辑。
const [filterMode, setFilterMode] = useState<"categories" | "tags">(activeTag ? "tags" : "categories")
const filterBarRef = useRef<HTMLDivElement>(null)
const [filterEdges, setFilterEdges] = useState({ left: false, right: false })
const updateFilterEdges = useCallback(() => {
const el = filterBarRef.current
if (!el) return
const max = el.scrollWidth - el.clientWidth
setFilterEdges({
left: el.scrollLeft > 4,
right: max > 4 && el.scrollLeft < max - 4,
})
}, [])
// 把当前项滚到中间,只操作这一条横向滚动容器,不碰页面纵向位置
useEffect(() => {
const el = filterBarRef.current
if (!el) return
const active = el.querySelector<HTMLElement>('[data-filter-active="true"]')
if (!active) {
updateFilterEdges()
return
}
const target = active.offsetLeft - (el.clientWidth - active.offsetWidth) / 2
el.scrollTo({ left: Math.max(0, target), behavior: "smooth" })
const timer = window.setTimeout(updateFilterEdges, 350)
return () => window.clearTimeout(timer)
}, [activeCategory, activeTag, filterMode, updateFilterEdges])
useEffect(() => {
const el = filterBarRef.current
if (!el) return
updateFilterEdges()
el.addEventListener("scroll", updateFilterEdges, { passive: true })
window.addEventListener("resize", updateFilterEdges)
return () => {
el.removeEventListener("scroll", updateFilterEdges)
window.removeEventListener("resize", updateFilterEdges)
}
}, [updateFilterEdges, filterMode, categories.length, tags.length])
const filterPill =
"shrink-0 inline-flex items-center h-8 px-3 rounded-full border text-[13px] leading-none transition-colors duration-150"
const filterPillOn = "bg-primary border-primary text-primary-foreground"
const filterPillOff = "border-border text-muted-foreground hover:border-primary hover:text-primary"
const filterBar = (
<div className="lg:hidden sticky top-[var(--header-h)] z-30 -mx-6 mb-8 border-b border-border/60 bg-surface-subtle">
<div
ref={filterBarRef}
className="no-scrollbar flex items-center gap-2 overflow-x-auto px-6 py-2.5"
>
{filterMode === "categories" ? (
<>
{categories.map((category) => (
<button
key={category.id}
type="button"
data-filter-active={activeCategory === category.id ? "true" : undefined}
onClick={() => handleCategoryChange(category.id)}
className={cn(filterPill, activeCategory === category.id ? filterPillOn : filterPillOff)}
>
{category.name}
<span className="ml-1 text-[11px] opacity-60">{category.count}</span>
</button>
))}
<span className="shrink-0 w-px h-4 bg-border" aria-hidden="true" />
<button
type="button"
onClick={() => setFilterMode("tags")}
className={cn(filterPill, filterPillOff)}
>
标签 ›
</button>
</>
) : (
<>
<button
type="button"
onClick={() => setFilterMode("categories")}
className={cn(filterPill, filterPillOff)}
>
‹ 分类
</button>
<span className="shrink-0 w-px h-4 bg-border" aria-hidden="true" />
{tags.map((tag) => (
<button
key={tag}
type="button"
data-filter-active={activeTag === tag ? "true" : undefined}
onClick={() => handleTagChange(activeTag === tag ? null : tag)}
className={cn(filterPill, activeTag === tag ? filterPillOn : filterPillOff)}
>
{tag}
</button>
))}
</>
)}
</div>
{/* 两端渐隐,提示还能横向滑 */}
<span
aria-hidden="true"
className={cn(
"pointer-events-none absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-surface-subtle/90 to-transparent transition-opacity duration-200",
filterEdges.left ? "opacity-100" : "opacity-0",
)}
/>
<span
aria-hidden="true"
className={cn(
"pointer-events-none absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-surface-subtle/90 to-transparent transition-opacity duration-200",
filterEdges.right ? "opacity-100" : "opacity-0",
)}
/>
</div>
)
return (
// 手机端(<lg)上方不留内边距:列表的第一个元素是吸顶胶囊条,
// 让它紧贴 section 顶端,配合 #home-main 的 scroll-margin 正好落在页眉下沿;
// 桌面端没有吸顶条,保留 py-16 的呼吸位。
<section
ref={sectionRef}
className="article-list-root px-6 pt-0 pb-16 lg:py-16 bg-surface-subtle"
>
<div className="max-w-6xl mx-auto">
{filterBar}
{/* Section header */}
<div className="mb-10 onload-animation" style={{ animationDelay: "50ms" }}>
<div
key={headerAnimationKey}
className="max-w-xl lg:max-w-sm"
style={{ animation: "fade-in-up 220ms ease-out" }}
>
{activeCategory === "all" && !activeTag ? (
<h2 className="font-serif text-[21px] font-bold leading-tight tracking-tight text-foreground sm:text-3xl lg:text-[2.625rem]">
{headerMeta.heading}
</h2>
) : (
<h2 className="flex flex-wrap items-end gap-x-2 gap-y-1 font-serif text-[21px] font-bold leading-tight tracking-tight sm:text-3xl lg:text-[2.625rem]">
<span className="text-foreground/68">{headerMeta.eyebrow}</span>
<span className="text-foreground/35">/</span>
<span className="text-primary">{headerMeta.heading}</span>
</h2>
)}
</div>
</div>
{/* Main layout: Sidebar + Articles */}
<div className="flex flex-col lg:flex-row gap-8">
{/* Sidebar */}
<div className="order-2 lg:order-1 onload-animation" style={{ animationDelay: "100ms" }}>
<Sidebar
categories={categories}
tags={tags}
activeCategory={activeCategory}
onCategoryChange={handleCategoryChange}
activeTag={activeTag}
onTagChange={handleTagChange}
categoriesExpanded={categoriesExpanded}
onCategoriesExpandedChange={setCategoriesExpanded}
extra={sidebarThoughts && sidebarThoughts.length > 0 ? <ThoughtPreview thoughts={sidebarThoughts} /> : undefined}
/>
</div>
{/* Article list - Single column */}
<div className="order-1 lg:order-2 flex-1 space-y-4">
{pagedArticles.length > 0 ? (
pagedArticles.map((article, index) => (
<ArticleCard
key={article.slug}
article={article}
className="onload-animation"
style={{ animationDelay: `calc(var(--content-delay) + ${index * 50}ms)` }}
/>
))
) : (
<div className="text-center py-12 text-muted-foreground">暂无符合条件的文章</div>
)}
{/* View all link */}
{showViewAll && (
<div className="pt-8 text-center">
<a
href="/posts/"
className="inline-flex items-center gap-2 text-muted-foreground hover:text-primary transition-colors duration-200 group"
>
<span>查看全部文章</span>
<svg
className="w-4 h-4 transition-transform duration-200 group-hover:translate-x-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</a>
</div>
)}
{pagination && totalPages > 1 && (
<div className="pt-8">
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationLink
href={withHomeHash(previousUrl, currentPage - 1)}
size="default"
aria-disabled={!previousUrl}
tabIndex={previousUrl ? undefined : -1}
className={cn("gap-1 px-2.5", !previousUrl && "pointer-events-none opacity-50")}
rel={previousUrl ? "prev" : undefined}
onClick={previousUrl ? handlePageClick(currentPage - 1) : undefined}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:block">上一页</span>
</PaginationLink>
</PaginationItem>
{pageRange.map((page, index) => (
<PaginationItem key={`${page}-${index}`}>
{page === HIDDEN ? (
<PaginationEllipsis />
) : (
<PaginationLink
href={withHomeHash(getPageHref(page, basePath), page)}
isActive={currentPage === page}
onClick={handlePageClick(page)}
>
{page}
</PaginationLink>
)}
</PaginationItem>
))}
<PaginationItem>
<PaginationLink
href={withHomeHash(nextUrl, currentPage + 1)}
size="default"
aria-disabled={!nextUrl}
tabIndex={nextUrl ? undefined : -1}
className={cn("gap-1 px-2.5", !nextUrl && "pointer-events-none opacity-50")}
rel={nextUrl ? "next" : undefined}
onClick={nextUrl ? handlePageClick(currentPage + 1) : undefined}
>
<span className="hidden sm:block">下一页</span>
<ChevronRight className="size-4" />
</PaginationLink>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</div>
</div>
</div>
</section>
)
}