Files
blog/src/components/pages/BooksPage.astro
T
wait4xandGLM-5.3 6538311387 feat(content): 置顶契约与计数徽标组件(5h T1 契约部分)
置顶(T1):
- src/lib/post-order.ts 新增 sortPosts:置顶优先 → pubDate 倒序 → id 兜底,
  把首页双轨/归档/书架四处排序口径收敛到一处
- postsSchema 增 pinned;PostList 三处渲染路径加灰阶 pin 小徽标;后台置顶开关
- 语义边界:只影响列表顺序,不改 pubDate/URL/RSS 时间语义

计数徽标(T4 契约组件):
- src/components/geek/ViewMeta.astro:眼睛 = 浏览数、气泡 = 评论数,
  本地 SVG + tokens 着色,不引第三方图片
- 数据只用 Artalk 匿名端点:GET comments?limit=1 一次拿到 count 与 page.pv;
  page 模式先 POST pages/pv 计一次浏览,readonly 模式零写入
- SSR 占位「–」:无 JS/接口失败不显示 0 误导读者

验证: pnpm astro build 48 页 exit 0;config-check 208 字段/65 hint 全过;
      sortPosts 四调用点替换后构建通过

Spec: 2026-11-02_blog-5h-content-social (T1 契约)

Co-authored-by: GLM-5.3
2026-11-02 20:02:08 +08:00

175 lines
5.8 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.
---
// 书评索引画廊(/books):色脊排版卡 × 年份分组规整网格
// 预研方案 B:色脊天然安全(mood 深浅跨亮暗对比度风险回避),零 JS
import { getCollection } from 'astro:content';
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 }
const { locale = 'zh' } = Astro.props;
const books = sortPosts(
await getCollection('posts', ({ data }) => !data.draft && data.category === 'reading' && !data.tags.includes('hidden')),
);
// 回退契约(schema 注释声称而此前未实现——视觉复核抓到的缺口):
// 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 years = [...new Set(books.map((b) => b.data.pubDate.getFullYear()))].sort((a, b) => b - a);
const L = {
shelf: locale === 'zh' ? '书架' : 'Books',
count: (n: number) => (locale === 'zh' ? `${n} 本` : `${n} books`),
};
---
<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 ? (
<Image
src={b.data.cover}
alt={`${b.data.title} 封面`}
class="bk-cover"
widths={[96, 192]}
sizes="96px"
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>
))}
</BaseLayout>
<style>
.page-title {
font-size: var(--text-h1);
color: var(--ink-1);
display: flex;
align-items: baseline;
gap: var(--space-3);
}
.count { font-size: 13px; color: var(--ink-3); }
.year-group { margin-top: var(--space-4); border-top: 1px solid var(--line); }
.y-summary {
display: flex;
align-items: baseline;
gap: 0.4rem;
padding: var(--space-2) 0;
cursor: pointer;
list-style: none;
}
.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;
align-self: stretch;
flex: none;
border-right: 1px solid var(--paper-line);
}
.spine {
flex: none;
width: 10px;
background: var(--mood);
border-radius: 10px 0 0 10px;
}
.bk-body {
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-size: 17px;
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;
overflow: hidden;
}
</style>