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:
1 parent
68771101ea
commit
041471d25e
17 files changed
+1632
-76
No files matched your search
@@ -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`,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user