Author SHA1 Message Date
wait4xandGLM-5.3 d096d45c58 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
2026-11-03 12:28:30 +08:00
wait4xandGLM-5.3 d6e08a29f9 feat(books): 预览页新增 D 方案(封面网格 × 抽书),C 改置顶默认展开
验证: 构建 53 页 exit 0;Playwright 实测 C/D 各自唯一展开项 = 置顶书,
      D 5 格 / 3 列 / 置顶角标 1、1280 与 390 零横向溢出

Spec: none (预览页随选型迭代,正式改动在 spec/2026-11-02_blog-5i-fonts-polish)

Co-authored-by: GLM-5.3
2026-11-03 12:15:50 +08:00
wait4xandGLM-5.3 d119d6b555 feat(books): 书架三案按视觉复核修订后更新预览页
长标题/作者裁切、卡片参差、4+1 孤儿位、抽书页无展开暗示等缺陷已修,
用户可直接在 /books-preview/ 看修订版。

Spec: none (预览页随选型迭代,正式改动在 spec/2026-11-02_blog-5i-fonts-polish)

Co-authored-by: GLM-5.3
2026-11-03 11:55:33 +08:00
wait4xandGLM-5.3 b8c5e1c86e feat(books): 书架三方案预览页先行上线(临时路由)
用户要「直接查看网页」来选方案,故本页单独先部署;其余改版(字体按需加载、
灯箱关闭与 caption 排版、评论误报、开源页动效)仍在验证分支,选定方案后一并
合并并删掉本临时路由。

Spec: none (预览页需先上线供用户选择,其余改动仍在 spec/2026-11-02_blog-5i-fonts-polish 验证)

Co-authored-by: GLM-5.3
2026-11-03 11:42:25 +08:00
wait4x 2526b98323 Update 页面文案 “home” 2026-11-03 02:41:48 +00:00
5 changed files with 210 additions and 138 deletions

No files matched your search

