初始提交:时之刃的小站
基于 VermilionVoid(朱墨留白)主题的个人博客。 - 站点配置集中在 src/data/site.ts 与 src/data/profile.ts - 30 篇文章、8 条偶得、相册 - 评论使用自托管 twikoo - 部署走 deploy.sh(rsync 到树莓派 + 云服务器)
This commit is contained in:
@@ -0,0 +1,438 @@
|
||||
"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)
|
||||
}
|
||||
|
||||
return (
|
||||
<section ref={sectionRef} className="article-list-root px-6 py-16 bg-surface-subtle">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* 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-2xl 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-2xl 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user