"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(null) const [activeCategory, setActiveCategory] = useState("all") const [activeTag, setActiveTag] = useState(null) const [currentPage, setCurrentPage] = useState(pagination?.currentPage ?? 1) const [categoriesExpanded, setCategoriesExpanded] = useState(false) const basePath = pagination?.basePath ?? "/" const computedCategories = useMemo(() => { const counts = new Map() 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) => { 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(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('[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 = (
{filterMode === "categories" ? ( <> {categories.map((category) => ( ))}
{/* 两端渐隐,提示还能横向滑 */}
) return ( // 手机端(
{filterBar} {/* Section header */}
{activeCategory === "all" && !activeTag ? (

{headerMeta.heading}

) : (

{headerMeta.eyebrow} / {headerMeta.heading}

)}
{/* Main layout: Sidebar + Articles */}
{/* Sidebar */}
0 ? : undefined} />
{/* Article list - Single column */}
{pagedArticles.length > 0 ? ( pagedArticles.map((article, index) => ( )) ) : (
暂无符合条件的文章
)} {/* View all link */} {showViewAll && ( )} {pagination && totalPages > 1 && (
上一页 {pageRange.map((page, index) => ( {page === HIDDEN ? ( ) : ( {page} )} ))} 下一页
)}
) }