用户实测:「有的文章同时选择了置顶和隐藏,还会显示并置顶」。
根因:后台「隐藏」开关(hiddenTag)是个假开关——它只写 `hiddenTag: true` 这个
没人读的字段,而全站过滤只认 `tags` 里的 `hidden`。于是隐藏从未生效;
叠加置顶后,这篇还在列表最前面。
- 新增 src/lib/post-visibility.ts:可见性契约单一事实源——`!draft && !hidden &&
!tags.includes('hidden')`(两条通道都认,历史写法兼容)
- postsSchema 增真字段 `hidden`;七处消费点(首页双轨/归档 zh/en/书架/RSS zh/en/
文章页导航)统一走 isVisiblePost
- 后台假开关 hiddenTag 换成真开关 hidden,hint 写清语义:不进列表/RSS/sitemap,
页面本身仍生成(链接可分享);与置顶同时打开时以隐藏为准
- sitemap 过滤隐藏文章(astro.config 配置期扫 frontmatter,两种写法都认;
内容目录不可读时宁可不排除也不炸构建)
贡献数据去重(5j T2,回答用户「连续贡献是否实时」):
- 连续贡献不再构建期抓取 streak.svg 快照(不实时、暗色需滤镜、与热力图同源重复),
改为热力图同一份 GraphQL 日历本地计算(src/lib/heatmap.ts streakStats):
当前连续(以最后有贡献日为终点)/ 最长 / 总贡献,界面上标注「数据更新于 X」
- 贡献蛇块与自托管 snake/streak SVG 删除,fetch 脚本 ASSETS 清空,
build.sh 守卫改为退役说明;开源页数据板整块并入热力图区
验证: 构建 51 页 exit 0;content-lint 52 页 0/0/0;E2E 56/56;
临时两篇「置顶+隐藏」(新字段/旧 tags 各一)实测——首页/归档/书架/
双语首页/RSS 双语/sitemap 全部零命中,页面本身仍生成;
清理后归档首条恢复 writing-template(普通置顶不受影响);
开源页 snake/streak 资产零残留、连续统计条与更新时间标注就位
Spec: 2026-11-03_blog-5j-content-refine (T2 + 隐藏 bug 修复)
Co-authored-by: GLM-5.3
234 lines
8.1 KiB
Plaintext
234 lines
8.1 KiB
Plaintext
---
|
|
/**
|
|
* 书评索引(/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 { isVisiblePost } from '../../lib/post-visibility';
|
|
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 { pageNote } from '../../lib/site-content';
|
|
|
|
interface Props {
|
|
locale?: Locale;
|
|
}
|
|
const { locale = 'zh' } = Astro.props;
|
|
// 标题下说明:文字与样式都可后台改(白名单校验,非法值回落)
|
|
const note = await pageNote('books', locale);
|
|
|
|
const books = sortPosts(
|
|
await getCollection(
|
|
'posts',
|
|
({ data }) => isVisiblePost(data) && data.category === 'reading',
|
|
),
|
|
);
|
|
|
|
/**
|
|
* 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 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: t('books_title', locale),
|
|
count: locale === 'zh' ? `${items.length} 本` : `${items.length} books`,
|
|
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}</span>
|
|
{pinnedCount > 0 && <span class="pinned-count mono">{pinnedCount} {L.pinned}</span>}
|
|
</h1>
|
|
<!-- 标题下说明:由 content/site/pages.yaml 的 books.note/style 驱动 -->
|
|
<p class:list={['page-note', note.className]} style={note.style} set:html={note.html} />
|
|
|
|
<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.cover}
|
|
alt={`${b.title}${locale === 'zh' ? ' 封面' : ' cover'}`}
|
|
widths={[120, 240]}
|
|
sizes="120px"
|
|
loading="lazy"
|
|
/>
|
|
</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>
|
|
.page-title {
|
|
font-size: var(--text-h1);
|
|
color: var(--ink-1);
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: var(--space-3);
|
|
margin-top: var(--space-5);
|
|
}
|
|
.count { font-size: 13px; color: var(--ink-3); }
|
|
.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: center;
|
|
gap: var(--space-3);
|
|
padding: 10px var(--space-3);
|
|
cursor: pointer;
|
|
list-style: none;
|
|
transition: background var(--dur) var(--ease);
|
|
}
|
|
.row::-webkit-details-marker { display: none; }
|
|
.row:hover { background: var(--bg-soft); }
|
|
/* 情绪色脊:贯通行的窄条(不是表单色标) */
|
|
.bar {
|
|
width: 8px;
|
|
align-self: stretch;
|
|
margin: 2px 0;
|
|
border-radius: 4px;
|
|
background: var(--mood);
|
|
flex: none;
|
|
}
|
|
.title {
|
|
font-family: var(--font-kai);
|
|
font-size: 15.5px;
|
|
color: var(--paper-ink);
|
|
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>
|