置顶(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
175 lines
5.8 KiB
Plaintext
175 lines
5.8 KiB
Plaintext
---
|
||
// 书评索引画廊(/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>
|