feat(pages): 五页型落地——首页/文章双模板/图库 masonry/瞬间/关于

- 首页:名片+身份陈述空槽+按年分组分轨列表(情绪色/mono标签)+影像格+项目墙
- 文章 /archives/{slug}(与 Halo 旧 URL 1:1):reading→衬线纸墨 / tech→
  TOC 侧栏(≥1120 sticky)+2px 进度条+Artalk 容器占位
- 图库:grid masonry(构建期 aspect-ratio 零 JS)+原生 dialog lightbox
  (←→/Esc+EXIF 底栏)+分组筛选
- 瞬间时间线 / 关于(三轨版图+构建说明)
- sharp ^0.35.5 入依赖(Astro 7 不内置,astro:assets 缩略图管线前提)
- 裸 blockquote 默认吃 A 墨线样式(markdown 原生输出无类名的缝隙)
- 车道 30min 超时,产物完整,主会话补 sharp+断言脚本递归修正后收编

验证: pnpm astro build exit 0(7 页);五路由产物断言过;
      scripts/check-responsive.sh 11/11 ok(1120/820/760/560 全档)

Spec: 2026-10-24_add-blog-skeleton
Co-authored-by: GLM-5.3
This commit is contained in:
wait4xandGLM-5.3 committed 2026-10-25 00:11:34 +08:00
1 parent 80ff67e8fa
commit 945a9ee610
14 files changed
+1281 -18

No files matched your search

