feat(books): 书架定稿方案 C——抽书阅读 + 多本置顶默认展开
用户拍板选 C,置顶标记改为向上箭头图标(不写文字),并确认支持多本置顶。
- 抽书列表:情绪色脊 + 定宽标题列 + 展开 chevron,details 原生展开看封面与简介
- 置顶:所有 pinned 书排最前且默认展开,标记换成 ↑+横线 图标
- 删除一次性预览路由 /books-preview/ 与其 lint 白名单
验证: 构建 exit 0;content-lint 53 页 0/0/0;Playwright 实测展开/箭头旋转/
作者列对齐/390 无溢出;两本临置顶实测均排前且同时展开
Spec: none (先行上线,完整 5i 包仍在 spec/2026-11-02_blog-5i-fonts-polish)
Co-authored-by: GLM-5.3
This commit is contained in:
1 parent
d6e08a29f9
commit
d096d45c58
5 files changed
+208
-679
No files matched your search
@@ -1,74 +1,128 @@
|
||||
---
|
||||
// 书评索引画廊(/books):色脊排版卡 × 年份分组规整网格
|
||||
// 预研方案 B:色脊天然安全(mood 深浅跨亮暗对比度风险回避),零 JS
|
||||
/**
|
||||
* 书评索引(/books):方案 C「抽书阅读」(用户 2026-11-03 拍板定稿)。
|
||||
*
|
||||
* 为什么是这一版:五个方案里它是唯一在「书少、封面少」时也不露怯的——
|
||||
* 一排放得下、长书名不裁、没有 4+1 孤儿位;点一行抽出封面与简介,靠原生
|
||||
* details 零 JS。
|
||||
*
|
||||
* 置顶契约(与文章同一套,见 src/lib/post-order.ts):
|
||||
* - frontmatter `pinned: true` → 排在最前(置顶优先 → 日期倒序)
|
||||
* - **支持多本置顶**:所有 pinned 书都默认展开,不限于一本
|
||||
* - 置顶标记用「↑ + 横线」的回到顶部图标,不写文字(用户要求);
|
||||
* 展开/收起是另一个形状的 chevron,避免两个箭头混读
|
||||
*/
|
||||
import { getCollection } from 'astro:content';
|
||||
import { Image } from 'astro:assets';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { t } from '../../i18n/ui';
|
||||
import type { Locale } from '../../lib/locale';
|
||||
import { sortPosts } from '../../lib/post-order';
|
||||
import { Image } from 'astro:assets';
|
||||
|
||||
interface Props { locale?: Locale }
|
||||
interface Props {
|
||||
locale?: Locale;
|
||||
}
|
||||
const { locale = 'zh' } = Astro.props;
|
||||
|
||||
const books = sortPosts(
|
||||
await getCollection('posts', ({ data }) => !data.draft && data.category === 'reading' && !data.tags.includes('hidden')),
|
||||
await getCollection(
|
||||
'posts',
|
||||
({ data }) => !data.draft && data.category === 'reading' && !data.tags.includes('hidden'),
|
||||
),
|
||||
);
|
||||
|
||||
// 回退契约(schema 注释声称而此前未实现——视觉复核抓到的缺口):
|
||||
// description 缺省取正文第一个非空段落(去 markdown 记号)前 60 字
|
||||
/**
|
||||
* description 回退契约:缺省取正文第一个非空段落(去 markdown 记号)前 60 字。
|
||||
* 与历史实现保持一致——书架卡不能出现空白简介。
|
||||
*/
|
||||
const firstPara = (body: string | undefined): string => {
|
||||
const line = (body ?? '').split(/\n\s*\n/).map((l) => l.trim()).find((l) => l && !l.startsWith('#') && !l.startsWith('---')) ?? '';
|
||||
return line.replace(/[*_>`\[\]]/g, '').slice(0, 60);
|
||||
const line =
|
||||
(body ?? '')
|
||||
.split(/\n\s*\n/)
|
||||
.map((l) => l.trim())
|
||||
.find((l) => l && !l.startsWith('#') && !l.startsWith('---')) ?? '';
|
||||
return line.replace(/[*_>`[\]]/g, '').slice(0, 60);
|
||||
};
|
||||
const years = [...new Set(books.map((b) => b.data.pubDate.getFullYear()))].sort((a, b) => b - a);
|
||||
|
||||
const items = books.map((b) => ({
|
||||
id: b.id,
|
||||
title: b.data.title,
|
||||
author: b.data.bookAuthor ?? '',
|
||||
desc: b.data.description ?? firstPara(b.body),
|
||||
cover: b.data.cover,
|
||||
mood: b.data.mood ?? 'var(--ink-4)',
|
||||
pinned: b.data.pinned === true,
|
||||
href: locale === 'en' ? `/en/archives/${b.id}/` : `/archives/${b.id}/`,
|
||||
}));
|
||||
const pinnedCount = items.filter((i) => i.pinned).length;
|
||||
const L = {
|
||||
shelf: locale === 'zh' ? '书架' : 'Books',
|
||||
count: (n: number) => (locale === 'zh' ? `${n} 本` : `${n} books`),
|
||||
shelf: t('books_title', locale),
|
||||
count: locale === 'zh' ? `${items.length} 本` : `${items.length} books`,
|
||||
note:
|
||||
locale === 'zh'
|
||||
? '点一行展开封面与简介 · 置顶的书默认展开'
|
||||
: 'Tap a row for the cover and blurb · pinned books start open',
|
||||
read: locale === 'zh' ? '读这篇书评 →' : 'Read the review →',
|
||||
pinned: t('pinned_label', locale),
|
||||
expand: locale === 'zh' ? '展开封面与简介' : 'Expand cover and blurb',
|
||||
};
|
||||
---
|
||||
|
||||
<BaseLayout track="reading" title={`${L.shelf} · wait4x`} description={locale === 'zh' ? '读过的书与一句策展' : 'Books read, one line each'}>
|
||||
<h1 class="page-title">{L.shelf}<span class="count mono">{L.count(books.length)}</span></h1>
|
||||
{years.map((year, yi) => (
|
||||
<details class="year-group" open={yi === 0}>
|
||||
<summary class="y-summary">
|
||||
<span class="y-label mono">{year}</span>
|
||||
<span class="y-count mono">· {books.filter((b) => b.data.pubDate.getFullYear() === year).length} {locale === 'zh' ? '本' : 'books'}</span>
|
||||
</summary>
|
||||
<ul class="shelf">
|
||||
{books.filter((b) => b.data.pubDate.getFullYear() === year).map((b) => (
|
||||
<li>
|
||||
<a
|
||||
class={`book ${locale === 'en' ? 'en' : ''}`}
|
||||
href={locale === 'en' ? `/en/archives/${b.id}/` : `/archives/${b.id}/`}
|
||||
style={`--mood:${b.data.mood ?? 'var(--ink-4)'}`}
|
||||
>
|
||||
{b.data.cover ? (
|
||||
<BaseLayout
|
||||
track="reading"
|
||||
title={`${L.shelf} · wait4x`}
|
||||
description={locale === 'zh' ? '读过的书与一句策展' : 'Books read, one line each'}
|
||||
>
|
||||
<h1 class="page-title">
|
||||
{L.shelf}<span class="count mono">{L.count}</span>
|
||||
{pinnedCount > 0 && <span class="pinned-count mono">{pinnedCount} {L.pinned}</span>}
|
||||
</h1>
|
||||
<p class="page-note">{L.note}</p>
|
||||
|
||||
<ul class="books">
|
||||
{items.map((b) => (
|
||||
<li>
|
||||
<details class="book" style={`--mood:${b.mood}`} open={b.pinned}>
|
||||
<summary class="row" aria-label={`${b.title}${b.author ? ' · ' + b.author : ''} — ${L.expand}`}>
|
||||
<span class="bar" aria-hidden="true"></span>
|
||||
<span class="title">{b.title}</span>
|
||||
{b.pinned && (
|
||||
<span class="pin" title={L.pinned} role="img" aria-label={L.pinned}>
|
||||
{/* 回到顶部图标(↑ + 横线):不写文字,形状与右侧 chevron 区分 */}
|
||||
<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">
|
||||
<path d="M8 12.6V4.2M4.4 7.6 8 4l3.6 3.6" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<path d="M3.6 13.6h8.8" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"></path>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
{b.author && <span class="author">{b.author}</span>}
|
||||
<span class="chev" aria-hidden="true">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14">
|
||||
<path d="M4 6.4 8 10.4l4-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="body">
|
||||
{b.cover && (
|
||||
<span class="cover">
|
||||
<Image
|
||||
src={b.data.cover}
|
||||
alt={`${b.data.title} 封面`}
|
||||
class="bk-cover"
|
||||
widths={[96, 192]}
|
||||
sizes="96px"
|
||||
src={b.cover}
|
||||
alt={`${b.title}${locale === 'zh' ? ' 封面' : ' cover'}`}
|
||||
widths={[120, 240]}
|
||||
sizes="120px"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<i class="spine" aria-hidden="true"></i>
|
||||
)}
|
||||
<div class="bk-body">
|
||||
<strong class="bk-title">{b.data.title}</strong>
|
||||
{b.data.bookAuthor && <span class="bk-author">{b.data.bookAuthor}</span>}
|
||||
{(b.data.description || firstPara(b.body)) && (
|
||||
<span class="bk-desc">{b.data.description ?? firstPara(b.body)}</span>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<div class="info">
|
||||
{b.desc && <p>{b.desc}</p>}
|
||||
<a class="link" href={b.href}>{L.read}</a>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
@@ -78,97 +132,101 @@ const L = {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
.count { font-size: 13px; color: var(--ink-3); }
|
||||
.year-group { margin-top: var(--space-4); border-top: 1px solid var(--line); }
|
||||
.y-summary {
|
||||
.pinned-count { font-size: 11.5px; color: var(--ink-4); }
|
||||
.page-note { font-size: 13px; color: var(--ink-4); margin-block: var(--space-1) var(--space-4); }
|
||||
|
||||
.books { list-style: none; padding: 0; margin: 0; display: grid; gap: 6px; }
|
||||
.book {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: border-color var(--dur) var(--ease);
|
||||
}
|
||||
.book[open] { border-color: color-mix(in oklab, var(--mood) 42%, var(--line)); }
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
padding: var(--space-2) 0;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: 10px var(--space-3);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
transition: background var(--dur) var(--ease);
|
||||
}
|
||||
.y-summary::-webkit-details-marker { display: none; }
|
||||
.y-summary::before {
|
||||
content: '▸';
|
||||
font-size: 11px;
|
||||
color: var(--ink-4);
|
||||
transition: transform var(--dur) var(--ease);
|
||||
transform-origin: 50% 50%;
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
}
|
||||
.year-group[open] > .y-summary::before { transform: rotate(90deg); }
|
||||
.y-summary:hover .y-label { color: var(--ink-2); }
|
||||
.y-count { font-size: 12px; color: var(--ink-4); }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.y-summary::before { transition: none; }
|
||||
}
|
||||
.y-label { font-size: 16px; color: var(--ink-3); margin-bottom: 0; transition: color var(--dur) var(--ease); }
|
||||
.shelf {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
/* 色脊卡:纸底 + mood 竖脊 + 文楷书名;hover 边框转 mood(.repo 同语言) */
|
||||
.book {
|
||||
display: flex;
|
||||
min-height: 120px;
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--paper-line);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
transition: border-color var(--dur) var(--ease), transform var(--dur) var(--ease);
|
||||
}
|
||||
.book:hover { border-color: var(--mood); transform: translateY(-2px); }
|
||||
/* 书封(content/posts/assets/books/<slug>.jpg):无图时回退色脊卡设计 */
|
||||
.bk-cover {
|
||||
width: 96px;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
.row::-webkit-details-marker { display: none; }
|
||||
.row:hover { background: var(--bg-soft); }
|
||||
/* 情绪色脊:贯通行的窄条(不是表单色标) */
|
||||
.bar {
|
||||
width: 8px;
|
||||
align-self: stretch;
|
||||
flex: none;
|
||||
border-right: 1px solid var(--paper-line);
|
||||
}
|
||||
.spine {
|
||||
flex: none;
|
||||
width: 10px;
|
||||
margin: 2px 0;
|
||||
border-radius: 4px;
|
||||
background: var(--mood);
|
||||
border-radius: 10px 0 0 10px;
|
||||
flex: none;
|
||||
}
|
||||
.bk-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
.bk-title {
|
||||
.title {
|
||||
font-family: var(--font-kai);
|
||||
font-size: 17px;
|
||||
font-size: 15.5px;
|
||||
color: var(--paper-ink);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.en .bk-title { font-family: var(--font-serif); }
|
||||
.bk-author {
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-3);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.bk-desc {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--ink-2);
|
||||
font-family: var(--font-sans);
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
line-height: 1.4;
|
||||
/* 定宽列:作者起点不随书名长短漂移 */
|
||||
width: 11em;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pin {
|
||||
display: inline-flex;
|
||||
color: color-mix(in oklab, var(--mood) 62%, var(--ink-3));
|
||||
flex: none;
|
||||
}
|
||||
.author { font-size: 12.5px; color: var(--ink-3); }
|
||||
.author::before { content: '·'; margin-right: 6px; opacity: 0.5; }
|
||||
.chev {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
color: var(--ink-4);
|
||||
transition: rotate var(--dur) var(--ease), color var(--dur) var(--ease);
|
||||
}
|
||||
.row:hover .chev { color: var(--ink-2); }
|
||||
/* 展开时 chevron 转成向上,和左侧「回到顶部」图标形状不同,不会混读 */
|
||||
details[open] > .row .chev { rotate: 180deg; }
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
border-top: 1px dashed var(--line);
|
||||
background: var(--bg-soft);
|
||||
}
|
||||
.cover { flex: none; width: 96px; border-radius: 4px; overflow: hidden; }
|
||||
.cover :global(img) { width: 100%; height: auto; display: block; }
|
||||
.info { min-width: 0; }
|
||||
.info p {
|
||||
font-size: 13.5px;
|
||||
line-height: 1.7;
|
||||
color: var(--ink-2);
|
||||
margin: 0 0 var(--space-2);
|
||||
max-width: var(--measure);
|
||||
}
|
||||
.link {
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: color-mix(in oklab, var(--accent) 35%, transparent);
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.title { width: auto; flex: 1 1 auto; min-width: 0; }
|
||||
.author { display: none; }
|
||||
.body { flex-direction: column; }
|
||||
.cover { width: 88px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.book, .row, .chev { transition: none; }
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user