Files
hoelee-blog/src/components/PostList.astro
T
hoelee 041471d25e feat(listing): portfolio-first archive, case-study hero, row action links
/posts/ and /zh/posts/ become hybrid: three fixed-slug flagship cards above the
complete year/month archive, with the flagship block collapsing (inert, h=0) as
soon as the reader filters. /categories/case-studies/ becomes a portfolio: compact
two-column hero with a 21:9 deterministic topology banner, flagship ordering from
lib/portfolio.ts instead of pubDate, and one measured pill per card.

Also fixes what the screenshot review turned up: ZH archive rows and category chips
were printing raw English slugs, the two chip strips were unlabelled and read as one
list with a duplicated 'devops', card meta rows broke rhythm past four tags, the nav
had no current-section indicator, and mobile title links were under the 44px tap
minimum. scripts/probe-layout.mjs is the repeatable gate: 72/72 page x viewport
checks and the collapse test.
2026-10-27 04:06:54 +08:00

125 lines
4.9 KiB
Plaintext

---
import type { CollectionEntry } from 'astro:content';
import { getCollection } from 'astro:content';
import { postLang } from '../lib/lang';
import { tagCounts, tagLink } from '../lib/tags';
import { categoryLabel } from '../lib/categories';
interface Props {
posts: CollectionEntry<'posts'>[];
limit?: number;
lang?: 'en' | 'zh';
/**
* Measured headline per base slug (see lib/portfolio.ts) — rendered as a pill on a
* portfolio card. Omitted on the home page and tag pages, where a bare list is right.
*/
metrics?: Record<string, string>;
}
const { posts, limit, lang = 'en', metrics } = Astro.props;
const shown = limit ? posts.slice(0, limit) : posts;
const isZh = lang === 'zh';
const fmt = (d: Date) =>
d.toLocaleDateString(isZh ? 'zh-CN' : 'en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const updatedLabel = isZh ? '更新于' : 'Updated';
const catHref = (cat: string) => `/${isZh ? 'zh/categories' : 'categories'}/${cat}/`;
const og = (post: CollectionEntry<'posts'>) => post.data.ogImage || '/og-default.jpg';
// Tag pills only link when the tag has its own page (>= TAG_MIN_POSTS in this language).
const counts = tagCounts((await getCollection('posts', ({ data }) => !data.draft))
.filter((p) => postLang(p.slug) === lang));
// The row thumbnail is displayed at ~398 CSS px wide, so the list used to pull a
// 1200x630 PNG (~45 KB) per row for a 398px slot. Serve 400w/800w WebP derived from
// the same art instead (5.8 KB / 16.1 KB — see scripts/optimize-images.py).
const hasThumb = (post: CollectionEntry<'posts'>) => {
const p = og(post);
return p.startsWith('/og/') && p !== '/og-default.png' && p !== '/og-default.jpg';
};
const thumb = (post: CollectionEntry<'posts'>, w: 400 | 800) => {
// Thumbs are keyed by the base slug, not by the og file: a ZH post's ogImage is
// /og/zh-<slug>.jpg while the renderer only emits /og/thumb/<slug>-<w>.webp.
const base = post.slug.replace(/^zh\//, '');
return `/og/thumb/${base}-${w}.webp`;
};
// A case study is a portfolio artefact, so the row's action says so. The same rule is
// applied in Archive.astro for the /posts/ Featured block.
const ctaLabel = (category: string) => {
if (category === 'case-studies') return isZh ? '阅读案例复盘' : 'Read case study';
return isZh ? '阅读文章' : 'Read article';
};
const metricFor = (post: CollectionEntry<'posts'>) =>
metrics?.[post.slug.replace(/^zh\//, '')];
// A card that lists every tag wraps to two metadata lines on some rows and one on
// others, so the grid loses its rhythm (and the summary/CTA drift). Show four and
// fold the rest into a "+N" chip whose tooltip names them.
const TAG_LIMIT = 4;
---
<ul class="post-list">
{shown.map((post) => (
<li class="post-row">
<a class="thumb" href={`/posts/${post.slug}/`} aria-hidden="true" tabindex="-1">
<img
src={hasThumb(post) ? thumb(post, 800) : og(post)}
srcset={hasThumb(post) ? `${thumb(post, 400)} 400w, ${thumb(post, 800)} 800w` : undefined}
sizes="(max-width: 640px) 100vw, 398px"
alt=""
loading="lazy"
decoding="async"
width={hasThumb(post) ? 800 : 1200}
height={hasThumb(post) ? 420 : 630}
/>
</a>
<div class="body">
<a href={`/posts/${post.slug}/`}><h2>{post.data.title}</h2></a>
<div class="meta">
<time datetime={post.data.pubDate.toISOString()}>{fmt(post.data.pubDate)}</time>
{post.data.updatedDate && (
<>
<span class="sep">·</span>
<span class="updated" title={`${updatedLabel} ${fmt(post.data.updatedDate)}`}>
{updatedLabel} {fmt(post.data.updatedDate)}
</span>
</>
)}
<span class="sep">·</span>
<a class="tag" href={catHref(post.data.category)}>{categoryLabel(post.data.category, lang)}</a>
{post.data.tags.slice(0, TAG_LIMIT).map((t) => {
const href = tagLink(t, counts, lang);
return href ? <a class="tag" href={href}>{t}</a> : <span class="tag">{t}</span>;
})}
{post.data.tags.length > TAG_LIMIT && (
<span class="tag tag-more" title={post.data.tags.slice(TAG_LIMIT).join(', ')}>
+{post.data.tags.length - TAG_LIMIT}
</span>
)}
</div>
<p>{post.data.description}</p>
<div class="row-foot">
{/* A second visual target for the same destination — deliberately out of the
tab order so keyboard users get one stop per card, not two. */}
<a class="row-cta" href={`/posts/${post.slug}/`} tabindex="-1" aria-hidden="true">
{ctaLabel(post.data.category)} <span aria-hidden="true">→</span>
</a>
{metricFor(post) && (
<span class="row-metric">
<span class="pulse-dot" aria-hidden="true"></span>{metricFor(post)}
</span>
)}
</div>
</div>
</li>
))}
</ul>