+118
View File
@@ -0,0 +1,118 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import IdCard from '../components/IdCard.astro';
---
<BaseLayout title="关于 · wait4x">
<h1 class="page-title">关于</h1>
<IdCard />
<section class="tracks" aria-label="兴趣版图">
<h2 class="sec-title">兴趣版图</h2>
<ul>
<li class="track-row">
<span class="track-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 4h6a4 4 0 0 1 4 4v13a3 3 0 0 0-3-3H2z"></path>
<path d="M22 4h-6a4 4 0 0 0-4 4v13a3 3 0 0 1 3-3h7z"></path>
</svg>
</span>
<div>
<strong>阅读</strong>
<p>文学与戏剧为主,读到哪写到哪。</p>
</div>
</li>
<li class="track-row">
<span class="track-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="m4 5.5 6.5 6.5L4 18.5"></path>
<path d="M12.5 18.5H20"></path>
</svg>
</span>
<div>
<strong>技术</strong>
<p>Linux、容器与自托管,把家里的小机器折腾成顺手的工具。</p>
</div>
</li>
<li class="track-row">
<span class="track-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"></path>
<circle cx="12" cy="13" r="4"></circle>
</svg>
</span>
<div>
<strong>摄影</strong>
<p>街拍与山野,随身一台无反,在等光的间隙按快门。</p>
</div>
</li>
</ul>
</section>
<section class="build" aria-label="本站构建说明">
<h2 class="sec-title">本站如何构建</h2>
<p>
页面由 Astro 静态生成,不含运行时框架 JS;内容住在自托管 gitea——本地编辑器与 gitea
网页写的是同一个 git 仓库,push 之后由 homelab 自动构建发布;评论走自托管
Artalk。页脚 built from 一行链到本次构建对应的 commit,构建过程透明可追溯。
</p>
</section>
</BaseLayout>
<style>
.page-title {
font-size: var(--text-h1);
line-height: var(--leading-heading);
color: var(--ink-1);
margin-top: var(--space-5);
text-wrap: balance;
}
.sec-title {
font-size: 14px;
font-family: var(--font-mono);
color: var(--ink-3);
letter-spacing: 0.08em;
margin: var(--space-5) 0 var(--space-3);
}
.tracks ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--space-2);
}
.track-row {
display: flex;
align-items: flex-start;
gap: var(--space-3);
border: 1px solid var(--line);
border-radius: 10px;
padding: var(--space-3) var(--space-4);
}
.track-icon {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
background: var(--bg-soft);
color: var(--ink-3);
}
.track-row strong {
font-size: 15px;
color: var(--ink-1);
}
.track-row p {
font-size: 14px;
color: var(--ink-3);
margin-top: 2px;
}
.build p {
font-size: 15px;
color: var(--ink-2);
max-width: var(--measure);
}
</style>
+270
View File
@@ -0,0 +1,270 @@
---
import type { CollectionEntry } from 'astro:content';
import { getCollection, render } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { trackOf } from '../../content.config';
// URL = /archives/{slug},slug 即文件名,与 Halo 旧结构 1:1(不可改)
export async function getStaticPaths() {
const posts = (await getCollection('posts', ({ data }) => !data.draft)).sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
);
return posts.map((entry, i) => ({
params: { slug: entry.id },
props: { entry, prev: posts[i + 1] ?? null, next: posts[i - 1] ?? null },
}));
}
interface Props {
entry: CollectionEntry<'posts'>;
prev: CollectionEntry<'posts'> | null;
next: CollectionEntry<'posts'> | null;
}
const { entry, prev, next } = Astro.props;
const { Content, headings } = await render(entry);
const isReading = entry.data.category === 'reading';
const date = entry.data.pubDate.toISOString().slice(0, 10);
// 阅读时长:正文汉字数 / 400 向上取整(最低 1 分钟)
const hanzi = entry.body?.match(/[\u4e00-\u9fff]/g)?.length ?? 0;
const minutes = Math.max(1, Math.ceil(hanzi / 400));
const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
---
<BaseLayout title={`${entry.data.title} · wait4x`} track={isReading ? 'reading' : 'tech'}>
{isReading ? (
<article class="post post-reading">
<header class="post-head">
<p class="post-cat">Reading · {trackOf(entry.data.category)}</p>
<h1 class="post-title">{entry.data.title}</h1>
<p class="post-meta">
<i
class="emo"
style={`background:${entry.data.mood ?? 'var(--mood)'}`}
title="情绪色"
aria-hidden="true"
/>
<time datetime={entry.data.pubDate.toISOString()}>{date}</time>
<span aria-hidden="true">·</span>
<span>阅读时长 {minutes} 分钟</span>
</p>
</header>
<div class="prose-reading">
<Content />
</div>
</article>
) : (
<>
<div class="read-progress" id="read-progress" aria-hidden="true"></div>
<article class="post post-tech">
<header class="post-head">
<p class="post-cat">Tech · {trackOf(entry.data.category)}</p>
<h1 class="post-title">{entry.data.title}</h1>
<p class="post-meta">
{entry.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
<span aria-hidden="true">·</span>
<time datetime={entry.data.pubDate.toISOString()}>{date}</time>
<span aria-hidden="true">·</span>
<span>阅读时长 {minutes} 分钟</span>
</p>
</header>
<div class="tech-grid">
<div class="prose-tech">
<Content />
</div>
{toc.length > 0 && (
<aside class="toc" aria-label="目录">
<p class="toc-h">目录</p>
{toc.map((h) => (
<a class={h.depth === 3 ? 'toc-sub' : undefined} href={`#${h.slug}`}>{h.text}</a>
))}
</aside>
)}
</div>
</article>
</>
)}
<nav class="pn" aria-label="上一篇下一篇">
{prev ? (
<a class="pn-item" href={`/archives/${prev.id}/`}>
<span>← 上一篇</span>
<strong>{prev.data.title}</strong>
</a>
) : (
<span class="pn-item" aria-hidden="true"></span>
)}
{next ? (
<a class="pn-item right" href={`/archives/${next.id}/`}>
<span>下一篇 →</span>
<strong>{next.data.title}</strong>
</a>
) : (
<span class="pn-item right" aria-hidden="true"></span>
)}
</nav>
<!-- T8 填充:在此容器注入 Artalk 脚本,data-page-key 即文章 id -->
<div id="artalk-container" data-page-key={entry.id}></div>
</BaseLayout>
<script>
// 阅读进度条(技术轨):ClientRouter 下模块脚本只执行一次,
// 单例滚动监听 + astro:page-load 重绘,条不在当前页时零开销;
// prefers-reduced-motion 时整条不初始化(CSS 层同步隐藏)
const paint = () => {
const bar = document.getElementById('read-progress');
if (!bar) return;
const el = document.documentElement;
const total = el.scrollHeight - el.clientHeight;
bar.style.transform = `scaleX(${total > 0 ? el.scrollTop / total : 0})`;
};
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) {
addEventListener('scroll', paint, { passive: true });
document.addEventListener('astro:page-load', paint);
}
</script>
<style>
/* 版心放行:技术轨带 TOC 时加宽到 measure+200(丙路线 §3;BaseLayout 默认 720) */
@media (min-width: 1120px) {
:global(main#main:has(.post-tech)) {
max-width: calc(var(--measure) + 200px);
}
}
.read-progress {
position: fixed;
top: 0;
left: 0;
z-index: 20;
height: 2px;
width: 100%;
background: var(--accent);
transform: scaleX(0);
transform-origin: 0 0;
}
@media (prefers-reduced-motion: reduce) {
.read-progress { display: none; }
}
.post { margin-top: var(--space-5); }
.post-cat {
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--ink-4);
}
.post-title {
font-size: var(--text-h1);
line-height: var(--leading-heading);
color: var(--ink-1);
margin-top: var(--space-2);
text-wrap: balance;
}
.post-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-3);
font-size: 14px;
color: var(--ink-3);
}
.emo {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 3px;
flex: none;
}
.tag {
font-family: var(--font-mono);
font-size: 12px;
color: var(--ink-3);
background: var(--bg-soft);
border-radius: 5px;
padding: 1px 7px;
white-space: nowrap;
}
/* 书评轨:Content 渲染的 blockquote 打不了 .quote-block 类,此处镜像同一引文形状 */
.prose-reading { margin-top: var(--space-4); }
.prose-reading :global(blockquote) {
border-left: 2px solid var(--paper-line);
padding-left: 1.35em;
margin-block: 1.2em;
color: color-mix(in oklab, var(--paper-ink) 72%, transparent);
}
/* 技术轨:TOC 侧栏仅 ≥1120 常驻,窄屏隐藏 */
.tech-grid { margin-top: var(--space-4); }
.toc { display: none; }
@media (min-width: 1120px) {
.tech-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 176px;
gap: var(--space-5);
align-items: start;
}
.toc {
display: block;
position: sticky;
top: 80px;
max-height: calc(100dvh - 120px);
overflow: auto;
font-size: 13px;
padding-left: var(--space-3);
border-left: 1px solid var(--line);
}
}
.toc-h {
font-weight: 600;
color: var(--ink-1);
margin-bottom: var(--space-2);
}
.toc a {
display: block;
padding: var(--space-1) 0;
color: var(--ink-3);
transition: color var(--dur) var(--ease);
}
.toc a:hover { color: var(--accent); }
.toc .toc-sub { padding-left: var(--space-3); }
.prose-tech :global(h2),
.prose-tech :global(h3),
.prose-reading :global(h2),
.prose-reading :global(h3) {
scroll-margin-top: 80px;
}
.pn {
display: flex;
justify-content: space-between;
gap: var(--space-4);
margin-top: var(--space-5);
}
.pn-item {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
padding: var(--space-1) var(--space-2);
}
.pn-item.right {
text-align: right;
align-items: flex-end;
}
.pn-item span { font-size: 12px; color: var(--ink-4); }
.pn-item strong {
font-size: 15px;
font-weight: 600;
color: var(--ink-1);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: color var(--dur) var(--ease);
}
.pn-item:hover strong { color: var(--accent); }
#artalk-container { margin-top: var(--space-5); }
</style>
+31
View File
@@ -0,0 +1,31 @@
---
// 导航「文章」落点:全量归档列表(年份/轨道分节逻辑与首页共用 PostList)
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import PostList from '../../components/PostList.astro';
const posts = (await getCollection('posts', ({ data }) => !data.draft)).sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
);
---
<BaseLayout title="文章 · wait4x">
<h1 class="page-title">文章</h1>
<p class="page-note">按年份归档 · 轨道分节 · 共 {posts.length} 篇</p>
<PostList {posts} />
</BaseLayout>
<style>
.page-title {
font-size: var(--text-h1);
line-height: var(--leading-heading);
color: var(--ink-1);
margin-top: var(--space-5);
text-wrap: balance;
}
.page-note {
font-size: 13px;
color: var(--ink-4);
margin-block: var(--space-1) var(--space-4);
}
</style>
+357
View File
@@ -0,0 +1,357 @@
---
import { Image, getImage } from 'astro:assets';
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import type { PhotoExif } from '../../lib/exif';
const photos = await getCollection('photos');
// EXIF 参数条(机身/镜头/光圈/快门/ISO)构建期拼好整串,灯箱运行时只做文本替换
function formatExif(exif: PhotoExif | undefined): string {
if (!exif) return '';
const shutter =
exif.exposureTime === undefined
? undefined
: exif.exposureTime >= 1
? `${exif.exposureTime}s`
: `1/${Math.round(1 / exif.exposureTime)}s`;
return [
[exif.make, exif.model].filter(Boolean).join(' ') || undefined,
exif.lens,
exif.fNumber === undefined ? undefined : `f/${exif.fNumber}`,
shutter,
exif.iso === undefined ? undefined : `ISO ${exif.iso}`,
]
.filter(Boolean)
.join(' · ');
}
/* masonry:行高/列距用容器宽百分比(0.92cqw/1.62cqw,在 864px 版心即 8px/14px 档),
行高随断点等比缩放,构建期一份 row-span 在 2/3 列全断点精确成立——
px 定值行高会让格高随列宽漂移,窄屏裁切失控 */
const ROW = 0.0092;
const GAP = 0.0162;
const spanFor = (w: number, h: number, cols: number) =>
Math.round(((1 - GAP * (cols - 1)) / (cols * (w / h)) + GAP) / (ROW + GAP));
const cards = await Promise.all(
photos.map(async (photo) => {
// 宽高比来源:photos loader 产出 ImageMetadata 的 width/height(orientation 已修正)
const w = photo.data.image.width ?? 4;
const h = photo.data.image.height ?? 3;
const full = await getImage({ src: photo.data.image, width: 1440 });
return {
group: photo.data.group,
title: photo.data.title,
alt: photo.data.alt ?? photo.data.title,
image: photo.data.image,
exif: formatExif(photo.data.exif),
fullSrc: full.src,
w,
h,
s3: spanFor(w, h, 3),
s2: spanFor(w, h, 2),
};
}),
);
const first = cards[0];
---
<BaseLayout title="图库 · wait4x">
<h1 class="page-title">图库</h1>
<div class="g-main">
<fieldset class="filters">
<legend class="sr-only">分组筛选</legend>
<input class="f-input" type="radio" name="gallery-group" id="f-all" checked />
<label class="tap chip" for="f-all">全部</label>
<input class="f-input" type="radio" name="gallery-group" id="f-street" />
<label class="tap chip" for="f-street">街拍</label>
<input class="f-input" type="radio" name="gallery-group" id="f-landscape" />
<label class="tap chip" for="f-landscape">风景</label>
</fieldset>
<div class="stage">
<ul class="wall">
{cards.map((card) => (
<li
class="shot"
data-group={card.group}
style={`aspect-ratio:${card.w}/${card.h}; --s3:${card.s3}; --s2:${card.s2}`}
>
<button
type="button"
class="shot-btn"
data-title={card.title}
data-exif={card.exif}
data-full={card.fullSrc}
aria-label={`查看大图:${card.title}${card.exif ? `(${card.exif})` : ''}`}
>
<Image
src={card.image}
alt={card.alt}
widths={[320, 560, 720]}
sizes="(min-width: 820px) 30vw, 46vw"
loading="lazy"
/>
</button>
</li>
))}
</ul>
</div>
<p class="g-note">共 {cards.length} 张 · EXIF 构建期自动提取</p>
</div>
<dialog id="lb" aria-label="照片查看">
<button id="lb-close" class="tap lbnav" type="button" aria-label="关闭(Esc)">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<path d="m5 5 14 14M19 5 5 19"></path>
</svg>
</button>
<div class="lb-figure">
<img id="lb-img" src={first?.fullSrc} alt={first?.alt ?? ''} />
</div>
<div class="lb-bottom">
<button id="lb-prev" class="tap lbnav" type="button" aria-label="上一张(←)">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m14.5 5.5-6.5 6.5 6.5 6.5"></path>
</svg>
</button>
<div class="lb-meta">
<strong id="lb-title">{first?.title}</strong>
<span id="lb-exif">{first?.exif}</span>
</div>
<button id="lb-next" class="tap lbnav" type="button" aria-label="下一张(→)">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m9.5 5.5 6.5 6.5-6.5 6.5"></path>
</svg>
</button>
</div>
</dialog>
</BaseLayout>
<script>
// 灯箱:原生 dialog,←→ 切换、Esc 原生关闭;触屏快滑翻页是键盘的等价物。
// ClientRouter 下模块脚本只执行一次,接线挂在 astro:page-load 按当前 DOM 重装
document.addEventListener('astro:page-load', () => {
const lb = document.getElementById('lb') as HTMLDialogElement | null;
const shots = [...document.querySelectorAll<HTMLButtonElement>('.shot-btn')];
if (!lb || shots.length === 0) return;
const img = document.getElementById('lb-img') as HTMLImageElement;
const titleEl = document.getElementById('lb-title') as HTMLElement;
const exifEl = document.getElementById('lb-exif') as HTMLElement;
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
let idx = 0;
const show = (i: number) => {
idx = (i + shots.length) % shots.length;
const btn = shots[idx]!;
img.src = btn.dataset.full ?? '';
img.alt = btn.ariaLabel ?? '';
titleEl.textContent = btn.dataset.title ?? '';
exifEl.textContent = btn.dataset.exif ?? '';
if (!reduced.matches) {
img.style.opacity = '0';
requestAnimationFrame(() => {
img.style.opacity = '1';
});
}
if (!lb.open) lb.showModal();
};
shots.forEach((btn, i) => btn.addEventListener('click', () => show(i)));
document.getElementById('lb-prev')?.addEventListener('click', () => show(idx - 1));
document.getElementById('lb-next')?.addEventListener('click', () => show(idx + 1));
document.getElementById('lb-close')?.addEventListener('click', () => lb.close());
lb.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft') {
e.preventDefault();
show(idx - 1);
} else if (e.key === 'ArrowRight') {
e.preventDefault();
show(idx + 1);
}
});
let x0: number | null = null;
lb.addEventListener('touchstart', (e) => {
x0 = e.touches[0]!.clientX;
}, { passive: true });
lb.addEventListener('touchend', (e) => {
if (x0 === null) return;
const dx = e.changedTouches[0]!.clientX - x0;
if (Math.abs(dx) > 40) show(idx + (dx < 0 ? 1 : -1));
x0 = null;
}, { passive: true });
});
</script>
<style>
/* 版心放行:图库加宽到 measure+176(丙路线 §3;BaseLayout 默认 720) */
:global(main#main:has(.g-main)) {
max-width: calc(var(--measure) + 176px);
}
.page-title {
font-size: var(--text-h1);
line-height: var(--leading-heading);
color: var(--ink-1);
margin-top: var(--space-5);
text-wrap: balance;
}
/* 分组筛选:纯 CSS :checked + :has,radio 原生键盘可达(方向键切换) */
.filters {
border: none;
padding: 0;
margin: var(--space-3) 0 var(--space-4);
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.sr-only,
.f-input {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
clip-path: inset(50%);
overflow: hidden;
white-space: nowrap;
}
.chip {
padding-inline: 13px;
border: 1px solid var(--line);
border-radius: 999px;
font-size: 13px;
color: var(--ink-3);
cursor: pointer;
transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
.chip:hover { color: var(--ink-1); border-color: var(--ink-4); }
.f-input:focus-visible + .chip { outline: 2px solid var(--accent); outline-offset: 2px; }
.f-input:checked + .chip { color: var(--ink-1); border-color: var(--ink-3); font-weight: 500; }
.g-main:has(#f-street:checked) .shot:not([data-group='street']) { display: none; }
.g-main:has(#f-landscape:checked) .shot:not([data-group='landscape']) { display: none; }
/* masonry:stage 是容器查询锚,行高/列距以 stage 宽为基(cqw) */
.stage { container-type: inline-size; }
.wall {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-auto-rows: 0.92cqw; /* 8px 档 @ 864px 版心 */
grid-auto-flow: dense;
gap: 1.62cqw; /* 14px 档 @ 864px 版心 */
}
@media (min-width: 820px) {
.wall { grid-template-columns: repeat(3, 1fr); }
}
.shot {
grid-row-end: span var(--s2, 10);
border-radius: 10px;
overflow: hidden;
}
@media (min-width: 820px) {
.shot { grid-row-end: span var(--s3, 12); }
}
.shot-btn {
display: block;
width: 100%;
height: 100%;
padding: 0;
border: none;
background: none;
cursor: zoom-in;
}
.shot-btn :global(img) {
width: 100%;
height: 100%;
object-fit: cover;
}
.g-note {
margin-top: var(--space-3);
font-size: 12.5px;
color: var(--ink-4);
}
/* 灯箱:全屏原生 dialog,高度用 dvh(规避 100vh 移动端地址栏陷阱) */
#lb {
inset: 0;
width: 100%;
height: 100dvh;
max-width: none;
max-height: none;
margin: 0;
border: none;
border-radius: 0;
background: transparent;
display: flex;
flex-direction: column;
padding: 0;
}
#lb::backdrop { background: rgba(8, 10, 14, 0.95); }
.lb-figure {
flex: 1;
min-height: 0;
display: grid;
place-items: center;
padding: var(--space-5) var(--space-4) var(--space-2);
}
#lb-img {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
border-radius: 6px;
transition: opacity 160ms var(--ease);
}
.lb-bottom {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-4) var(--space-4);
color: var(--code-ink);
font-family: var(--font-mono);
font-size: 12.5px;
}
.lb-meta {
flex: 1;
min-width: 0;
text-align: center;
display: flex;
flex-direction: column;
gap: 2px;
}
.lb-meta strong {
color: #fff;
font-size: 13px;
font-family: var(--font-sans);
font-weight: 600;
}
.lb-meta span {
opacity: 0.82;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lbnav {
flex: none;
width: 44px;
height: 44px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.06);
color: #e6e9ee;
}
.lbnav:hover { background: rgba(255, 255, 255, 0.12); }
#lb-close {
position: absolute;
top: var(--space-3);
right: var(--space-3);
}
@media (max-width: 560px) {
.lb-bottom { flex-wrap: wrap; }
.lb-meta { order: 3; flex-basis: 100%; }
}
</style>
+138 -1
View File
@@ -1,6 +1,143 @@
---
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';
const posts = (await getCollection('posts', ({ data }) => !data.draft)).sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
);
// 最新影像取集合前 4 张;照片时间序由照片管线(exif 车道)治理,此处只裁切
const photos = (await getCollection('photos')).slice(0, 4);
---
<BaseLayout title="wait4x">
<p>Phase 1 · T5 布局就绪,首页结构 T6 填充。</p>
<IdCard>
<div class="socials">
<a
class="tap social"
href="https://github.com/wait4xx"
rel="noopener noreferrer"
aria-label="GitHub"
title="GitHub"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" aria-hidden="true">
<path
d="M12 2C6.48 2 2 6.58 2 12.25c0 4.53 2.87 8.37 6.84 9.73.5.1.68-.22.68-.49 0-.24-.01-.88-.01-1.73-2.78.62-3.37-1.37-3.37-1.37-.45-1.18-1.11-1.5-1.11-1.5-.91-.63.07-.62.07-.62 1 .07 1.53 1.06 1.53 1.06.89 1.57 2.34 1.12 2.91.85.09-.66.35-1.11.63-1.37-2.22-.26-4.56-1.14-4.56-5.07 0-1.12.39-2.03 1.03-2.75-.1-.26-.45-1.3.1-2.7 0 0 .84-.28 2.75 1.05a9.3 9.3 0 0 1 5 0c1.91-1.33 2.75-1.05 2.75-1.05.55 1.4.2 2.44.1 2.7.64.72 1.03 1.63 1.03 2.75 0 3.94-2.34 4.8-4.57 5.06.36.32.68.94.68 1.9 0 1.37-.01 2.47-.01 2.81 0 .27.18.6.69.49A10.26 10.26 0 0 0 22 12.25C22 6.58 17.52 2 12 2Z"
></path>
</svg>
</a>
<a class="tap social" href="/rss.xml" aria-label="RSS 订阅" title="RSS 订阅">
<svg
viewBox="0 0 24 24"
width="16"
height="16"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
aria-hidden="true"
>
<path d="M4 11.5a8.5 8.5 0 0 1 8.5 8.5M4 4.5a15.5 15.5 0 0 1 15.5 15.5"></path>
<circle cx="5.4" cy="18.6" r="1.5" fill="currentColor" stroke="none"></circle>
</svg>
</a>
<a class="tap social" href="mailto:admin@wait4x.online" aria-label="邮件" title="邮件">
<svg
viewBox="0 0 24 24"
width="16"
height="16"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<rect x="3" y="5.5" width="18" height="13" rx="2.5"></rect>
<path d="m4 7.5 8 5.5 8-5.5"></path>
</svg>
</a>
</div>
</IdCard>
<PostList {posts} />
<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) => (
<a class="thumb" href="/gallery/" aria-label={`查看图库:${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>
<ProjectWall />
</BaseLayout>
<style>
.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>
+142
View File
@@ -0,0 +1,142 @@
---
import { Image } from 'astro:assets';
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
const moments = (await getCollection('moments')).sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
);
const iso = (d: Date) => d.toISOString();
---
<BaseLayout title="瞬间 · wait4x">
<h1 class="page-title">瞬间</h1>
<p class="page-note">零碎的记录:随手拍、一句话感想、开源动态。</p>
<ol class="tl">
{moments.map((m) => (
<li class="tl-item">
<time class="tl-date" datetime={iso(m.data.pubDate)}>
{iso(m.data.pubDate).slice(0, 10)}
</time>
<div class="mom-card">
{m.data.image && (
<figure
class:list={[
'mom-fig',
(m.data.image.width ?? 0) < (m.data.image.height ?? 0) && 'vt',
]}
>
<Image
src={m.data.image}
alt={m.data.text ?? m.data.tags.join(' ')}
widths={[480, 840]}
sizes="(min-width: 760px) 420px, calc(100vw - 140px)"
/>
</figure>
)}
{m.data.text && <p>{m.data.text}</p>}
{m.data.tags.length > 0 && (
<p class="mom-tags">
{m.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
</p>
)}
</div>
</li>
))}
</ol>
</BaseLayout>
<style>
.page-title {
font-size: var(--text-h1);
line-height: var(--leading-heading);
color: var(--ink-1);
margin-top: var(--space-5);
text-wrap: balance;
}
.page-note {
font-size: 13px;
color: var(--ink-4);
margin-block: var(--space-1) var(--space-4);
}
.tl {
list-style: none;
margin: var(--space-4) 0 0;
padding: 0;
}
/* 时间线骨架:左 mono 日期 + 竖线 + --accent 节点 + 右侧内容卡 */
.tl-item {
position: relative;
margin: 0 0 var(--space-4) calc(var(--space-5) + var(--space-4));
padding-left: var(--space-4);
border-left: 1px solid var(--line);
}
.tl-item:last-child { margin-bottom: 0; }
.tl-item::before {
content: '';
position: absolute;
left: -5px;
top: 7px;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 3px var(--bg);
}
.tl-date {
position: absolute;
left: calc(-1 * (var(--space-5) + var(--space-4)));
top: 4px;
width: var(--space-5);
text-align: right;
font-family: var(--font-mono);
font-size: 12.5px;
color: var(--ink-4);
font-variant-numeric: tabular-nums;
}
.mom-card {
border: 1px solid var(--line);
border-radius: 12px;
padding: var(--space-3) var(--space-4);
background: var(--bg-soft);
}
.mom-card > p { font-size: 15px; color: var(--ink-2); }
.mom-fig {
border-radius: 8px;
overflow: hidden;
margin: 0 0 var(--space-2);
max-width: 420px;
}
.mom-fig.vt { max-width: 260px; }
.mom-fig :global(img) { width: 100%; height: auto; }
.mom-tags {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
margin-top: var(--space-2);
}
.tag {
font-family: var(--font-mono);
font-size: 12px;
color: var(--ink-3);
background: var(--bg);
border-radius: 5px;
padding: 1px 7px;
white-space: nowrap;
}
/* 窄屏:日期卡到卡片上方,竖线左移收窄 */
@media (max-width: 760px) {
.tl-item { margin-left: var(--space-3); }
.tl-date {
position: static;
display: block;
width: auto;
text-align: left;
margin-bottom: var(--space-1);
}
}
@media (max-width: 560px) {
.mom-card { padding: var(--space-2) var(--space-3); }
.mom-fig { max-width: 100%; }
}
</style>