Categories page: descriptions, per-category terminal illustrations, full ZH version
Deploy / build (push) Successful in 17s

- 7 categories now listed with name, blurb, post count and a
  per-category terminal-style SVG illustration (CategoryArt)
- empty categories show '0 posts · coming soon' (non-link, no soft-404)
- new zh pages: /zh/categories/ index + /zh/categories/[category]/ detail
- PostList is locale-aware (zh-CN dates, zh category links)
- zh nav '分类' now points to /zh/categories/
- category detail pages get art + description header
- language switch wired both ways (EN <-> ZH)
This commit is contained in:
hoelee committed 2026-09-13 23:50:08 +08:00
1 parent 730a936137
commit 4cf52e04c1
11 files changed
+577 -25

No files matched your search

+181
View File
@@ -0,0 +1,181 @@
---
/**
* CategoryArt — terminal-style SVG illustration per category.
* Matches the banner-gen visual language (dark #0d1220 canvas, terminal
* window with traffic-light dots, JetBrains Mono, status colors) but with a
* per-category accent color, so each category is scannable at a glance.
* Inline SVG inherits the blog's self-hosted JetBrains Mono for text lines.
*/
import { categoryMeta } from '../lib/categories';
interface Props {
slug: string;
}
const { slug } = Astro.props;
const meta = categoryMeta(slug);
// line types: prompt (accent), cmd #e6edf3, ok #3fb950, hl #f5a524, dim #6b7c99
type Line = { t: 'prompt' | 'cmd' | 'ok' | 'hl' | 'dim'; c: string };
const ART: Record<string, { title: string; lines: Line[]; glyph: 'text' | 'check'; gc: string }> = {
ai: {
title: 'n8n — ai workflows',
lines: [
{ t: 'dim', c: 'telegram ⇄ llm ⇄ memory' },
{ t: 'prompt', c: '>' },
{ t: 'cmd', c: ' user: "help me book a slot"' },
{ t: 'ok', c: 'bot: done · calendar updated' },
{ t: 'hl', c: 'mem0 long-term memory · on' },
],
glyph: 'text',
gc: 'λ',
},
'case-studies': {
title: 'mr@hoelee — portfolio',
lines: [
{ t: 'dim', c: 'drwxr-xr-x hoelee projects' },
{ t: 'prompt', c: '$' },
{ t: 'cmd', c: ' cd digikedai-bot && ship' },
{ t: 'ok', c: 'deployed · measured · written up' },
{ t: 'hl', c: '→ end-to-end build logs' },
],
glyph: 'check',
gc: '',
},
devops: {
title: 'root@homelab',
lines: [
{ t: 'dim', c: '~140 containers · 6 hosts' },
{ t: 'prompt', c: '$' },
{ t: 'cmd', c: ' docker compose up -d' },
{ t: 'ok', c: 'traefik · pihole · n8n — Up' },
{ t: 'ok', c: 'backups 3-2-1 · verified' },
],
glyph: 'text',
gc: '$',
},
engineering: {
title: 'src — spring & wordpress',
lines: [
{ t: 'dim', c: 'Java · PHP · TypeScript' },
{ t: 'prompt', c: '$' },
{ t: 'cmd', c: ' mvn clean package' },
{ t: 'ok', c: 'BUILD SUCCESS · 42s' },
{ t: 'hl', c: '{ "status": "200 OK" }' },
],
glyph: 'text',
gc: '</>',
},
notes: {
title: 'scratchpad — gotchas',
lines: [
{ t: 'dim', c: '3-minute reads' },
{ t: 'hl', c: 'fix · traefik forward-auth' },
{ t: 'hl', c: 'fix · cv 301 → 404 chain' },
{ t: 'dim', c: '→ lessons, fast' },
],
glyph: 'text',
gc: '!',
},
tutorials: {
title: 'learn — follow along',
lines: [
{ t: 'dim', c: 'copy · run · understand' },
{ t: 'prompt', c: '$' },
{ t: 'cmd', c: ' npm create astro@latest' },
{ t: 'ok', c: 'step 01 · scaffolded' },
{ t: 'ok', c: 'step 02 · deployed' },
],
glyph: 'text',
gc: '>_',
},
web3: {
title: 'foundry — testnet 31337',
lines: [
{ t: 'dim', c: 'solidity · honest scope' },
{ t: 'prompt', c: '$' },
{ t: 'cmd', c: ' forge test' },
{ t: 'ok', c: '[PASS] testMintERC721' },
{ t: 'ok', c: '[PASS] testTransferERC20' },
],
glyph: 'text',
gc: '0x',
},
};
const fallback: (typeof ART)[string] = {
title: slug,
lines: [
{ t: 'dim', c: 'mr hoelee · blog' },
{ t: 'prompt', c: '$' },
{ t: 'cmd', c: ' ls posts' },
],
glyph: 'text',
gc: '#',
};
const art = ART[slug] ?? fallback;
const accent = meta?.accent ?? '#295cff';
const LINE_COLORS: Record<Line['t'], string> = {
prompt: accent,
cmd: '#e6edf3',
ok: '#3fb950',
hl: '#f5a524',
dim: '#6b7c99',
};
---
<svg
viewBox="0 0 640 320"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label={meta ? `${meta.name.en} category` : 'Category illustration'}
focusable="false"
>
<rect width="640" height="320" fill="#0d1220" />
<circle cx="600" cy="-20" r="150" fill={accent} opacity="0.12" />
<circle cx="10" cy="330" r="130" fill="#0e94ff" opacity="0.08" />
<!-- terminal window -->
<rect x="28" y="24" width="584" height="268" rx="12" fill="#161e2e" stroke="#2a3550" stroke-width="1" />
<!-- titlebar -->
<path
d="M 28 36 a 12 12 0 0 1 12 -12 h 560 a 12 12 0 0 1 12 12 v 32 h -584 z"
fill="#131a2a"
/>
<circle cx="52" cy="46" r="6" fill="#ff5c5c" />
<circle cx="74" cy="46" r="6" fill="#f5a524" />
<circle cx="96" cy="46" r="6" fill="#3fb950" />
<text x="118" y="52" fill="#6b7c99" font-family="'JetBrains Mono', ui-monospace, monospace" font-size="15">{art.title}</text>
<!-- body lines -->
{art.lines.map((ln, i) => (
<text
x="52"
y={112 + i * 36}
fill={LINE_COLORS[ln.t]}
font-family="'JetBrains Mono', ui-monospace, monospace"
font-size="19"
>{ln.c}</text>
))}
<!-- watermark glyph -->
{art.glyph === 'text' ? (
<text
x="588"
y="262"
text-anchor="end"
fill={accent}
opacity="0.16"
font-family="'JetBrains Mono', ui-monospace, monospace"
font-weight="700"
font-size="96"
>{art.gc}</text>
) : (
<g opacity="0.22">
<circle cx="560" cy="196" r="58" fill="none" stroke={accent} stroke-width="10" />
<path d="M 536 196 l 17 17 l 32 -36" fill="none" stroke={accent} stroke-width="12" stroke-linecap="round" stroke-linejoin="round" />
</g>
)}
</svg>
+54
View File
@@ -0,0 +1,54 @@
---
import CategoryArt from './CategoryArt.astro';
import { ALL_CATEGORIES } from '../lib/categories';
interface Props {
/** Per-slug post count for the current locale. */
counts: Record<string, number>;
lang?: 'en' | 'zh';
}
const { counts, lang = 'en' } = Astro.props;
const isZh = lang === 'zh';
const label = (count: number) => {
if (isZh) return count === 0 ? '0 篇 · 敬请期待' : `${count} 篇`;
return count === 0 ? '0 posts · coming soon' : `${count} post${count === 1 ? '' : 's'}`;
};
---
<ul class="category-grid">
{ALL_CATEGORIES.map((cat) => {
const count = counts[cat.slug] ?? 0;
const href = count > 0 ? `/${isZh ? 'zh/' : ''}categories/${cat.slug}/` : null;
const cardBodyAttrs = href ? { href } : {};
const body = (
<>
<div class="art">
<CategoryArt slug={cat.slug} />
</div>
<div class="body">
<h2>
<span class="dot" style={`background:${cat.accent}`} aria-hidden="true"></span>
{cat.name[isZh ? 'zh' : 'en']}
</h2>
<p>{cat.blurb[isZh ? 'zh' : 'en']}</p>
<span class="count" class:list={{ 'is-empty': count === 0 }}>
<span class="n">{count}</span> {label(count)}
</span>
</div>
</>
);
return (
<li>
{href ? (
<a {...cardBodyAttrs} class="category-card">
{body}
</a>
) : (
<div class="category-card is-empty">{body}</div>
)}
</li>
);
})}
</ul>
+15 -5
View File
@@ -4,13 +4,23 @@ import type { CollectionEntry } from 'astro:content';
interface Props {
posts: CollectionEntry<'posts'>[];
limit?: number;
lang?: 'en' | 'zh';
}
const { posts, limit } = Astro.props;
const { posts, limit, lang = 'en' } = Astro.props;
const shown = limit ? posts.slice(0, limit) : posts;
const isZh = lang === 'zh';
const fmt = (d: Date) =>
d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
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.png';
---
@@ -28,13 +38,13 @@ const og = (post: CollectionEntry<'posts'>) => post.data.ogImage || '/og-default
{post.data.updatedDate && (
<>
<span class="sep">·</span>
<span class="updated" title={`Updated ${fmt(post.data.updatedDate)}`}>
Updated {fmt(post.data.updatedDate)}
<span class="updated" title={`${updatedLabel} ${fmt(post.data.updatedDate)}`}>
{updatedLabel} {fmt(post.data.updatedDate)}
</span>
</>
)}
<span class="sep">·</span>
<a class="tag" href={`/categories/${post.data.category}/`}>{post.data.category}</a>
<a class="tag" href={catHref(post.data.category)}>{post.data.category}</a>
{post.data.tags.map((t) => <span class="tag">{t}</span>)}
</div>
<p>{post.data.description}</p>