569 lines
20 KiB
TypeScript
569 lines
20 KiB
TypeScript
"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>
|
||
)
|
||
}
|