Files
NewBlog/src/components/bookshelf-content.astro
T
dsh 4df235ea21 已读完的书挪进「书籍」,抓取上限去掉(8 -> 整架 26 本)
抓取范围
- TOP_N 默认从 8 改成 0=不限制。原来那个 8 是我拍的「最近动过的前 8 本」,
  不是接口限制;整架 26 本也就 1+26 个请求,完全够用
- --top N 仍可用,想只抓最近几本时加

分组调整
- 「最近在读」只留 progress < 100 的(20 本),进度条不再出现 100%
- 新增「书籍」区底部的「微信读书 · 已读完」分组(6 本),卡片结构与手工
  书单一致,左上角带「已读完」徽标;因为微信读书的数据没有书评/简介,
  这一组点击直接跳 weread.qq.com,不弹窗
- 这一组带 data-weread-card + data-category="微信读书",所以搜索框和
  「领域」筛选同样作用得到;领域筛选里会多出一个「微信读书」选项
- 筛选逻辑把 [data-book-card] 的选择器扩成 [data-book-card],
  [data-weread-card],分组计数也一并算上

实测数据分布(整架 26 本)
- 已读完 6 / 在读中 16(1%-97%)/ 没开始 4(0%)

静态验证(dist/bookshelf/index.html)
- 在读区 20 张卡片,进度条 30/0/77/28/13/97/0/5/16/3/10/1/4/13/4/0/1/10/6/0,
  区内无「已读完」字样
- 已读完分组 6 张卡片,6 个徽标、6 个微信读书跳转链接,均带 data-weread-card
- 手工书单 3 张卡片未受影响;领域筛选新增「微信读书」按钮
2026-09-15 10:45:29 +08:00

