Categories page: descriptions, per-category terminal illustrations, full ZH version
Deploy / build (push) Successful in 17s
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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -135,7 +135,7 @@ const nav = isZh
|
||||
<nav class="nav" aria-label="Main">
|
||||
<a class="brand" href="/">Mr<span class="dot"> Hoelee</span></a>
|
||||
<a href={isZh ? '/zh/' : '/posts/'}>{nav.posts}</a>
|
||||
<a href={isZh ? '/zh/' : '/categories/'}>{nav.categories}</a>
|
||||
<a href={isZh ? '/zh/categories/' : '/categories/'}>{nav.categories}</a>
|
||||
<a href={isZh ? '/zh/' : '/about/'}>{nav.about}</a>
|
||||
<a href="/rss.xml" title={nav.rss}>{nav.rss}</a>
|
||||
<button class="search-toggle" id="search-toggle" aria-label={isZh ? '搜索' : 'Search'}>⌕</button>
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Category metadata — single source of truth for the 7 official categories.
|
||||
* Names + descriptions in EN/ZH, one accent color per category (used by the
|
||||
* terminal-style illustration on the categories page).
|
||||
*
|
||||
* The enum itself stays in src/content.config.ts (zod); this file adds the
|
||||
* human-facing copy and ordering for listing pages.
|
||||
*/
|
||||
|
||||
export const CATEGORY_SLUGS = [
|
||||
'ai',
|
||||
'case-studies',
|
||||
'devops',
|
||||
'engineering',
|
||||
'notes',
|
||||
'tutorials',
|
||||
'web3',
|
||||
] as const;
|
||||
|
||||
export type CategorySlug = (typeof CATEGORY_SLUGS)[number];
|
||||
|
||||
interface CategoryMeta {
|
||||
slug: CategorySlug;
|
||||
/** Display name (Title Case EN / 中文) */
|
||||
name: { en: string; zh: string };
|
||||
/** One-paragraph description of what lives in this category */
|
||||
blurb: { en: string; zh: string };
|
||||
/** Accent color for the category illustration (terminal prompt / glyph) */
|
||||
accent: string;
|
||||
}
|
||||
|
||||
export const CATEGORY_META: Record<CategorySlug, CategoryMeta> = {
|
||||
ai: {
|
||||
slug: 'ai',
|
||||
name: { en: 'AI', zh: '人工智能' },
|
||||
blurb: {
|
||||
en: 'n8n workflows, Telegram bots, local LLMs, the Mem0 memory stack and TTS — AI automations I have actually shipped, mostly on self-hosted hardware.',
|
||||
zh: 'n8n 工作流、Telegram 机器人、本地大模型、Mem0 记忆栈与语音合成——真正落地运行过的 AI 自动化,大部分跑在自己托管的基础设施上。',
|
||||
},
|
||||
accent: '#0e94ff',
|
||||
},
|
||||
'case-studies': {
|
||||
slug: 'case-studies',
|
||||
name: { en: 'Case Studies', zh: '案例研究' },
|
||||
blurb: {
|
||||
en: 'How I built X, end to end: the problem, the debugging story, the fix and the measured result. This is my working portfolio.',
|
||||
zh: '「我是如何构建 X」的完整实录:问题、踩坑、修复与可衡量的结果。这是我的实战作品集。',
|
||||
},
|
||||
accent: '#3fb950',
|
||||
},
|
||||
devops: {
|
||||
slug: 'devops',
|
||||
name: { en: 'DevOps', zh: 'DevOps' },
|
||||
blurb: {
|
||||
en: 'Docker & Portainer, Traefik, Cloudflare tunnels, a ~140-container NAS homelab, backups and monitoring — my most hands-on, differentiated material.',
|
||||
zh: 'Docker/Portainer、Traefik、Cloudflare 隧道、约 140 个容器的 NAS 家庭机房、备份与监控——我最硬核、实操最深的领域。',
|
||||
},
|
||||
accent: '#295cff',
|
||||
},
|
||||
engineering: {
|
||||
slug: 'engineering',
|
||||
name: { en: 'Engineering', zh: '工程开发' },
|
||||
blurb: {
|
||||
en: 'Java & Spring, PHP and WordPress deep dives — how production web applications are really put together, from the code up.',
|
||||
zh: 'Java/Spring、PHP 与 WordPress 深度解析——生产级 Web 应用从代码层面开始的真实构建方式。',
|
||||
},
|
||||
accent: '#7c9cff',
|
||||
},
|
||||
notes: {
|
||||
slug: 'notes',
|
||||
name: { en: 'Notes', zh: '随记' },
|
||||
blurb: {
|
||||
en: 'Short, low-friction entries: fixes, gotchas and link roundups from day-to-day work. Quick reads that save someone an afternoon.',
|
||||
zh: '简短速记:问题修复、避坑记录与链接精选,来自日常开发的第一手经验。三分钟读完,省一个下午。',
|
||||
},
|
||||
accent: '#ff7ab6',
|
||||
},
|
||||
tutorials: {
|
||||
slug: 'tutorials',
|
||||
name: { en: 'Tutorials', zh: '教程' },
|
||||
blurb: {
|
||||
en: 'Beginner-friendly, follow-along how-tos. Copy the commands, run them, get a working result — then understand why it worked.',
|
||||
zh: '面向初学者的跟练教程:复制命令、动手运行、得到可用结果——再弄懂它为什么能work。',
|
||||
},
|
||||
accent: '#f5a524',
|
||||
},
|
||||
web3: {
|
||||
slug: 'web3',
|
||||
name: { en: 'Web3', zh: 'Web3' },
|
||||
blurb: {
|
||||
en: 'Solidity, Foundry, ERC-20/721 — honestly framed learning projects on testnets, not production DeFi.',
|
||||
zh: 'Solidity、Foundry、ERC-20/721——在测试网上如实记录的学习项目,而非生产级 DeFi。',
|
||||
},
|
||||
accent: '#b026ff',
|
||||
},
|
||||
};
|
||||
|
||||
/** Ordered list of all categories (alphabetical by slug). */
|
||||
export const ALL_CATEGORIES: CategoryMeta[] = CATEGORY_SLUGS.map((s) => CATEGORY_META[s]);
|
||||
|
||||
/** Safer lookup that tolerates unknown strings (e.g. old post data). */
|
||||
export function categoryMeta(slug: string): CategoryMeta | undefined {
|
||||
return (CATEGORY_META as Record<string, CategoryMeta | undefined>)[slug];
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import PostList from '../../components/PostList.astro';
|
||||
import CategoryArt from '../../components/CategoryArt.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { isEn } from '../../lib/lang';
|
||||
import { sortByUpdated } from '../../lib/sort';
|
||||
import { categoryMeta } from '../../lib/categories';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getCollection('posts', isEn);
|
||||
@@ -12,13 +14,31 @@ export async function getStaticPaths() {
|
||||
}
|
||||
|
||||
const { category } = Astro.params;
|
||||
const meta = categoryMeta(category as string);
|
||||
|
||||
const posts = sortByUpdated(
|
||||
(await getCollection('posts', isEn)).filter((p) => p.data.category === category),
|
||||
);
|
||||
|
||||
const name = meta?.name.en ?? category;
|
||||
const blurb = meta?.blurb.en;
|
||||
---
|
||||
|
||||
<BaseLayout title={`${category} — Mr Hoelee`} description={`Posts in the ${category} category.`}>
|
||||
<h1>Category: {category}</h1>
|
||||
<PostList posts={posts} />
|
||||
</BaseLayout>
|
||||
<BaseLayout
|
||||
title={`${name} — Mr Hoelee`}
|
||||
description={blurb ?? `Posts in the ${category} category.`}
|
||||
switchHref={`/zh/categories/${category}/`}
|
||||
>
|
||||
<h1>{name}</h1>
|
||||
{blurb && <p class="lede">{blurb}</p>}
|
||||
|
||||
<div class="cat-art-wrap">
|
||||
<CategoryArt slug={category} />
|
||||
</div>
|
||||
|
||||
<p class="cat-count">
|
||||
{posts.length} post{posts.length === 1 ? '' : 's'} in this category
|
||||
</p>
|
||||
|
||||
<PostList posts={posts} lang="en" />
|
||||
</BaseLayout>
|
||||
@@ -1,23 +1,31 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import CategoryGrid from '../../components/CategoryGrid.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { isEn } from '../../lib/lang';
|
||||
|
||||
const posts = await getCollection('posts', isEn);
|
||||
|
||||
const categories = [...new Set(posts.map((p) => p.data.category))].sort();
|
||||
|
||||
const count = (cat: string) => posts.filter((p) => p.data.category === cat).length;
|
||||
const counts: Record<string, number> = {};
|
||||
for (const p of posts) {
|
||||
const cat = p.data.category as string;
|
||||
counts[cat] = (counts[cat] ?? 0) + 1;
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout title="Categories — Mr Hoelee" description="Browse posts by category.">
|
||||
<h1>Categories</h1>
|
||||
<ul class="post-list">
|
||||
{categories.map((cat) => (
|
||||
<li>
|
||||
<a href={`/categories/${cat}/`}><h2>{cat}</h2></a>
|
||||
<p>{count(cat)} post{count(cat) === 1 ? '' : 's'}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</BaseLayout>
|
||||
<BaseLayout
|
||||
title="Categories — Mr Hoelee"
|
||||
description={`Browse ${posts.length} technical posts by category: case studies, DevOps, AI, engineering, tutorials and quick notes.`}
|
||||
switchHref="/zh/categories/"
|
||||
>
|
||||
<div class="page-head">
|
||||
<h1>Categories</h1>
|
||||
<p class="lede">
|
||||
Every post belongs to one of seven themes. The <strong>Case Studies</strong> are my
|
||||
working portfolio — end-to-end build logs with the debugging included. The{' '}
|
||||
<strong>Notes</strong> are the quick fixes. Start wherever matches what you're building.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CategoryGrid counts={counts} lang="en" />
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
||||
import PostList from '../../../components/PostList.astro';
|
||||
import CategoryArt from '../../../components/CategoryArt.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { isZh } from '../../../lib/lang';
|
||||
import { sortByUpdated } from '../../../lib/sort';
|
||||
import { categoryMeta } from '../../../lib/categories';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getCollection('posts', isZh);
|
||||
const cats = [...new Set(posts.map((p) => p.data.category))];
|
||||
return cats.map((cat) => ({ params: { category: cat } }));
|
||||
}
|
||||
|
||||
const { category } = Astro.params;
|
||||
const meta = categoryMeta(category as string);
|
||||
|
||||
const posts = sortByUpdated(
|
||||
(await getCollection('posts', isZh)).filter((p) => p.data.category === category),
|
||||
);
|
||||
|
||||
const name = meta?.name.zh ?? category;
|
||||
const blurb = meta?.blurb.zh;
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`${name} — Mr Hoelee`}
|
||||
description={blurb ?? `「${category}」分类下的文章。`}
|
||||
lang="zh"
|
||||
switchHref={`/categories/${category}/`}
|
||||
>
|
||||
<h1>{name}</h1>
|
||||
{blurb && <p class="lede">{blurb}</p>}
|
||||
|
||||
<div class="cat-art-wrap">
|
||||
<CategoryArt slug={category} />
|
||||
</div>
|
||||
|
||||
<p class="cat-count">本分类共 {posts.length} 篇文章</p>
|
||||
|
||||
<PostList posts={posts} lang="zh" />
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
||||
import CategoryGrid from '../../../components/CategoryGrid.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { isZh } from '../../../lib/lang';
|
||||
|
||||
const posts = await getCollection('posts', isZh);
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
for (const p of posts) {
|
||||
const cat = p.data.category as string;
|
||||
counts[cat] = (counts[cat] ?? 0) + 1;
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="分类 — Mr Hoelee"
|
||||
description={`按分类浏览 ${posts.length} 篇中文技术文章:案例研究、DevOps、AI、工程开发、教程与随记。`}
|
||||
lang="zh"
|
||||
switchHref="/categories/"
|
||||
>
|
||||
<div class="page-head">
|
||||
<h1>分类</h1>
|
||||
<p class="lede">
|
||||
每篇文章都属于七大主题之一。<strong>案例研究</strong>是我的实战作品集——包含完整踩坑过程的
|
||||
端到端构建实录;<strong>随记</strong>则是简短速记。从你正在做的事情开始看吧。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CategoryGrid counts={counts} lang="zh" />
|
||||
</BaseLayout>
|
||||
@@ -34,7 +34,7 @@ const posts = sortByUpdated(await getCollection('posts', isZh));
|
||||
{posts.length === 0 ? (
|
||||
<p><em>中文文章即将发布,敬请期待。</em></p>
|
||||
) : (
|
||||
<PostList posts={posts} />
|
||||
<PostList posts={posts} lang="zh" />
|
||||
)}
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -355,6 +355,107 @@ pre code {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Page head (lead paragraphs on listing pages) ---------- */
|
||||
.page-head h1 { margin: 0 0 0.75rem; }
|
||||
.lede {
|
||||
color: var(--muted);
|
||||
font-size: 1.05rem;
|
||||
max-width: 46rem;
|
||||
margin: 0 0 1.75rem;
|
||||
}
|
||||
p.lede { margin-bottom: 1.75rem; }
|
||||
|
||||
/* ---------- Category grid ---------- */
|
||||
.category-grid {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.category-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
color: var(--ink);
|
||||
height: 100%;
|
||||
}
|
||||
a.category-card:hover { border-color: var(--brand); }
|
||||
.category-card .art {
|
||||
line-height: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #0d1220;
|
||||
}
|
||||
.category-card .art svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
.category-card .body {
|
||||
padding: 1.05rem 1.2rem 1.2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
.category-card h2 {
|
||||
margin: 0 0 0.45rem;
|
||||
font-size: 1.15rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.category-card h2 .dot {
|
||||
width: 0.5em;
|
||||
height: 0.5em;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.category-card p {
|
||||
color: var(--muted);
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.93rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.category-card .count {
|
||||
margin-top: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
color: var(--faint);
|
||||
}
|
||||
.category-card .count .n { color: var(--brand); font-weight: 600; }
|
||||
.category-card.is-empty { opacity: 0.82; }
|
||||
.category-card.is-empty .count { color: var(--warn); }
|
||||
.category-card.is-empty .count .n { color: var(--warn); }
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.category-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.category-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
|
||||
/* ---------- Category detail hero art ---------- */
|
||||
.cat-art-wrap {
|
||||
margin: 1.1rem 0 0 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
line-height: 0;
|
||||
background: #0d1220;
|
||||
}
|
||||
.cat-art-wrap svg { width: 100%; height: auto; display: block; }
|
||||
.cat-count {
|
||||
margin: 1.25rem 0 1.75rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
/* ---------- Callouts (correct / wrong in tutorials) ---------- */
|
||||
.callout {
|
||||
border-left: 3px solid var(--brand);
|
||||
|
||||
Reference in New Issue
Block a user