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.
This commit is contained in:
hoelee committed 2026-10-27 04:06:54 +08:00
1 parent 68771101ea
commit 041471d25e
17 files changed
+1632 -76

No files matched your search

+37 -11
View File
@@ -3,10 +3,12 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
import { SITE } from '../../config';
import PostList from '../../components/PostList.astro';
import CategoryArt from '../../components/CategoryArt.astro';
import PortfolioHero from '../../components/PortfolioHero.astro';
import { getCollection } from 'astro:content';
import { isEn } from '../../lib/lang';
import { sortForListing } from '../../lib/sort';
import { categoryMeta } from '../../lib/categories';
import { CASE_STUDY_FLAGSHIPS, caseStudyMetrics, orderCaseStudies } from '../../lib/portfolio';
export async function getStaticPaths() {
const posts = await getCollection('posts', isEn);
@@ -17,30 +19,54 @@ export async function getStaticPaths() {
const { category } = Astro.params;
const meta = categoryMeta(category as string);
const posts = sortForListing(
(await getCollection('posts', isEn)).filter((p) => p.data.category === category),
// case-studies is the portfolio: flagship write-ups lead instead of the newest post,
// and each card carries its measured headline. Every other category stays plain
// reverse-chronological.
const isPortfolio = category === 'case-studies';
const inCategory = (await getCollection('posts', isEn)).filter(
(p) => p.data.category === category,
);
const posts = isPortfolio ? orderCaseStudies(inCategory) : sortForListing(inCategory);
const metrics = isPortfolio ? caseStudyMetrics('en') : undefined;
const flagshipCount = isPortfolio
? posts.filter((p) => (CASE_STUDY_FLAGSHIPS as readonly string[]).includes(p.slug)).length
: 0;
const name = meta?.name.en ?? category;
const blurb = meta?.blurb.en;
---
<BaseLayout
title={`${name} — Mr Hoelee`}
description={blurb ?? `Posts in the ${category} category.`}
switchHref={`/zh/categories/${category}/`}
altLocaleUrl={`${SITE.url}/zh/categories/${category}/`}
>
<h1>{name}</h1>
{blurb && <p class="lede">{blurb}</p>}
{isPortfolio ? (
<PortfolioHero
lang="en"
title={name}
blurb={blurb}
count={posts.length}
flagshipCount={flagshipCount}
/>
) : (
<>
<h1>{name}</h1>
{blurb && <p class="lede">{blurb}</p>}
<div class="cat-art-wrap">
<CategoryArt slug={category} />
</div>
<div class="cat-art-wrap">
<CategoryArt slug={category} />
</div>
</>
)}
<p class="cat-count">
{posts.length} post{posts.length === 1 ? '' : 's'} in this category
</p>
<section id="case-list">
<p class="cat-count">
{posts.length} post{posts.length === 1 ? '' : 's'} in this category
</p>
<PostList posts={posts} lang="en" />
<PostList posts={posts} lang="en" metrics={metrics} />
</section>
</BaseLayout>
+15 -30
View File
@@ -5,31 +5,18 @@ import { getCollection } from 'astro:content';
import { isEn } from '../lib/lang';
import { SITE } from '../config';
import { sortForListing } from '../lib/sort';
import { pickFeatured } from '../lib/featured';
const posts = sortForListing(await getCollection('posts', isEn));
const engineeringPosts = posts.filter((p) => p.data.stream !== 'creative-lab');
const creativeLabPosts = posts.filter((p) => p.data.stream === 'creative-lab');
// Featured picks: fixed slugs, not "newest 4", so the front page stays stable after a
// publish and keeps answering "who is this and what has he actually run" (scale →
// automation → observability → a delivered business result). Falls back to the newest
// posts if a slug ever disappears.
const FEATURED_SLUGS = [
'running-production-infrastructure-solo',
'loop-engineering-without-a-coding-agent',
'one-prometheus-for-unraid-synology-and-a-vps',
'self-healing-digital-goods-entitlements',
];
const bySlug = new Map(posts.map((p) => [p.slug, p]));
const featured = FEATURED_SLUGS.map((s) => bySlug.get(s)).filter(Boolean);
const featuredPosts = featured.length === 4 ? featured : posts.slice(0, 4);
const featuredMetrics: Record<string, string> = {
'running-production-infrastructure-solo': '3 Hosts · 162 Containers',
'loop-engineering-without-a-coding-agent': '11 Cron Jobs · 8 Pure Loops',
'one-prometheus-for-unraid-synology-and-a-vps': '3 Hosts · 1 Prometheus',
'self-healing-digital-goods-entitlements': 'NocoDB + n8n + AList',
};
// Featured picks live in src/lib/featured.ts so the front page and the /posts/
// archive render the same flagship set with the same measured headline. Fixed slugs,
// not "newest 4", so the front page stays stable after a publish — see the comment
// in that module for the ordering rationale (scale → automation → observability →
// a delivered business result).
const featured = pickFeatured(posts, 'en', 4);
---
<BaseLayout title={SITE.title} description={SITE.description} altLocaleUrl={`${SITE.url}/zh/`}>
@@ -142,15 +129,13 @@ const featuredMetrics: Record<string, string> = {
<section id="featured">
<h2>Featured</h2>
<div class="featured-bento-grid">
{featuredPosts.map((post, idx) => {
const cleanSlug = post.slug.replace(/^zh\//, '');
const metric = featuredMetrics[cleanSlug] ?? post.data.category;
{featured.map((post, idx) => {
return (
<a class={`bento-card bento-item-${idx + 1}`} href={`/posts/${post.slug}/`}>
<a class={`bento-card bento-item-${idx + 1}`} href={post.href}>
<div class="bento-visual" aria-hidden="true">
<img
src={`/og/thumb/${cleanSlug}-800.webp`}
srcset={`/og/thumb/${cleanSlug}-400.webp 400w, /og/thumb/${cleanSlug}-800.webp 800w`}
src={post.thumb800}
srcset={`${post.thumb400} 400w, ${post.thumb800} 800w`}
sizes="(max-width: 640px) 60vw, 341px"
alt=""
loading="lazy"
@@ -163,14 +148,14 @@ const featuredMetrics: Record<string, string> = {
</div>
<div class="bento-content">
<div class="bento-top-meta">
<span class="featured-badge">{post.data.category}</span>
<span class="featured-badge">{post.category}</span>
<span class="bento-metric-pill">
<span class="pulse-dot"></span>
{metric}
{post.metric}
</span>
</div>
<h3>{post.data.title}</h3>
<p>{post.data.description}</p>
<h3>{post.title}</h3>
<p>{post.description}</p>
<span class="bento-cta">Read case study →</span>
</div>
</a>
+7 -1
View File
@@ -4,12 +4,18 @@ import Archive from '../../components/Archive.astro';
import { getCollection } from 'astro:content';
import { isEn } from '../../lib/lang';
import { sortForListing } from '../../lib/sort';
import { pickFeatured } from '../../lib/featured';
import { SITE } from '../../config';
// One static page for all posts: the filter (title/tags/category) runs client-side
// over this already-rendered list, so it costs no requests and Ctrl+F still works.
// Before 2026-10-16 this page rendered 71 full cards and stood 18,220px tall.
//
// Since 2026-10-27 the page is hybrid: three flagship case-study cards on top, then
// the full year/month archive. The featured block collapses as soon as the reader
// filters (Archive.astro), handing the viewport to the results.
const posts = sortForListing(await getCollection('posts', isEn));
const featured = pickFeatured(posts, 'en', 3);
---
<BaseLayout
@@ -23,5 +29,5 @@ const posts = sortForListing(await getCollection('posts', isEn));
{posts.length} posts, newest first. Filter by title, tag or category below — or read a
whole topic through its <a href="/tags/docker/">tag page</a>.
</p>
<Archive posts={posts} lang="en" />
<Archive posts={posts} lang="en" featured={featured} />
</BaseLayout>
+36 -9
View File
@@ -3,10 +3,12 @@ import BaseLayout from '../../../layouts/BaseLayout.astro';
import { SITE } from '../../../config';
import PostList from '../../../components/PostList.astro';
import CategoryArt from '../../../components/CategoryArt.astro';
import PortfolioHero from '../../../components/PortfolioHero.astro';
import { getCollection } from 'astro:content';
import { isZh } from '../../../lib/lang';
import { sortForListing } from '../../../lib/sort';
import { categoryMeta } from '../../../lib/categories';
import { CASE_STUDY_FLAGSHIPS, caseStudyMetrics, orderCaseStudies } from '../../../lib/portfolio';
export async function getStaticPaths() {
const posts = await getCollection('posts', isZh);
@@ -17,14 +19,25 @@ export async function getStaticPaths() {
const { category } = Astro.params;
const meta = categoryMeta(category as string);
const posts = sortForListing(
(await getCollection('posts', isZh)).filter((p) => p.data.category === category),
// Same portfolio treatment as the EN page: flagships lead, cards carry the measured
// headline, and the ZH twins are matched by base slug so both languages agree.
const isPortfolio = category === 'case-studies';
const inCategory = (await getCollection('posts', isZh)).filter(
(p) => p.data.category === category,
);
const posts = isPortfolio ? orderCaseStudies(inCategory) : sortForListing(inCategory);
const metrics = isPortfolio ? caseStudyMetrics('zh') : undefined;
const flagshipCount = isPortfolio
? posts.filter((p) =>
(CASE_STUDY_FLAGSHIPS as readonly string[]).includes(p.slug.replace(/^zh\//, '')),
).length
: 0;
const name = meta?.name.zh ?? category;
const blurb = meta?.blurb.zh;
---
<BaseLayout
title={`${name} — Mr Hoelee`}
description={blurb ?? `「${category}」分类下的文章。`}
@@ -32,14 +45,28 @@ const blurb = meta?.blurb.zh;
switchHref={`/categories/${category}/`}
altLocaleUrl={`${SITE.url}/categories/${category}/`}
>
<h1>{name}</h1>
{blurb && <p class="lede">{blurb}</p>}
{isPortfolio ? (
<PortfolioHero
lang="zh"
title={name}
blurb={blurb}
count={posts.length}
flagshipCount={flagshipCount}
/>
) : (
<>
<h1>{name}</h1>
{blurb && <p class="lede">{blurb}</p>}
<div class="cat-art-wrap">
<CategoryArt slug={category} />
</div>
<div class="cat-art-wrap">
<CategoryArt slug={category} />
</div>
</>
)}
<p class="cat-count">本分类共 {posts.length} 篇文章</p>
<section id="case-list">
<p class="cat-count">本分类共 {posts.length} 篇文章</p>
<PostList posts={posts} lang="zh" />
<PostList posts={posts} lang="zh" metrics={metrics} />
</section>
</BaseLayout>
+3 -1
View File
@@ -4,12 +4,14 @@ import Archive from '../../../components/Archive.astro';
import { getCollection } from 'astro:content';
import { isZh } from '../../../lib/lang';
import { sortForListing } from '../../../lib/sort';
import { pickFeatured } from '../../../lib/featured';
import { SITE } from '../../../config';
// The ZH archive. Until 2026-10-16 the ZH nav pointed "文章/关于" at /zh/ and this
// route did not exist at all: /posts/zh/ (a directory with no index) answered 403
// from nginx, and /zh/posts/ 404'd. Both are fixed here + in the nginx conf.
const posts = sortForListing(await getCollection('posts', isZh));
const featured = pickFeatured(posts, 'zh', 3);
---
<BaseLayout
@@ -24,5 +26,5 @@ const posts = sortForListing(await getCollection('posts', isZh));
共 {posts.length} 篇,按时间倒序。下方可按标题、标签或分类即时筛选,也可以走
<a href="/zh/tags/docker/">标签页</a> 把一个主题读完。
</p>
<Archive posts={posts} lang="zh" />
<Archive posts={posts} lang="zh" featured={featured} />
</BaseLayout>