683 lines
34 KiB
Plaintext
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.
---
import SiteHeader from "@/components/site-header.astro"
import Footer from "@/components/footer.astro"
import { cn } from "@/lib/utils"
import booksData from "../../public/data/books.json"
import { formatReadingDate, getReadingData } from "@/lib/reading"
// 微信读书数据。数据缺失时 reading 为 null,页面退化成空态,不报错。
const reading = getReadingData()
const readingBooks = reading?.books ?? []
// 读完的挪到「书籍」区,剩下的才留在「最近在读」
const inProgressReading = readingBooks.filter((book) => !book.finished)
const finishedReading = readingBooks.filter((book) => book.finished)
const readingCount = inProgressReading.length
/** 「书籍」区里给微信读书已读书记一个分类名,同时作为可筛选的领域 */
const WEREAD_CATEGORY = "微信读书"
// ---- Types
interface BookFields {
书名: string
作者?: string[]
书籍简介?: string
书评?: string
封面?: { local_path: string }[]
推荐状态?: "推荐" | "中庸" | "不行"
阅读进度?: string
完成阅读时期?: string
领域?: string
}
interface BookRecord {
fields: BookFields
id: string
record_id: string
}
// ---- Books
const allBooks = (booksData.data.items as BookRecord[]).filter(
(b) => b.fields.阅读进度 === "1",
)
const booksByCategory = allBooks.reduce<Record<string, BookRecord[]>>((acc, book) => {
const cat = book.fields.领域 || "未分类"
;(acc[cat] = acc[cat] || []).push(book)
return acc
}, {})
const bookCategories = ["全部", ...Object.keys(booksByCategory), ...(finishedReading.length > 0 ? [WEREAD_CATEGORY] : [])]
const bookStatuses = ["全部", "推荐", "中庸", "不行"]
const bookStats = {
total: allBooks.length,
recommended: allBooks.filter((b) => b.fields.推荐状态 === "推荐").length,
categories: Object.keys(booksByCategory).length,
}
const getBookCover = (b: BookRecord): string | null => b.fields.封面?.[0]?.local_path ?? null
// ---- Data for client scripts (modal, filters)
const booksForClient = allBooks.map((b) => ({
id: b.id,
title: b.fields.书名,
authors: b.fields.作者?.join(", ") ?? "",
category: b.fields.领域 || "未分类",
status: b.fields.推荐状态 ?? "",
cover: getBookCover(b) ?? "",
review: b.fields.书评 ?? "",
description: b.fields.书籍简介 ?? "",
finished: b.fields.完成阅读时期 ?? "",
}))
// Status badge color helpers
const recommendBadge = (status?: string) => {
switch (status) {
case "推荐":
return "bg-status-positive-bg text-status-positive-fg"
case "中庸":
return "bg-status-neutral-bg text-status-neutral-fg"
case "不行":
return "bg-status-negative-bg text-status-negative-fg"
default:
return "bg-muted text-muted-foreground"
}
}
---
<div class="min-h-screen bg-background dark:bg-surface-subtle">
<SiteHeader />
<main class="pt-24 pb-24">
<div class="max-w-7xl mx-auto px-4 md:px-8">
<div class="flex flex-col md:flex-row md:gap-10">
{/* ========================= Sidebar ========================= */}
<aside class="md:w-52 md:shrink-0 md:sticky md:top-24 md:self-start md:pr-2 mb-8 md:mb-0">
<div class="flex items-center gap-3 mb-8">
<div class="w-1 h-8 bg-primary rounded-full" />
<div>
<h1 class="text-2xl font-bold font-serif leading-none text-foreground">书架</h1>
<div class="text-[10px] text-muted-foreground tracking-[0.22em] mt-1.5">BOOKSHELF</div>
</div>
</div>
<div class="text-[10px] text-muted-foreground/80 uppercase tracking-[0.18em] mb-2 pl-2">馆藏</div>
{/* 漫画和论文两个分类已移除,只剩书籍;这里改成页内锚点,不再是 tab 切换 */}
<nav class="flex flex-row md:flex-col gap-1 mb-6 md:mb-8 overflow-x-auto md:overflow-visible -mx-1 px-1">
{[
{ href: "#reading", label: "在读", count: readingCount, svg: "reading" },
{ href: "#books", label: "书籍", count: bookStats.total, svg: "books" },
].map((it) => (
<a
href={it.href}
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors whitespace-nowrap text-foreground/80 hover:bg-muted/60 hover:text-foreground"
>
<span class="inline-flex h-4 w-4 items-center justify-center shrink-0">
{it.svg === "reading" && (
<svg viewBox="0 0 24 24" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 7v5l3 2" />
<path d="M3 5.5A2.5 2.5 0 0 1 5.5 3H10a2 2 0 0 1 2 2v14a1.5 1.5 0 0 0-1.5-1.5h-5A2.5 2.5 0 0 1 3 15z" />
<path d="M21 5.5A2.5 2.5 0 0 0 18.5 3H14a2 2 0 0 0-2 2v14a1.5 1.5 0 0 1 1.5-1.5h5A2.5 2.5 0 0 0 21 15z" />
</svg>
)}
{it.svg === "books" && (
<svg viewBox="0 0 24 24" class="h-4 w-4" fill="currentColor">
<path d="M4 5.5A2.5 2.5 0 0 1 6.5 3H20v16.5a1.5 1.5 0 0 1-1.5 1.5H6.5A2.5 2.5 0 0 1 4 18.5zM6.5 4A1.5 1.5 0 0 0 5 5.5v13A1.5 1.5 0 0 0 6.5 20H18a.5.5 0 0 0 .5-.5V4z" />
</svg>
)}
</span>
<span class="flex-1 font-medium text-left">{it.label}</span>
<span class="text-[11px] opacity-60 tabular-nums">{it.count}</span>
</a>
))}
</nav>
</aside>
{/* ========================= Main ========================= */}
<section class="flex-1 min-w-0" data-bookshelf-main>
{/* ========================= 最近在读(微信读书) =========================
数据在构建期由 scripts/fetch-reading.mjs 抓到 src/data/reading.json,
这里只负责渲染,页面本身不发任何请求。 */}
<div id="reading" class="mb-16 scroll-mt-24">
<div class="flex flex-wrap items-end justify-between gap-x-6 gap-y-2 mb-6">
<div>
<div class="text-[11px] text-primary tracking-[0.22em] font-semibold uppercase mb-1.5">Collection · 01</div>
<h2 class="text-3xl md:text-4xl font-bold font-serif tracking-tight text-foreground mb-1.5">最近在读</h2>
<div class="text-sm italic font-serif text-muted-foreground">翻开的书,都还热着</div>
</div>
{reading ? (
<p class="text-xs text-muted-foreground tabular-nums">
来自微信读书 · 更新于 {formatReadingDate(reading.updatedAt)}
</p>
) : null}
</div>
{inProgressReading.length > 0 ? (
<div class="grid gap-4 sm:grid-cols-2">
{inProgressReading.map((book) => (
<a
href={book.deepLink || undefined}
target={book.deepLink ? "_blank" : undefined}
rel={book.deepLink ? "noopener noreferrer" : undefined}
class="group flex gap-4 rounded-xl border border-border/50 bg-card p-4 transition-shadow duration-300 hover:shadow-lg"
>
<div class="shrink-0 w-20 aspect-[3/4] rounded-lg overflow-hidden bg-surface-subtle">
{book.cover ? (
<img
src={book.cover}
alt={book.title}
loading="lazy"
decoding="async"
referrerpolicy="no-referrer"
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
) : (
<div class="flex h-full w-full items-center justify-center p-2 bg-gradient-to-br from-primary/30 via-primary/20 to-primary/5">
<p class="font-semibold text-[10px] text-foreground text-center line-clamp-4">{book.title}</p>
</div>
)}
</div>
<div class="flex-1 min-w-0 flex flex-col">
<h3 class="font-medium text-sm text-foreground line-clamp-2 leading-snug mb-1 group-hover:text-primary transition-colors">
{book.title}
</h3>
<p class="text-xs text-muted-foreground line-clamp-1 mb-3">
{[book.author, book.category].filter(Boolean).join(" · ")}
</p>
<div class="mt-auto space-y-1.5">
<div class="flex items-center gap-2">
<div class="flex-1 h-1.5 rounded-full bg-secondary overflow-hidden">
<div class="h-full rounded-full bg-primary" style={`width: ${book.progress ?? 0}%`} />
</div>
<span
class={cn(
"text-[11px] tabular-nums shrink-0",
book.finished ? "text-status-positive-fg font-medium" : "text-muted-foreground",
)}
>
{book.finished ? "已读完" : !book.progress ? "还没开始" : `${book.progress}%`}
</span>
</div>
<p class="text-[11px] text-muted-foreground/80 tabular-nums">
最近阅读 {formatReadingDate(book.lastReadAt)}
</p>
</div>
</div>
</a>
))}
</div>
) : (
<div class="py-12 text-center border border-dashed border-border rounded-xl">
<p class="text-muted-foreground mb-2">暂时读不到微信读书的数据。</p>
<p class="text-xs text-muted-foreground/70">
在项目根目录跑一次 <code class="px-1 py-0.5 rounded bg-muted font-mono">pnpm reading</code> 刷新。
</p>
</div>
)}
</div>
{/* ---------- Header ---------- */}
<div id="books" class="mb-6 scroll-mt-24">
<div class="text-[11px] text-primary tracking-[0.22em] font-semibold uppercase mb-1.5">Collection · 02</div>
<h2 class="text-3xl md:text-4xl font-bold font-serif tracking-tight text-foreground mb-1.5">书籍</h2>
<div class="text-sm italic font-serif text-muted-foreground">博学而笃志,切问而近思</div>
</div>
{/* ---------- Stats ---------- */}
<div class="flex flex-wrap gap-x-7 gap-y-2 text-sm text-muted-foreground mb-7">
<span class="flex items-baseline gap-2"><span class="text-primary font-semibold text-base font-serif tabular-nums">{bookStats.total}</span><span>本已读</span></span>
<span class="flex items-baseline gap-2"><span class="text-primary font-semibold text-base font-serif tabular-nums">{bookStats.recommended}</span><span>本推荐</span></span>
<span class="flex items-baseline gap-2"><span class="text-primary font-semibold text-base font-serif tabular-nums">{bookStats.categories}</span><span>个领域</span></span>
</div>
{/* ---------- Search + filters ---------- */}
<div class="space-y-3 mb-8">
<div class="relative max-w-sm">
<span class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none">
<svg viewBox="0 0 24 24" class="h-4 w-4"><path fill="currentColor" d="M10 4a6 6 0 1 0 3.5 10.8l4.9 4.9 1.4-1.4-4.9-4.9A6 6 0 0 0 10 4m0 2a4 4 0 1 1 0 8 4 4 0 0 1 0-8" /></svg>
</span>
<input
data-bookshelf-search
type="text"
placeholder="搜索书名或作者..."
class="w-full pl-10 pr-4 py-2 bg-background border border-border rounded-lg text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/50 transition-all"
/>
</div>
{/* Books filters */}
<div class="flex flex-wrap items-center gap-x-6 gap-y-3">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-[10px] text-muted-foreground uppercase tracking-[0.18em]">领域</span>
{bookCategories.map((cat) => (
<button
type="button"
data-filter-books-category={cat}
data-active-class="bg-primary text-primary-foreground"
data-inactive-class="text-muted-foreground hover:text-foreground hover:bg-muted/50"
class={cn(
"px-3 py-1.5 rounded-md text-xs transition-all",
cat === "全部" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground hover:bg-muted/50",
)}
>{cat}</button>
))}
</div>
<div class="h-4 w-px bg-border hidden sm:block" />
<div class="flex items-center gap-2 flex-wrap">
<span class="text-[10px] text-muted-foreground uppercase tracking-[0.18em]">评价</span>
{bookStatuses.map((s) => (
<button
type="button"
data-filter-books-status={s}
data-active-class="bg-primary text-primary-foreground"
data-inactive-class="text-muted-foreground hover:text-foreground hover:bg-muted/50"
class={cn(
"px-3 py-1.5 rounded-md text-xs transition-all",
s === "全部" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground hover:bg-muted/50",
)}
>{s}</button>
))}
</div>
</div>
</div>
{/* ========================= Views ========================= */}
{/* ---- Books ---- */}
<div data-bookshelf-fade class="space-y-12">
{Object.entries(booksByCategory).map(([category, books]) => (
<section data-books-section={category}>
<div class="flex items-center gap-3 mb-5">
<h3 class="text-base font-semibold text-foreground">{category}</h3>
<span class="text-xs text-muted-foreground" data-books-count>({books.length})</span>
<div class="flex-1 h-px bg-border/60" />
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-5">
{books.map((book) => {
const badgeClass = recommendBadge(book.fields.推荐状态)
const coverUrl = getBookCover(book)
const authors = book.fields.作者?.join(", ") ?? ""
return (
<button
type="button"
data-book-card
data-book-id={book.id}
data-category={book.fields.领域 || "未分类"}
data-status={book.fields.推荐状态 || ""}
data-title={book.fields.书名.toLowerCase()}
data-authors={authors.toLowerCase()}
class="group w-full cursor-pointer text-left focus:outline-none"
>
<div class="relative aspect-[3/4] rounded-lg overflow-hidden mb-3 bg-surface-subtle shadow-sm group-hover:shadow-xl transition-shadow duration-300">
{coverUrl ? (
<img
src={coverUrl}
alt={book.fields.书名}
loading="lazy"
decoding="async"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
) : (
<div class="flex h-full w-full items-center justify-center p-3 bg-gradient-to-br from-primary/30 via-primary/20 to-primary/5">
<p class="font-semibold text-xs text-foreground text-center line-clamp-4">{book.fields.书名}</p>
</div>
)}
{book.fields.推荐状态 && (
<div class={cn("absolute top-2 right-2 w-6 h-6 rounded-full flex items-center justify-center", badgeClass)}>
<span class="text-xs font-semibold" aria-hidden="true">★</span>
</div>
)}
<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-end p-3">
{book.fields.书评 ? (
<p class="text-white/90 text-xs line-clamp-4 leading-relaxed">{book.fields.书评}</p>
) : book.fields.书籍简介 ? (
<p class="text-white/80 text-xs line-clamp-4 leading-relaxed">{book.fields.书籍简介}</p>
) : null}
</div>
</div>
<div class="min-h-[3.5rem] space-y-1">
<h4 class="font-medium text-sm text-foreground line-clamp-2 group-hover:text-primary transition-colors leading-snug">
{book.fields.书名}
</h4>
{book.fields.作者 ? (
<p class="text-xs text-muted-foreground line-clamp-1">{book.fields.作者.join(", ")}</p>
) : (
<p class="text-xs text-muted-foreground" aria-hidden="true">&nbsp;</p>
)}
</div>
</button>
)
})}
</div>
</section>
))}
{/* 微信读书里已读完的书,并入「书籍」区。
卡片结构和上面的书一样,但没有书评/简介,所以点击直接跳微信读书,不弹窗。
data-weread-card 让搜索框和领域/评价筛选同样作用于这一组。 */}
{finishedReading.length > 0 && (
<section data-books-section={WEREAD_CATEGORY}>
<div class="flex items-center gap-3 mb-5">
<h3 class="text-base font-semibold text-foreground">微信读书 · 已读完</h3>
<span class="text-xs text-muted-foreground" data-books-count>({finishedReading.length})</span>
<div class="flex-1 h-px bg-border/60" />
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-5">
{finishedReading.map((book) => (
<a
href={book.deepLink || undefined}
target={book.deepLink ? "_blank" : undefined}
rel={book.deepLink ? "noopener noreferrer" : undefined}
data-weread-card
data-category={WEREAD_CATEGORY}
data-title={book.title.toLowerCase()}
data-authors={book.author.toLowerCase()}
class="group block cursor-pointer focus:outline-none"
>
<div class="relative aspect-[3/4] rounded-lg overflow-hidden mb-3 bg-surface-subtle shadow-sm group-hover:shadow-xl transition-shadow duration-300">
{book.cover ? (
<img
src={book.cover}
alt={book.title}
loading="lazy"
decoding="async"
referrerpolicy="no-referrer"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
) : (
<div class="flex h-full w-full items-center justify-center p-3 bg-gradient-to-br from-primary/30 via-primary/20 to-primary/5">
<p class="font-semibold text-xs text-foreground text-center line-clamp-4">{book.title}</p>
</div>
)}
<span class="absolute top-2 right-2 text-[10px] px-2 py-0.5 rounded-full bg-status-positive-bg text-status-positive-fg font-medium">
已读完
</span>
</div>
<div class="min-h-[3.5rem] space-y-1">
<h4 class="font-medium text-sm text-foreground line-clamp-2 group-hover:text-primary transition-colors leading-snug">
{book.title}
</h4>
<p class="text-xs text-muted-foreground line-clamp-1">
{[book.author, book.category].filter(Boolean).join(" · ")}
</p>
</div>
</a>
))}
</div>
</section>
)}
</div>
{/* Empty state (shared, shown when filters return nothing) */}
<div data-empty-state class="hidden text-center py-16">
<span class="inline-flex h-12 w-12 items-center justify-center text-muted-foreground/30 mx-auto mb-4">
<svg viewBox="0 0 24 24" class="h-12 w-12"><path fill="currentColor" d="M4 5.5A2.5 2.5 0 0 1 6.5 3H20v16.5a1.5 1.5 0 0 1-1.5 1.5H6.5A2.5 2.5 0 0 1 4 18.5zM6.5 4A1.5 1.5 0 0 0 5 5.5v13A1.5 1.5 0 0 0 6.5 20H18a.5.5 0 0 0 .5-.5V4z" /></svg>
</span>
<p class="text-muted-foreground">没有找到匹配的内容</p>
</div>
</section>
</div>
</div>
</main>
<Footer />
{/* ====== Book modal ====== */}
<div
class="fixed inset-0 z-[60] hidden flex items-center justify-center px-4"
data-bookshelf-modal
aria-hidden="true"
>
<div class="absolute inset-0 bg-black/50 opacity-0 transition-opacity duration-200" data-bookshelf-backdrop></div>
<div
class="relative w-full max-w-2xl max-h-[85vh] overflow-hidden flex flex-col bg-background border border-border rounded-2xl shadow-2xl opacity-0 scale-95 transition-all duration-200"
data-bookshelf-panel
>
<button type="button" class="absolute right-4 top-4 text-muted-foreground hover:text-foreground" data-bookshelf-close aria-label="关闭">
<svg viewBox="0 0 24 24" class="h-5 w-5"><path fill="currentColor" d="M18.3 5.7 12 12l6.3 6.3-1.4 1.4L10.6 13.4 4.3 19.7 2.9 18.3 9.2 12 2.9 5.7 4.3 4.3l6.3 6.3 6.3-6.3z" /></svg>
</button>
<div class="flex gap-5 p-6">
<div class="shrink-0 w-32 aspect-[3/4] rounded-lg overflow-hidden bg-surface-subtle" data-modal-cover-wrap>
<img data-modal-cover alt="" decoding="async" class="w-full h-full object-cover hidden" />
<div class="flex h-full w-full items-center justify-center p-3 bg-gradient-to-br from-primary/30 via-primary/20 to-primary/5" data-modal-cover-fallback>
<p class="font-semibold text-xs text-foreground text-center" data-modal-cover-title></p>
</div>
</div>
<div class="flex-1 min-w-0">
<h2 class="text-xl font-bold text-foreground mb-2" data-modal-title></h2>
<p class="text-sm text-muted-foreground mb-3" data-modal-authors></p>
<div class="flex flex-wrap gap-2">
<span class="px-2 py-1 text-xs rounded-md bg-muted text-muted-foreground" data-modal-category></span>
<span class="px-2 py-1 text-xs rounded-md" data-modal-status></span>
<span class="px-2 py-1 text-xs rounded-md bg-muted text-muted-foreground flex items-center gap-1" data-modal-finished>
<span class="inline-flex h-3 w-3 items-center justify-center">
<svg viewBox="0 0 24 24" class="h-3 w-3"><path fill="currentColor" d="M7 2h2v2h6V2h2v2h3v18H4V4h3zm11 6H6v12h12z" /></svg>
</span>
<span data-modal-finished-text></span>
</span>
</div>
</div>
</div>
<div class="flex-1 overflow-y-auto space-y-4 px-6 pb-6" data-modal-content>
<div data-modal-review-section>
<h3 class="text-sm font-medium text-foreground mb-2">我的书评</h3>
<p class="text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap" data-modal-review></p>
</div>
<div data-modal-desc-section>
<h3 class="text-sm font-medium text-foreground mb-2">书籍简介</h3>
<p class="text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap" data-modal-desc></p>
</div>
</div>
</div>
</div>
{/* ====== Styles ====== */}
<style is:global>
@keyframes bookshelfFadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: none; }
}
[data-bookshelf-fade] {
animation: bookshelfFadeIn 200ms ease-out both;
}
@media (prefers-reduced-motion: reduce) {
[data-bookshelf-fade] { animation: none; }
}
</style>
{/* ====== Client script ====== */}
<script is:inline type="application/json" id="books-data" set:html={JSON.stringify(booksForClient).replace(/</g, "\\u003c")} />
<script is:inline data-astro-rerun>
(function () {
if (window.__bookshelfCleanup) { window.__bookshelfCleanup(); window.__bookshelfCleanup = null; }
const getJSON = (id) => {
const el = document.getElementById(id);
try { return JSON.parse(el?.textContent || "[]"); } catch { return []; }
};
const books = getJSON("books-data");
const bookMap = new Map(books.map((b) => [b.id, b]));
const searchInput = document.querySelector("[data-bookshelf-search]");
const emptyState = document.querySelector("[data-empty-state]");
const state = {
query: "",
books: { category: "全部", status: "全部" },
};
const cleanups = [];
const on = (el, ev, handler) => { el.addEventListener(ev, handler); cleanups.push(() => el.removeEventListener(ev, handler)); };
const setButtonState = (button, isActive) => {
const activeClass = (button.getAttribute("data-active-class") || "").split(" ").filter(Boolean);
const inactiveClass = (button.getAttribute("data-inactive-class") || "").split(" ").filter(Boolean);
activeClass.forEach((cls) => button.classList.toggle(cls, isActive));
inactiveClass.forEach((cls) => button.classList.toggle(cls, !isActive));
};
const applyBooks = () => {
const q = state.query.trim().toLowerCase();
const cards = document.querySelectorAll("[data-book-card], [data-weread-card]");
let totalVisible = 0;
cards.forEach((card) => {
const cat = card.getAttribute("data-category") || "";
const st = card.getAttribute("data-status") || "";
const title = card.getAttribute("data-title") || "";
const authors = card.getAttribute("data-authors") || "";
const mc = state.books.category === "全部" || cat === state.books.category;
const ms = state.books.status === "全部" || st === state.books.status;
const mq = !q || title.includes(q) || authors.includes(q);
const show = mc && ms && mq;
card.classList.toggle("hidden", !show);
if (show) totalVisible++;
});
document.querySelectorAll("[data-books-section]").forEach((section) => {
const visible = Array.from(section.querySelectorAll("[data-book-card], [data-weread-card]")).filter((c) => !c.classList.contains("hidden")).length;
const countEl = section.querySelector("[data-books-count]");
if (countEl) countEl.textContent = `(${visible})`;
section.classList.toggle("hidden", visible === 0);
});
return totalVisible;
};
const applyAll = () => {
const visible = applyBooks();
if (emptyState) emptyState.classList.toggle("hidden", visible !== 0);
};
// Filter buttons wiring
const wireFilterGroup = (selector, onPick) => {
const btns = Array.from(document.querySelectorAll(selector));
btns.forEach((b) => on(b, "click", () => {
btns.forEach((x) => setButtonState(x, x === b));
onPick(b);
applyAll();
}));
};
wireFilterGroup("[data-filter-books-category]", (b) => { state.books.category = b.getAttribute("data-filter-books-category") || "全部"; });
wireFilterGroup("[data-filter-books-status]", (b) => { state.books.status = b.getAttribute("data-filter-books-status") || "全部"; });
if (searchInput) {
let searchTimer = 0;
on(searchInput, "input", (e) => {
state.query = e.target.value || "";
window.clearTimeout(searchTimer);
searchTimer = window.setTimeout(applyAll, 80);
});
cleanups.push(() => window.clearTimeout(searchTimer));
}
// -------- Book modal --------
const modal = document.querySelector("[data-bookshelf-modal]");
const modalBackdrop = document.querySelector("[data-bookshelf-backdrop]");
const modalPanel = document.querySelector("[data-bookshelf-panel]");
const modalClose = document.querySelector("[data-bookshelf-close]");
const modalCover = document.querySelector("[data-modal-cover]");
const modalCoverFallback = document.querySelector("[data-modal-cover-fallback]");
const modalCoverTitle = document.querySelector("[data-modal-cover-title]");
const modalTitle = document.querySelector("[data-modal-title]");
const modalAuthors = document.querySelector("[data-modal-authors]");
const modalCategory = document.querySelector("[data-modal-category]");
const modalStatus = document.querySelector("[data-modal-status]");
const modalFinished = document.querySelector("[data-modal-finished]");
const modalFinishedText = document.querySelector("[data-modal-finished-text]");
const modalReviewSection = document.querySelector("[data-modal-review-section]");
const modalReview = document.querySelector("[data-modal-review]");
const modalDescSection = document.querySelector("[data-modal-desc-section]");
const modalDesc = document.querySelector("[data-modal-desc]");
const STATUS_CLS = [
"bg-status-positive-bg", "text-status-positive-fg",
"bg-status-neutral-bg", "text-status-neutral-fg",
"bg-status-negative-bg", "text-status-negative-fg",
];
const openBookModal = (book) => {
if (!modal) return;
modal.classList.remove("hidden");
requestAnimationFrame(() => {
modalBackdrop && modalBackdrop.classList.add("opacity-100");
modalPanel && modalPanel.classList.add("opacity-100", "scale-100");
});
modal.setAttribute("aria-hidden", "false");
document.body.classList.add("overflow-hidden");
if (modalTitle) modalTitle.textContent = book.title || "";
if (modalAuthors) { modalAuthors.textContent = book.authors || ""; modalAuthors.classList.toggle("hidden", !book.authors); }
if (modalCategory) { modalCategory.textContent = book.category || "未分类"; modalCategory.classList.toggle("hidden", !book.category); }
if (modalStatus) {
modalStatus.textContent = book.status || "";
modalStatus.classList.toggle("hidden", !book.status);
STATUS_CLS.forEach((c) => modalStatus.classList.remove(c));
if (book.status === "推荐") modalStatus.classList.add("bg-status-positive-bg","text-status-positive-fg");
else if (book.status === "中庸") modalStatus.classList.add("bg-status-neutral-bg","text-status-neutral-fg");
else if (book.status === "不行") modalStatus.classList.add("bg-status-negative-bg","text-status-negative-fg");
}
if (modalFinished && modalFinishedText) {
modalFinishedText.textContent = book.finished || "";
modalFinished.classList.toggle("hidden", !book.finished);
}
if (modalCover && modalCoverFallback && modalCoverTitle) {
if (book.cover) {
modalCover.src = book.cover;
modalCover.classList.remove("hidden");
modalCoverFallback.classList.add("hidden");
} else {
modalCover.removeAttribute("src");
modalCover.classList.add("hidden");
modalCoverTitle.textContent = book.title || "";
modalCoverFallback.classList.remove("hidden");
}
}
if (modalReviewSection && modalReview) { modalReview.textContent = book.review || ""; modalReviewSection.classList.toggle("hidden", !book.review); }
if (modalDescSection && modalDesc) { modalDesc.textContent = book.description || ""; modalDescSection.classList.toggle("hidden", !book.description); }
};
const closeModal = () => {
if (!modal) return;
modalBackdrop && modalBackdrop.classList.remove("opacity-100");
modalPanel && modalPanel.classList.remove("opacity-100", "scale-100");
modal.setAttribute("aria-hidden", "true");
document.body.classList.remove("overflow-hidden");
setTimeout(() => { modal.classList.add("hidden"); }, 200);
};
document.querySelectorAll("[data-book-card]").forEach((card) => {
on(card, "click", () => {
const id = card.getAttribute("data-book-id");
if (!id) return;
const book = bookMap.get(id);
if (book) openBookModal(book);
});
});
if (modalClose) on(modalClose, "click", closeModal);
if (modalBackdrop) on(modalBackdrop, "click", closeModal);
on(document, "keydown", (e) => { if (e.key === "Escape") closeModal(); });
// Initial state
applyAll();
window.__bookshelfCleanup = () => {
cleanups.forEach((c) => c());
if (modal && !modal.classList.contains("hidden")) {
modal.classList.add("hidden");
modal.setAttribute("aria-hidden", "true");
document.body.classList.remove("overflow-hidden");
}
};
})();
</script>
</div>