置顶(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
217 lines
7.1 KiB
Plaintext
217 lines
7.1 KiB
Plaintext
---
|
||
// 首页(zh/en 共用):名片区 / 文章列表 / 最新影像 / 热力图 / 项目墙 / 心跳
|
||
// 两个 index.astro 只是薄壳(传 locale),区块逻辑与样式集中在这里,避免双份漂移。
|
||
import { Image } from 'astro:assets';
|
||
import { getCollection } from 'astro:content';
|
||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||
import IdCard from '../IdCard.astro';
|
||
import PostList from '../PostList.astro';
|
||
import ProjectWall from '../geek/ProjectWall.astro';
|
||
import Heatmap from '../geek/Heatmap.astro';
|
||
import SocialLinks from '../SocialLinks.astro';
|
||
import { t } from '../../i18n/ui';
|
||
import { sortPosts } from '../../lib/post-order';
|
||
import type { Locale } from '../../lib/locale';
|
||
import {
|
||
homeConfig,
|
||
homeTagline,
|
||
pageTypography,
|
||
renderMarkdown,
|
||
renderRich,
|
||
} from '../../lib/site-content';
|
||
import { fetchHeartbeat, agoZh } from '../../lib/heartbeat';
|
||
|
||
interface Props {
|
||
locale?: Locale;
|
||
}
|
||
const { locale = 'zh' } = Astro.props;
|
||
|
||
// 首页可配项(后台主页设置):副标题/影像张数/区块开关/每轨条数/项目墙条数/公告/轨细分
|
||
const cfg = await homeConfig(locale);
|
||
const subtitleHtml = cfg.subtitle ? await renderMarkdown(cfg.subtitle) : '';
|
||
const announcementHtml = cfg.announcement ? await renderRich(cfg.announcement) : '';
|
||
|
||
// 置顶优先 → 时间倒序(sortPosts 契约见 src/lib/post-order.ts)
|
||
const posts = sortPosts(
|
||
await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden')),
|
||
);
|
||
|
||
// en 轨列表标题吃 postsEn 译题(缺译回退 zh,与 /en/archives 同策略)
|
||
const enTitle = locale === 'en'
|
||
? Object.fromEntries((await getCollection('postsEn')).map((e) => [e.id, e.data.title]))
|
||
: undefined;
|
||
|
||
// 最新影像取集合前 N 张;照片时间序由照片管线治理,此处只裁切
|
||
const photos = (await getCollection('photos')).slice(0, Math.max(1, cfg.latestPhotos));
|
||
|
||
const tagline = await homeTagline(locale);
|
||
const taglineHtml = await renderRich(tagline, { inline: true });
|
||
const typo = await pageTypography('siteHome', 'home');
|
||
// 心跳只出现在中文首页(en 轨历史形态如此,保持零回归)
|
||
const heartbeat = locale === 'zh' ? await fetchHeartbeat() : null;
|
||
const galleryHref = locale === 'en' ? '/en/gallery/' : '/gallery/';
|
||
|
||
// 轨细分开关:只保留开启的轨道(全开时等价于不过滤,零回归)
|
||
const activeTracks = Object.entries(cfg.postsTracks)
|
||
.filter(([, on]) => on)
|
||
.map(([k]) => k);
|
||
const allTracksOn = activeTracks.length === Object.keys(cfg.postsTracks).length;
|
||
const visiblePosts = allTracksOn
|
||
? posts
|
||
: posts.filter((p) => activeTracks.includes(p.data.category));
|
||
---
|
||
|
||
<BaseLayout
|
||
title="wait4x"
|
||
description={locale === 'en' ? "wait4x's blog: reading · code · photography" : undefined}
|
||
>
|
||
<div class={typo.className || undefined} style={typo.style || undefined}>
|
||
<IdCard>
|
||
<p class="tagline rich" set:html={taglineHtml} />
|
||
{subtitleHtml && <div class="subtitle" set:html={subtitleHtml} />}
|
||
<SocialLinks />
|
||
</IdCard>
|
||
|
||
{announcementHtml && (
|
||
<div class="announcement rich" set:html={announcementHtml} aria-label={locale === 'en' ? 'Announcement' : '公告'} />
|
||
)}
|
||
|
||
{cfg.sections.tracks && (
|
||
<PostList
|
||
posts={visiblePosts}
|
||
locale={locale}
|
||
titleOverrides={enTitle}
|
||
perTrack={cfg.perTrackLimit}
|
||
/>
|
||
)}
|
||
|
||
{cfg.sections.photos && cfg.latestPhotos > 0 && (
|
||
<section class="latest" aria-label={t('latest_photos', locale)}>
|
||
<div class="latest-head">
|
||
<span class="lab">{t('latest_photos', locale)}</span>
|
||
<a class="tap more" href={galleryHref}>{t('enter_gallery', locale)}</a>
|
||
</div>
|
||
<div class="thumbs">
|
||
{photos.map((photo, i) => (
|
||
<a class="thumb" href={galleryHref} aria-label={`${t('latest_photos', locale)}: ${photo.data.title}`}>
|
||
{/* 首两图 eager(Astro Image 默认 lazy 会把 LCP 压到 6s+),首图再拔 fetchpriority */}
|
||
<Image
|
||
src={photo.data.image}
|
||
alt={photo.data.alt ?? photo.data.title}
|
||
widths={[180, 360]}
|
||
sizes="(min-width: 820px) 20vw, 42vw"
|
||
loading={i < 2 ? 'eager' : 'lazy'}
|
||
fetchpriority={i === 0 ? 'high' : undefined}
|
||
/>
|
||
</a>
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{cfg.sections.heatmap && (
|
||
<Heatmap
|
||
labels={{
|
||
title: t('heatmap_title', locale),
|
||
summary: t('heatmap_summary', locale),
|
||
less: t('heatmap_less', locale),
|
||
more: t('heatmap_more', locale),
|
||
note: t('heatmap_note', locale),
|
||
}}
|
||
/>
|
||
)}
|
||
{cfg.sections.projects && <ProjectWall limit={cfg.projectsLimit} />}
|
||
{heartbeat && (
|
||
<p class="heartbeat" aria-label="最近心跳">
|
||
<span class="beat-dot" aria-hidden="true"></span>
|
||
最近心跳:{agoZh(heartbeat.when)} · {heartbeat.msg}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</BaseLayout>
|
||
|
||
<style>
|
||
.subtitle {
|
||
font-size: 13.5px;
|
||
color: var(--ink-4);
|
||
margin-top: 2px;
|
||
letter-spacing: 0.01em;
|
||
}
|
||
.subtitle :global(p) { margin: 0; }
|
||
.subtitle :global(a) { color: var(--accent); }
|
||
.tagline {
|
||
font-size: 14px;
|
||
color: var(--ink-3);
|
||
margin-top: 4px;
|
||
letter-spacing: 0.01em;
|
||
}
|
||
/* 公告块:独立于区块开关,空则不渲染(markdown 富文本,左缘细线做提示) */
|
||
.announcement {
|
||
margin-top: var(--space-4);
|
||
padding: var(--space-2) 0 var(--space-2) var(--space-3);
|
||
border-left: 2px solid var(--line);
|
||
font-size: 14px;
|
||
line-height: 1.7;
|
||
color: var(--ink-2);
|
||
}
|
||
.announcement :global(p) { margin: 0; }
|
||
.announcement :global(p + p) { margin-top: 0.6em; }
|
||
.announcement :global(a) { color: var(--accent); }
|
||
.heartbeat {
|
||
font-family: var(--font-mono);
|
||
font-size: 12.5px;
|
||
color: var(--ink-3);
|
||
margin-top: var(--space-2);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.beat-dot {
|
||
width: 7px; height: 7px; border-radius: 50%;
|
||
background: var(--ink-2);
|
||
animation: beat 2.4s ease-in-out infinite;
|
||
}
|
||
@keyframes beat { 0%, 100% { opacity: 0.35; } 50% { opacity: 1; } }
|
||
@media (prefers-reduced-motion: reduce) { .beat-dot { animation: none; } }
|
||
.latest { margin-top: var(--space-6); }
|
||
.latest-head {
|
||
display: flex;
|
||
align-items: baseline;
|
||
justify-content: space-between;
|
||
gap: var(--space-3);
|
||
}
|
||
.lab {
|
||
font-size: 12.5px;
|
||
color: var(--ink-4);
|
||
letter-spacing: 0.08em;
|
||
}
|
||
.more {
|
||
font-size: 13px;
|
||
color: var(--accent);
|
||
padding-inline: var(--space-2);
|
||
transition: color var(--dur) var(--ease);
|
||
}
|
||
.more:hover { color: var(--ink-1); }
|
||
.thumbs {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: var(--space-2);
|
||
margin-top: var(--space-3);
|
||
}
|
||
.thumb {
|
||
aspect-ratio: 1;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
}
|
||
.thumb :global(img) {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
transition: transform var(--dur) var(--ease);
|
||
}
|
||
.thumb:hover :global(img) { transform: scale(1.03); }
|
||
@media (min-width: 820px) {
|
||
.thumbs { grid-template-columns: repeat(4, 1fr); }
|
||
}
|
||
</style>
|