抓取范围 - 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 张卡片未受影响;领域筛选新增「微信读书」按钮
683 lines
34 KiB
Plaintext
683 lines
34 KiB
Plaintext
---
|
||
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"> </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>
|