feat(theme-home): 样式即数据 + 首页抽组件去重(5g T3/T4)
【T3 样式即数据】
- content/site/theme.yaml 九组白名单旋钮(accent 明暗两值 / radius_img /
text_h1-h3 / prose_size_reading|tech / leading_reading|tech /
space_3-6 / flow_p 与 flow_h2_post / dur);--measure、断点、纸墨、
code 配色按方案 §3.6 红线不开放
- src/lib/theme.ts:getTheme() 钳制 + 单调性校验(h1>h2>h3、
space3<4<5<6 任一不满足整组回落默认);themeVarsCss() 产出
`:root:root{…}` 与 `[data-theme="dark"]:root:root{…}`,靠特异性压过
tokens 的 :root,不依赖注入顺序
- BaseLayout head 内联 `<style is:inline set:html={themeStyle}>`:构建期
静态、运行时零 JS 零请求
- scripts/build-perf/contrast-check.mjs 读产物断言正文/强调 × 亮暗四项
≥4.5:1,不达标 exit 1;build.sh 守卫挂载([ -f ] 守卫先行)
实测:默认 12.40/10.53/6.30/7.06 全过;负例 accent #cccccc → 1.61:1 exit 1
两坑入档:tokens 的 --bg 是 3 位简写需归一化;选 tokens CSS 要用
`--bg:#` 定义断言(includes('--bg') 会误命中 var(--bg-soft) 引用)
- 后台 theme 单例 17 字段全中文 hint + 取值范围(分组约束写进 hint)
【T4 首页去重与细化】
- 新增 src/components/pages/HomePage.astro 承接全部区块(名片区富文本
tagline/subtitle/社交、公告、文章列表、最新影像、热力图、项目墙、
心跳),locale 一统;en 轨吃 postsEn 译题映射、图库链走 /en/gallery/,
心跳仍仅 zh(零回归)
- 两 index.astro 缩成 4 行薄壳,双份漂移隐患消除
- home.yaml 三字段:projectsLimit(1-99 夹取,缺省 6)、announcement
(双语 markdown 走 renderRich,空则不渲染)、postsTracks(三轨开关,
在 HomePage 内过滤而非改 PostList,守边界)
验证: 构建 48 页 exit 0;contrast-check 四项 PASS;E2E 37/37(快照口径);
实测 theme 变量内联、公告 markdown 粗体渲染、projectsLimit 限流、
photo 轨关闭生效;验收数据已还原
Spec: 2026-11-02_blog-5g-pages (T3/T4)
Co-authored-by: GLM-5.3
This commit is contained in:
1 parent
be87917443
commit
8debd99e61
12 files changed
+620
-308
No files matched your search
@@ -0,0 +1,214 @@
|
||||
---
|
||||
// 首页(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 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) : '';
|
||||
|
||||
const posts = (
|
||||
await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden'))
|
||||
).sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime());
|
||||
|
||||
// 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>
|
||||
@@ -184,6 +184,18 @@ export const collections = {
|
||||
latestPhotos: z.number().int().optional(),
|
||||
/** 首页每轨条数:4-8(越界由访问器夹取,缺省 6) */
|
||||
perTrackLimit: z.number().int().optional(),
|
||||
/** 首页项目墙条数:1-99(缺省 6;越界夹取) */
|
||||
projectsLimit: z.number().int().optional(),
|
||||
/** 首页公告(markdown 富文本,走富文本管线);空 = 不渲染 */
|
||||
announcement: bilingual.optional(),
|
||||
/** 首页轨细分开关:分开关阅读/技术/照片三轨(缺省全开) */
|
||||
postsTracks: z
|
||||
.object({
|
||||
reading: z.boolean().optional(),
|
||||
tech: z.boolean().optional(),
|
||||
photo: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
/** 区块开关:缺省全开 */
|
||||
sections: z
|
||||
.object({
|
||||
|
||||
@@ -18,6 +18,7 @@ import { siteFavicon } from '../lib/site-content';
|
||||
import SearchDialog from '../components/SearchDialog.astro';
|
||||
import BuildInfo from '../components/geek/BuildInfo.astro';
|
||||
import { ClientRouter } from 'astro:transitions';
|
||||
import { themeCss } from '../lib/theme';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@@ -36,6 +37,7 @@ const withSlash = (p: string) => (p.endsWith('/') ? p : `${p}/`);
|
||||
const favicon = await siteFavicon();
|
||||
// 自指 canonical(T2):SEO 权重收敛到当前页自身,与 hreflang 组合覆盖双轨
|
||||
const canonicalUrl = new URL(withSlash(rawPath), Astro.site).href;
|
||||
const themeStyle = await themeCss();
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
@@ -100,7 +102,9 @@ const canonicalUrl = new URL(withSlash(rawPath), Astro.site).href;
|
||||
<link rel="alternate" hreflang="zh" href={new URL(withSlash(zhPath), Astro.site)} />
|
||||
<link rel="alternate" hreflang="en" href={new URL(withSlash(enPath), Astro.site)} />
|
||||
<link rel="alternate" hreflang="x-default" href={new URL(withSlash(zhPath), Astro.site)} />
|
||||
</head>
|
||||
<!-- 样式即数据:theme.yaml 白名单旋钮(构建期内联,零运行时;:root:root 靠特异性压 tokens.css) -->
|
||||
<style is:inline set:html={themeStyle}></style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 背景辉光:跟随鼠标的 ink 色径向渐变(丙灰阶语言,3-4% 透明度;
|
||||
pointer:fine + 非 reduced-motion 才有 JS 驱动,其余静态隐藏) -->
|
||||
|
||||
@@ -223,9 +223,21 @@ export interface HomeSections {
|
||||
projects: boolean;
|
||||
}
|
||||
|
||||
export interface HomeTracks {
|
||||
reading: boolean;
|
||||
tech: boolean;
|
||||
photo: boolean;
|
||||
}
|
||||
|
||||
export interface HomeConfig {
|
||||
/** 副标题 markdown 原文;空串 = 不渲染 */
|
||||
subtitle: string;
|
||||
/** 公告 markdown 原文;空串 = 不渲染 */
|
||||
announcement: string;
|
||||
/** 首页项目墙条数(1-99,缺省 6) */
|
||||
projectsLimit: number;
|
||||
/** 轨细分开关(缺省全开) */
|
||||
postsTracks: HomeTracks;
|
||||
/** 最新影像张数:0 = 隐藏区块 */
|
||||
latestPhotos: number;
|
||||
/** 每轨文章条数 */
|
||||
@@ -244,6 +256,13 @@ export async function homeConfig(locale: Locale): Promise<HomeConfig> {
|
||||
const d = entry?.data;
|
||||
return {
|
||||
subtitle: pickBi(d?.subtitle, { zh: '', en: '' }, locale),
|
||||
announcement: pickBi(d?.announcement, { zh: '', en: '' }, locale),
|
||||
projectsLimit: clampInt(d?.projectsLimit, 1, 99, 6),
|
||||
postsTracks: {
|
||||
reading: d?.postsTracks?.reading ?? true,
|
||||
tech: d?.postsTracks?.tech ?? true,
|
||||
photo: d?.postsTracks?.photo ?? true,
|
||||
},
|
||||
latestPhotos: clampInt(d?.latestPhotos, 0, 6, 4),
|
||||
perTrackLimit: clampInt(d?.perTrackLimit, 4, 8, 6),
|
||||
sections: {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 样式即数据(层 4):把 content/site/theme.yaml 的白名单旋钮编译成 CSS 变量注入串。
|
||||
*
|
||||
* 三条红线(docs/research/10 方案 §3.6):
|
||||
* ① 只认 9 组白名单旋钮;--measure / 断点 / 纸墨 / code 配色一律不可配(牵动版面结构与分轨身份)。
|
||||
* ② 注入用 `:root:root{…}`(0,2,0)与 `[data-theme="dark"]:root:root{…}`(0,3,0)——
|
||||
* 必须靠特异性压过 tokens.css 的 `:root`(0,1,0),**不能靠 DOM 顺序**(link 与内联 style 的相对次序不可控)。
|
||||
* ③ 值全部钳制 + 单调性校验:越界只回落默认,绝不让构建失败(主题配错不该炸站)。
|
||||
*
|
||||
* 构建期静态:BaseLayout 直接把返回串内联进 <style>,运行时零 JS、零请求。
|
||||
*/
|
||||
import { getEntry } from 'astro:content';
|
||||
|
||||
/** 与 tokens.css 完全一致的默认值(零配置 = 现状) */
|
||||
const DEFAULTS = {
|
||||
accentLight: '#3454d1',
|
||||
accentDark: '#8ea2ff',
|
||||
radiusImg: 10,
|
||||
textH1: 27,
|
||||
textH2: 22,
|
||||
textH3: 19,
|
||||
proseReading: 20,
|
||||
proseTech: 19,
|
||||
leadingReading: 1.75,
|
||||
leadingTech: 1.7,
|
||||
space3: 16,
|
||||
space4: 24,
|
||||
space5: 40,
|
||||
space6: 64,
|
||||
flowP: 0.9,
|
||||
flowH2Post: 0.6,
|
||||
dur: 350,
|
||||
} as const;
|
||||
|
||||
const clamp = (v: unknown, min: number, max: number, fallback: number): number =>
|
||||
typeof v === 'number' && Number.isFinite(v) ? Math.min(max, Math.max(min, v)) : fallback;
|
||||
|
||||
const isHex = (v: unknown): v is string =>
|
||||
typeof v === 'string' && /^#[0-9a-fA-F]{6}$/.test(v.trim());
|
||||
|
||||
export interface Theme {
|
||||
accentLight: string;
|
||||
accentDark: string;
|
||||
radiusImg: number;
|
||||
textH1: number;
|
||||
textH2: number;
|
||||
textH3: number;
|
||||
proseReading: number;
|
||||
proseTech: number;
|
||||
leadingReading: number;
|
||||
leadingTech: number;
|
||||
space3: number;
|
||||
space4: number;
|
||||
space5: number;
|
||||
space6: number;
|
||||
flowP: number;
|
||||
flowH2Post: number;
|
||||
dur: number;
|
||||
}
|
||||
|
||||
/** 读 theme 单例并做钳制/单调校验(任一约束不满足即整组回落默认) */
|
||||
export async function getTheme(): Promise<Theme> {
|
||||
const entry = await getEntry('siteTheme', 'theme');
|
||||
const d = (entry?.data ?? {}) as Record<string, unknown>;
|
||||
const num = (k: string) => (typeof d[k] === 'number' ? (d[k] as number) : undefined);
|
||||
|
||||
// 标题三级必须单调递减,否则整组回落(避免 h2 比 h1 大的畸形版式)
|
||||
let textH1 = clamp(num('text_h1'), 22, 32, DEFAULTS.textH1);
|
||||
let textH2 = clamp(num('text_h2'), 22, 32, DEFAULTS.textH2);
|
||||
let textH3 = clamp(num('text_h3'), 16, 32, DEFAULTS.textH3);
|
||||
if (!(textH1 > textH2 && textH2 > textH3)) {
|
||||
textH1 = DEFAULTS.textH1;
|
||||
textH2 = DEFAULTS.textH2;
|
||||
textH3 = DEFAULTS.textH3;
|
||||
}
|
||||
|
||||
// 间距梯度必须严格递增,否则整组回落
|
||||
let space3 = clamp(num('space_3'), 8, 80, DEFAULTS.space3);
|
||||
let space4 = clamp(num('space_4'), 8, 80, DEFAULTS.space4);
|
||||
let space5 = clamp(num('space_5'), 8, 80, DEFAULTS.space5);
|
||||
let space6 = clamp(num('space_6'), 8, 80, DEFAULTS.space6);
|
||||
if (!(space3 < space4 && space4 < space5 && space5 < space6)) {
|
||||
space3 = DEFAULTS.space3;
|
||||
space4 = DEFAULTS.space4;
|
||||
space5 = DEFAULTS.space5;
|
||||
space6 = DEFAULTS.space6;
|
||||
}
|
||||
|
||||
return {
|
||||
accentLight: isHex(d.accent_light) ? d.accent_light.trim() : DEFAULTS.accentLight,
|
||||
accentDark: isHex(d.accent_dark) ? d.accent_dark.trim() : DEFAULTS.accentDark,
|
||||
radiusImg: clamp(num('radius_img'), 4, 16, DEFAULTS.radiusImg),
|
||||
textH1,
|
||||
textH2,
|
||||
textH3,
|
||||
proseReading: clamp(num('prose_size_reading'), 16, 22, DEFAULTS.proseReading),
|
||||
proseTech: clamp(num('prose_size_tech'), 16, 22, DEFAULTS.proseTech),
|
||||
leadingReading: clamp(num('leading_reading'), 1.5, 2.0, DEFAULTS.leadingReading),
|
||||
leadingTech: clamp(num('leading_tech'), 1.5, 2.0, DEFAULTS.leadingTech),
|
||||
space3,
|
||||
space4,
|
||||
space5,
|
||||
space6,
|
||||
flowP: clamp(num('flow_p'), 0, 2, DEFAULTS.flowP),
|
||||
flowH2Post: clamp(num('flow_h2_post'), 0, 2, DEFAULTS.flowH2Post),
|
||||
dur: clamp(num('dur'), 150, 600, DEFAULTS.dur),
|
||||
};
|
||||
}
|
||||
|
||||
/** 亮态变量块(不含 accent,accent 分态) */
|
||||
function lightVars(t: Theme): string {
|
||||
return [
|
||||
`--accent:${t.accentLight}`,
|
||||
`--radius-img:${t.radiusImg}px`,
|
||||
`--text-h1:${t.textH1}px`,
|
||||
`--text-h2:${t.textH2}px`,
|
||||
`--text-h3:${t.textH3}px`,
|
||||
`--prose-size-reading:${t.proseReading}px`,
|
||||
`--prose-size-tech:${t.proseTech}px`,
|
||||
`--leading-reading:${t.leadingReading}`,
|
||||
`--leading-tech:${t.leadingTech}`,
|
||||
`--space-3:${t.space3}px`,
|
||||
`--space-4:${t.space4}px`,
|
||||
`--space-5:${t.space5}px`,
|
||||
`--space-6:${t.space6}px`,
|
||||
`--flow-p:${t.flowP}em`,
|
||||
`--flow-h2-post:${t.flowH2Post}em`,
|
||||
`--dur:${t.dur}ms`,
|
||||
].join(';');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成内联样式串:`:root:root{…}` 压 tokens.css 的 `:root`(0,1,0),
|
||||
* `[data-theme="dark"]:root:root{…}` 压其暗态覆盖——不依赖注入顺序。
|
||||
*/
|
||||
export function themeVarsCss(t: Theme): string {
|
||||
return `:root:root{${lightVars(t)}}[data-theme="dark"]:root:root{--accent:${t.accentDark}}`;
|
||||
}
|
||||
|
||||
/** 一次取全(BaseLayout 用):返回内联 CSS 串 */
|
||||
export async function themeCss(): Promise<string> {
|
||||
return themeVarsCss(await getTheme());
|
||||
}
|
||||
+3
-141
@@ -1,143 +1,5 @@
|
||||
---
|
||||
// /en/ 首页:与 zh 版结构逐区对齐(名片区/文章列表/最新影像/热力图/项目墙)
|
||||
import { Image } from 'astro:assets';
|
||||
import { getCollection } from 'astro:content';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import IdCard from '../../components/IdCard.astro';
|
||||
import PostList from '../../components/PostList.astro';
|
||||
import ProjectWall from '../../components/geek/ProjectWall.astro';
|
||||
import Heatmap from '../../components/geek/Heatmap.astro';
|
||||
import { t } from '../../i18n/ui';
|
||||
import { homeConfig, homeTagline, pageTypography, renderMarkdown, renderRich } from '../../lib/site-content';
|
||||
import SocialLinks from '../../components/SocialLinks.astro';
|
||||
|
||||
// 首页可配项(后台主页设置):副标题/影像张数/区块开关/每轨条数,字段级回落
|
||||
const cfg = await homeConfig('en');
|
||||
const subtitleHtml = cfg.subtitle ? await renderMarkdown(cfg.subtitle) : '';
|
||||
const posts = (await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden'))).sort(
|
||||
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
|
||||
);
|
||||
// 列表标题吃 postsEn 译题(缺译回退 zh,与 /en/archives 同策略)
|
||||
const enTitle = Object.fromEntries(
|
||||
(await getCollection('postsEn')).map((e) => [e.id, e.data.title]),
|
||||
);
|
||||
const photos = (await getCollection('photos')).slice(0, Math.max(1, cfg.latestPhotos));
|
||||
// 身份语与 zh 版同源(content/site/home.yaml 的 en 字段),缺值回落默认
|
||||
const tagline = await homeTagline('en');
|
||||
const taglineHtml = await renderRich(tagline, { inline: true });
|
||||
const typo = await pageTypography('siteHome', 'home');
|
||||
// /en/ 首页薄壳(与 zh 共用 HomePage 组件,仅 locale 不同)
|
||||
import HomePage from '../../components/pages/HomePage.astro';
|
||||
---
|
||||
|
||||
<BaseLayout title="wait4x" description="wait4x's blog: reading · code · photography">
|
||||
<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>
|
||||
|
||||
{cfg.sections.tracks && <PostList {posts} locale="en" titleOverrides={enTitle} perTrack={cfg.perTrackLimit} />}
|
||||
|
||||
{cfg.sections.photos && cfg.latestPhotos > 0 && (
|
||||
<section class="latest" aria-label={t('latest_photos', 'en')}>
|
||||
<div class="latest-head">
|
||||
<span class="lab">{t('latest_photos', 'en')}</span>
|
||||
<a class="tap more" href="/en/gallery/">{t('enter_gallery', 'en')}</a>
|
||||
</div>
|
||||
<div class="thumbs">
|
||||
{photos.map((photo) => (
|
||||
<a class="thumb" href="/en/gallery/" aria-label={`Gallery: ${photo.data.title}`}>
|
||||
<Image
|
||||
src={photo.data.image}
|
||||
alt={photo.data.alt ?? photo.data.title}
|
||||
widths={[180, 360]}
|
||||
sizes="(min-width: 820px) 20vw, 42vw"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{cfg.sections.heatmap && <Heatmap
|
||||
labels={{
|
||||
title: t('heatmap_title', 'en'),
|
||||
summary: t('heatmap_summary', 'en'),
|
||||
less: t('heatmap_less', 'en'),
|
||||
more: t('heatmap_more', 'en'),
|
||||
note: t('heatmap_note', 'en'),
|
||||
}}
|
||||
/>}
|
||||
{cfg.sections.projects && <ProjectWall locale="en" />}
|
||||
</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;
|
||||
}
|
||||
.socials {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.social {
|
||||
color: var(--ink-3);
|
||||
border-radius: 8px;
|
||||
transition: color var(--dur) var(--ease);
|
||||
}
|
||||
.social:hover { color: var(--accent); }
|
||||
|
||||
.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>
|
||||
<HomePage locale="en" />
|
||||
+3
-165
@@ -1,167 +1,5 @@
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import { getCollection } from 'astro:content';
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import IdCard from '../components/IdCard.astro';
|
||||
import PostList from '../components/PostList.astro';
|
||||
import ProjectWall from '../components/geek/ProjectWall.astro';
|
||||
import Heatmap from '../components/geek/Heatmap.astro';
|
||||
import { pageTypography, renderRich } from '../lib/site-content';
|
||||
import { fetchHeartbeat, agoZh } from '../lib/heartbeat';
|
||||
import { t } from '../i18n/ui';
|
||||
import { homeConfig, homeTagline, renderMarkdown } from '../lib/site-content';
|
||||
import SocialLinks from '../components/SocialLinks.astro';
|
||||
|
||||
// 首页可配项(后台主页设置):副标题/影像张数/区块开关/每轨条数,字段级回落
|
||||
const cfg = await homeConfig('zh');
|
||||
const subtitleHtml = cfg.subtitle ? await renderMarkdown(cfg.subtitle) : '';
|
||||
const posts = (await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden'))).sort(
|
||||
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
|
||||
);
|
||||
// 最新影像取集合前 4 张;照片时间序由照片管线(exif 车道)治理,此处只裁切
|
||||
const photos = (await getCollection('photos')).slice(0, Math.max(1, cfg.latestPhotos));
|
||||
const heartbeat = await fetchHeartbeat();
|
||||
// 身份语改由 content/site/home.yaml 供给(后台可改),缺值回落默认文案
|
||||
const tagline = await homeTagline('zh');
|
||||
// 层 1:tagline 走富文本(后台可写 <span class="font-kai c-blue">)
|
||||
const taglineHtml = await renderRich(tagline, { inline: true });
|
||||
const typo = await pageTypography('siteHome', 'home');
|
||||
// 首页薄壳(逻辑与样式在 src/components/pages/HomePage.astro,zh/en 共用)
|
||||
import HomePage from '../components/pages/HomePage.astro';
|
||||
---
|
||||
|
||||
<BaseLayout title="wait4x">
|
||||
<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>
|
||||
|
||||
{cfg.sections.tracks && <PostList {posts} perTrack={cfg.perTrackLimit} />}
|
||||
|
||||
{cfg.sections.photos && cfg.latestPhotos > 0 && (
|
||||
<section class="latest" aria-label="最新影像">
|
||||
<div class="latest-head">
|
||||
<span class="lab">最新影像</span>
|
||||
<a class="tap more" href="/gallery/">进入图库 →</a>
|
||||
</div>
|
||||
<div class="thumbs">
|
||||
{photos.map((photo, i) => (
|
||||
<a class="thumb" href="/gallery/" aria-label={`查看图库:${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', 'zh'),
|
||||
summary: t('heatmap_summary', 'zh'),
|
||||
less: t('heatmap_less', 'zh'),
|
||||
more: t('heatmap_more', 'zh'),
|
||||
note: t('heatmap_note', 'zh'),
|
||||
}}
|
||||
/>}
|
||||
{cfg.sections.projects && <ProjectWall />}
|
||||
{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;
|
||||
}
|
||||
.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; } }
|
||||
.socials {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.social {
|
||||
color: var(--ink-3);
|
||||
border-radius: 8px;
|
||||
transition: color var(--dur) var(--ease);
|
||||
}
|
||||
.social:hover { color: var(--accent); }
|
||||
.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>
|
||||
<HomePage locale="zh" />
|
||||
Reference in new issue
Block a user