+2 -5
View File
@@ -1,6 +1,6 @@
tagline: tagline:
zh: 写作者 · 读者 · 快门按压者,拥抱开源。 zh: 拥抱开源,热爱开源
en: Writer · Reader · Shutter-presser — embracing open source. en: Embracing open source, loving open source.
latestPhotos: 4 latestPhotos: 4
perTrackLimit: 6 perTrackLimit: 6
sections: sections:
@@ -13,9 +13,6 @@ socials:
- label: GitHub - label: GitHub
url: https://github.com/wait4xx url: https://github.com/wait4xx
icon: github icon: github
- label: RSS
url: /rss.xml
icon: rss
- label: 邮件 - label: 邮件
url: mailto:X@alumni.nudt.edu.cn url: mailto:X@alumni.nudt.edu.cn
icon: email icon: email
+17 -2
View File
@@ -30,7 +30,8 @@
语义边界(刻意如此,别当 bug 报): 语义边界(刻意如此,别当 bug 报):
- **只影响列表顺序**:`pubDate` 不变、URL 不变、RSS 里的时间序列不变,订阅者不会看到时间错乱 - **只影响列表顺序**:`pubDate` 不变、URL 不变、RSS 里的时间序列不变,订阅者不会看到时间错乱
- 多篇同时置顶时,置顶组内部仍按日期倒序 - **支持多篇/多本同时置顶**:置顶组内部仍按日期倒序;书架页(/books)里每个置顶的书评都会排在前面并**默认展开**
- 书架页用「↑ + 横线」的回到顶部图标表示置顶(不写文字),与右侧展开箭头形状不同,不会混读
- 关掉开关立即恢复时间序,没有"冷却期" - 关掉开关立即恢复时间序,没有"冷却期"
## 瞬间(5h 起与文章同级) ## 瞬间(5h 起与文章同级)
@@ -39,12 +40,26 @@
- markdown:`**加粗**`、`*斜体*`、列表、`> 引用`、`` `行内代码` `` - markdown:`**加粗**`、`*斜体*`、列表、`> 引用`、`` `行内代码` ``
- 排版类 HTML:`<span class="c-blue font-kai">彩色 + 楷体</span>`(可用类清单见写作指南「文章排版类」一节,与文章完全同一份白名单) - 排版类 HTML:`<span class="c-blue font-kai">彩色 + 楷体</span>`(可用类清单见写作指南「文章排版类」一节,与文章完全同一份白名单)
- 仍守外壳灰阶纪律:彩色是**强调**不是装饰,一页 ≤3 处 - 彩色**不再限次**(原「≤3 处/页」已取消);仍守外壳灰阶:导航/页脚/按钮/背景不染色
**每条瞬间自带评论区**:时间线卡片下方有「评论」折叠条,读者点开才加载评论(不点开不为它付任何加载成本)。中文页与英文页共用同一条评论流,语言切换不会丢评论。 **每条瞬间自带评论区**:时间线卡片下方有「评论」折叠条,读者点开才加载评论(不点开不为它付任何加载成本)。中文页与英文页共用同一条评论流,语言切换不会丢评论。
**每条的浏览 / 评论数字是只读的**:读者点开评论区不会虚增浏览数(只有整页访问才计一次)。 **每条的浏览 / 评论数字是只读的**:读者点开评论区不会虚增浏览数(只有整页访问才计一次)。
## 照片说明文字(灯箱右侧)支持排版(5i 新增)
照片的「说明」(caption)现在支持 markdown 与排版类,与文章正文同一套:
```yaml
caption: 落日把海与城一齐烫成金箔,**这一帧等了二十分钟**
```
```html
<span class="c-blue">蓝色</span>、<span class="font-kai">楷体</span> 这些类都能用。
```
灯箱是深底,颜色类会自动取柔和对暗底的那一档,不会刺眼。EXIF 那几行是相机数据,不做排版。
## 照片上传流 ## 照片上传流
1. 图库照片 → New Entry:填标题(**必填**,空标题会让构建炸——实测坑)、选分组(street/landscape)、可写 alt 1. 图库照片 → New Entry:填标题(**必填**,空标题会让构建炸——实测坑)、选分组(street/landscape)、可写 alt
+2 -1
View File
@@ -16,6 +16,7 @@ if (MODE === 'skip') {
const SKIP_PREFIX = ['/admin/', '/photos-originals/']; // admin=CMS 路由;photos-originals=部署期 rsync 叠加层(不在 dist,另有部署链保障) const SKIP_PREFIX = ['/admin/', '/photos-originals/']; // admin=CMS 路由;photos-originals=部署期 rsync 叠加层(不在 dist,另有部署链保障)
const SKIP_FILE = new Set(['/404.html']); // Astro 404 页不参与配对 const SKIP_FILE = new Set(['/404.html']); // Astro 404 页不参与配对
function walk(dir, out = []) { function walk(dir, out = []) {
for (const e of readdirSync(dir)) { for (const e of readdirSync(dir)) {
const p = join(dir, e); const p = join(dir, e);
@@ -48,8 +49,8 @@ const fails = [];
const warns = []; const warns = [];
for (const f of files) { for (const f of files) {
const html = readFileSync(f, 'utf8');
const rel = f.slice(DIST.length) || '/'; const rel = f.slice(DIST.length) || '/';
const html = readFileSync(f, 'utf8');
// 站内 <a href>:仅根相对路径;#锚点/mailto/tel/外部/data: 全跳过 // 站内 <a href>:仅根相对路径;#锚点/mailto/tel/外部/data: 全跳过
for (const m of html.matchAll(/<a\s[^>]*href="([^"]*)"/g)) { for (const m of html.matchAll(/<a\s[^>]*href="([^"]*)"/g)) {
+188 -130
View File
@@ -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 { getCollection } from 'astro:content';
import { Image } from 'astro:assets';
import BaseLayout from '../../layouts/BaseLayout.astro'; import BaseLayout from '../../layouts/BaseLayout.astro';
import { t } from '../../i18n/ui'; import { t } from '../../i18n/ui';
import type { Locale } from '../../lib/locale'; import type { Locale } from '../../lib/locale';
import { sortPosts } from '../../lib/post-order'; 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 { locale = 'zh' } = Astro.props;
const books = sortPosts( 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 firstPara = (body: string | undefined): string => {
const line = (body ?? '').split(/\n\s*\n/).map((l) => l.trim()).find((l) => l && !l.startsWith('#') && !l.startsWith('---')) ?? ''; const line =
return line.replace(/[*_>`\[\]]/g, '').slice(0, 60); (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 = { const L = {
shelf: locale === 'zh' ? '书架' : 'Books', shelf: t('books_title', locale),
count: (n: number) => (locale === 'zh' ? `${n} 本` : `${n} books`), 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'}> <BaseLayout
<h1 class="page-title">{L.shelf}<span class="count mono">{L.count(books.length)}</span></h1> track="reading"
{years.map((year, yi) => ( title={`${L.shelf} · wait4x`}
<details class="year-group" open={yi === 0}> description={locale === 'zh' ? '读过的书与一句策展' : 'Books read, one line each'}
<summary class="y-summary"> >
<span class="y-label mono">{year}</span> <h1 class="page-title">
<span class="y-count mono">· {books.filter((b) => b.data.pubDate.getFullYear() === year).length} {locale === 'zh' ? '本' : 'books'}</span> {L.shelf}<span class="count mono">{L.count}</span>
</summary> {pinnedCount > 0 && <span class="pinned-count mono">{pinnedCount} {L.pinned}</span>}
<ul class="shelf"> </h1>
{books.filter((b) => b.data.pubDate.getFullYear() === year).map((b) => ( <p class="page-note">{L.note}</p>
<li>
<a <ul class="books">
class={`book ${locale === 'en' ? 'en' : ''}`} {items.map((b) => (
href={locale === 'en' ? `/en/archives/${b.id}/` : `/archives/${b.id}/`} <li>
style={`--mood:${b.data.mood ?? 'var(--ink-4)'}`} <details class="book" style={`--mood:${b.mood}`} open={b.pinned}>
> <summary class="row" aria-label={`${b.title}${b.author ? ' · ' + b.author : ''} — ${L.expand}`}>
{b.data.cover ? ( <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 <Image
src={b.data.cover} src={b.cover}
alt={`${b.data.title} 封面`} alt={`${b.title}${locale === 'zh' ? ' 封面' : ' cover'}`}
class="bk-cover" widths={[120, 240]}
widths={[96, 192]} sizes="120px"
sizes="96px"
loading="lazy" loading="lazy"
/> />
) : ( </span>
<i class="spine" aria-hidden="true"></i> )}
)} <div class="info">
<div class="bk-body"> {b.desc && <p>{b.desc}</p>}
<strong class="bk-title">{b.data.title}</strong> <a class="link" href={b.href}>{L.read}</a>
{b.data.bookAuthor && <span class="bk-author">{b.data.bookAuthor}</span>} </div>
{(b.data.description || firstPara(b.body)) && ( </div>
<span class="bk-desc">{b.data.description ?? firstPara(b.body)}</span> </details>
)} </li>
</div> ))}
</a> </ul>
</li>
))}
</ul>
</details>
))}
</BaseLayout> </BaseLayout>
<style> <style>
@@ -78,97 +132,101 @@ const L = {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: var(--space-3); gap: var(--space-3);
margin-top: var(--space-5);
} }
.count { font-size: 13px; color: var(--ink-3); } .count { font-size: 13px; color: var(--ink-3); }
.year-group { margin-top: var(--space-4); border-top: 1px solid var(--line); } .pinned-count { font-size: 11.5px; color: var(--ink-4); }
.y-summary { .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; display: flex;
align-items: baseline; align-items: center;
gap: 0.4rem; gap: var(--space-3);
padding: var(--space-2) 0; padding: 10px var(--space-3);
cursor: pointer; cursor: pointer;
list-style: none; list-style: none;
transition: background var(--dur) var(--ease);
} }
.y-summary::-webkit-details-marker { display: none; } .row::-webkit-details-marker { display: none; }
.y-summary::before { .row:hover { background: var(--bg-soft); }
content: '▸'; /* 情绪色脊:贯通行的窄条(不是表单色标) */
font-size: 11px; .bar {
color: var(--ink-4); width: 8px;
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;
align-self: stretch; align-self: stretch;
flex: none; margin: 2px 0;
border-right: 1px solid var(--paper-line); border-radius: 4px;
}
.spine {
flex: none;
width: 10px;
background: var(--mood); background: var(--mood);
border-radius: 10px 0 0 10px; flex: none;
} }
.bk-body { .title {
display: flex;
flex-direction: column;
gap: 3px;
padding: var(--space-3) var(--space-4);
min-width: 0;
}
.bk-title {
font-family: var(--font-kai); font-family: var(--font-kai);
font-size: 17px; font-size: 15.5px;
color: var(--paper-ink); color: var(--paper-ink);
line-height: 1.35; line-height: 1.4;
} /* 定宽列:作者起点不随书名长短漂移 */
.en .bk-title { font-family: var(--font-serif); } width: 11em;
.bk-author { flex: none;
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;
overflow: hidden; 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> </style>
+1
View File
@@ -113,6 +113,7 @@ const dict = {
// ===== F 车道:访问量小字(Artalk pv,组件内独立计数) ===== // ===== F 车道:访问量小字(Artalk pv,组件内独立计数) =====
pv_viewed: { zh: '人看过', en: 'views' }, pv_viewed: { zh: '人看过', en: 'views' },
pinned_label: { zh: '置顶', en: 'Pinned' }, pinned_label: { zh: '置顶', en: 'Pinned' },
books_title: { zh: '书架', en: 'Books' },
comment_count: { zh: '条评论', en: 'comments' }, comment_count: { zh: '条评论', en: 'comments' },
moment_comments: { zh: '评论', en: 'Comments' }, moment_comments: { zh: '评论', en: 'Comments' },
lb_hint: { zh: '滚轮缩放 · 双击放大 · Esc 关闭', en: 'Wheel to zoom · Double-click · Esc' }, lb_hint: { zh: '滚轮缩放 · 双击放大 · Esc 关闭', en: 'Wheel to zoom · Double-click · Esc' },