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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user