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

+99
View File
@@ -0,0 +1,99 @@
import type { CollectionEntry } from 'astro:content';
import { readingMinutes } from './reading-time';
import { categoryLabel } from './categories';
type Post = CollectionEntry<'posts'>;
/**
* Flagship picks, shared by the home page and the archive's Featured block.
*
* Fixed slugs rather than "newest 4": the front page has to answer "who is this and
* what has he actually run" (scale → automation → observability → a delivered
* business result), and that answer must not change because a new post landed.
*/
export 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',
] as const;
/**
* The measured headline for each flagship — a number, not an adjective. Rendered as
* a pill next to the category badge so a reader scanning the grid gets the result
* before opening anything.
*/
export const FEATURED_METRICS: Record<string, { en: string; zh: string }> = {
'running-production-infrastructure-solo': {
en: '3 Hosts · 162 Containers',
zh: '3 台主机 · 162 个容器',
},
'loop-engineering-without-a-coding-agent': {
en: '11 Cron Jobs · 8 Pure Loops',
zh: '11 个定时任务 · 8 个循环',
},
'one-prometheus-for-unraid-synology-and-a-vps': {
en: '3 Hosts · 1 Prometheus',
zh: '3 台主机 · 1 个 Prometheus',
},
'self-healing-digital-goods-entitlements': {
en: 'NocoDB + n8n + AList',
zh: 'NocoDB + n8n + AList',
},
};
export interface FeaturedCard {
/** Slug as it renders in the URL (ZH posts keep their `zh/` prefix). */
slug: string;
href: string;
title: string;
description: string;
category: string;
categoryLabel: string;
metric: string;
minutes: number;
thumb400: string;
thumb800: string;
}
/**
* Turn the fixed slug list into render-ready cards for one language.
*
* `posts` must already be filtered to a single language (the listing pages do that
* with `isEn`/`isZh`). Falls back to the newest posts of that language if a
* flagship slug ever disappears, so a renamed file can never empty the block.
*/
export function pickFeatured(
posts: Post[],
lang: 'en' | 'zh' = 'en',
limit: number = FEATURED_SLUGS.length,
): FeaturedCard[] {
const base = (slug: string) => slug.replace(/^zh\//, '');
const bySlug = new Map(posts.map((p) => [base(p.slug), p]));
const picked = FEATURED_SLUGS.map((s) => bySlug.get(s)).filter(
(p): p is Post => Boolean(p),
);
const source = picked.length >= Math.min(limit, FEATURED_SLUGS.length)
? picked
: posts.slice(0, limit);
return source.slice(0, limit).map((post) => {
const clean = base(post.slug);
return {
slug: post.slug,
href: `/posts/${post.slug}/`,
title: post.data.title,
description: post.data.description,
category: post.data.category,
categoryLabel: categoryLabel(post.data.category, lang),
metric: FEATURED_METRICS[clean]?.[lang] ?? categoryLabel(post.data.category, lang),
minutes: readingMinutes(post.body),
// Thumbs are keyed by the base slug, never by the og file name: a ZH post's
// ogImage is /og/zh-<slug>.jpg while the renderer only emits
// /og/thumb/<base>-<w>.webp.
thumb400: `/og/thumb/${clean}-400.webp`,
thumb800: `/og/thumb/${clean}-800.webp`,
};
});
}
+73
View File
@@ -0,0 +1,73 @@
import type { CollectionEntry } from 'astro:content';
import { sortForListing } from './sort';
type Post = CollectionEntry<'posts'>;
/**
* Which case studies lead the portfolio, and why.
*
* The consultant's round-2 ruling dropped plain reverse-chronological order for this
* category: a portfolio has to open with proof of scale, then proof of the hardest
* work, not with whatever happened to be written last. These four cover the four
* claims a hiring manager is checking — fleet scale, GPU/local-AI work, end-to-end
* automation, and a delivered business result.
*
* `pickFeatured` (lib/featured.ts) reuses a subset of these for the home page and the
* /posts/ Featured block, so the site never tells two different "flagship" stories.
*/
export const CASE_STUDY_FLAGSHIPS = [
'running-production-infrastructure-solo',
'self-hosted-tts-cosyvoice2-n8n-gpu',
'loop-engineering-without-a-coding-agent',
'self-healing-digital-goods-entitlements',
] as const;
/**
* A measured headline per case study — shown as a pill on the portfolio cards.
*
* Only entries taken from the post body are listed here (container counts, VRAM, cron
* counts, release counts). A post without a verifiable number gets no pill rather than
* a decorative one; `orderCaseStudies` never invents a metric.
*/
export const CASE_STUDY_METRICS: Record<string, { en: string; zh: string }> = {
'running-production-infrastructure-solo': { en: '3 Hosts · 162 Containers', zh: '3 台主机 · 162 个容器' },
'one-prometheus-for-unraid-synology-and-a-vps': { en: '3 Hosts · 1 Prometheus', zh: '3 台主机 · 1 个 Prometheus' },
'loop-engineering-without-a-coding-agent': { en: '11 Cron Jobs · 8 Loops', zh: '11 个定时任务 · 8 个循环' },
'self-healing-digital-goods-entitlements': { en: 'NocoDB + n8n + AList', zh: 'NocoDB + n8n + AList' },
'self-hosted-tts-cosyvoice2-n8n-gpu': { en: 'RTX 3060 · 3.2 GB VRAM', zh: 'RTX 3060 · 3.2 GB 显存' },
'self-hosted-speech-to-text-api': { en: '3× faster than typing', zh: '比打字快 3 倍' },
'how-i-vetted-20-vps-providers-with-parallel-subagents': { en: '20 VPS · 3 hours', zh: '20 家 VPS · 3 小时' },
'how-i-built-the-digikedai-telegram-bot': { en: '24/7 instant reply', zh: '24/7 即时回复' },
'how-i-built-an-automated-music-video-pipeline': { en: 'Remotion · Docker CDP', zh: 'Remotion · Docker CDP' },
'from-audio-to-a-4-minute-ai-music-video': { en: '4-minute MV · one pipeline', zh: '4 分钟 MV · 一条流水线' },
'how-i-host-this-blog': { en: 'Astro · Gitea Actions', zh: 'Astro · Gitea Actions' },
'self-hosting-mem0-memory-stack': { en: 'mem0 · pgvector · LiteLLM', zh: 'mem0 · pgvector · LiteLLM' },
'shipping-an-ai-photo-editor-as-a-wordpress-plugin': { en: '22 releases', zh: '22 个版本' },
'ai-furniture-compositing-with-flux-kontext': { en: 'FLUX.1 Kontext', zh: 'FLUX.1 Kontext' },
'yue2-on-a-12gb-gpu-songs-that-lost-their-endings': { en: 'YuE2 · 12 GB VRAM', zh: 'YuE2 · 12 GB 显存' },
'generating-full-length-songs-with-yue2-comfyui': { en: 'ComfyUI · YuE2', zh: 'ComfyUI · YuE2' },
};
const baseSlug = (slug: string) => slug.replace(/^zh\//, '');
/**
* Flagships first (in the declared order), then everything else newest-first.
* Stable across languages: the same slugs lead /categories/case-studies/ and
* /zh/categories/case-studies/.
*/
export function orderCaseStudies(posts: Post[]): Post[] {
const bySlug = new Map(posts.map((p) => [baseSlug(p.slug), p]));
const flagshipPosts = CASE_STUDY_FLAGSHIPS.map((s) => bySlug.get(s)).filter(
(p): p is Post => Boolean(p),
);
const flagshipSet = new Set(flagshipPosts.map((p) => p.slug));
const rest = sortForListing(posts.filter((p) => !flagshipSet.has(p.slug)));
return [...flagshipPosts, ...rest];
}
/** Metrics map for PostList, keyed by base slug so an EN page and its ZH twin match. */
export function caseStudyMetrics(lang: 'en' | 'zh'): Record<string, string> {
const out: Record<string, string> = {};
for (const [slug, value] of Object.entries(CASE_STUDY_METRICS)) out[slug] = value[lang];
return out;
}