Skip to content

Commit 0cc51a8

Browse files
committed
feat: implement hierarchical blog categories, tag navigation, and layout improvements
- Add CategorySidebar component with support for nested categories (e.g., 'Java > Micronaut') - Implement mobile-friendly horizontal category carousel - Extract PostPreview component for consistent listing across index, category, and tag pages - Create dynamic routes for categories (/blog/category/[...category]) and tags (/blog/tag/[tag]) - Add Pagefind filters for category and tag metadata - Improve layout stability and prevent mobile overflow with min-w-0 and unified container widths - Fix series navigation to display as a numbered list (ol)
1 parent b491f7e commit 0cc51a8

19 files changed

Lines changed: 387 additions & 70 deletions

File tree

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
---
2+
import { getCollection } from "astro:content";
3+
4+
interface Props {
5+
activeCategoryPath?: string; // e.g. "Java/Spring"
6+
}
7+
8+
const { activeCategoryPath } = Astro.props;
9+
10+
const allPosts = await getCollection("posts", ({ data }) => !data.draft && data.publish !== false);
11+
12+
// Build Tree Structure
13+
interface CategoryNode {
14+
name: string;
15+
fullPath: string;
16+
count: number;
17+
children: Record<string, CategoryNode>;
18+
}
19+
20+
const tree: Record<string, CategoryNode> = {};
21+
22+
allPosts.forEach(post => {
23+
const parts = post.data.category.split(">").map(s => s.trim());
24+
let currentLevel = tree;
25+
let currentPath = "";
26+
27+
parts.forEach((part, index) => {
28+
currentPath = index === 0 ? part : `${currentPath}/${part}`;
29+
if (!currentLevel[part]) {
30+
currentLevel[part] = {
31+
name: part,
32+
fullPath: currentPath,
33+
count: 0,
34+
children: {}
35+
};
36+
}
37+
currentLevel[part].count++;
38+
currentLevel = currentLevel[part].children;
39+
});
40+
});
41+
42+
const sortedRootCategories = Object.values(tree).sort((a, b) => a.name.localeCompare(b.name));
43+
const totalPosts = allPosts.length;
44+
---
45+
46+
<div class="category-sidebar">
47+
<!-- Mobile Carousel (Shown only on small screens) -->
48+
<div class="lg:hidden mb-8">
49+
<div class="flex overflow-x-auto pb-4 gap-2 no-scrollbar scroll-smooth">
50+
<a
51+
href="/blog"
52+
class={`whitespace-nowrap px-4 py-1.5 rounded-full border text-sm transition-colors ${
53+
!activeCategoryPath
54+
? "bg-[var(--color-accent)] border-[var(--color-accent)] text-white"
55+
: "border-[var(--color-border)] text-[var(--color-text-secondary)]"
56+
}`}
57+
>
58+
All ({totalPosts})
59+
</a>
60+
{sortedRootCategories.map(cat => (
61+
<a
62+
href={`/blog/category/${cat.fullPath}`}
63+
class={`whitespace-nowrap px-4 py-1.5 rounded-full border text-sm transition-colors ${
64+
activeCategoryPath === cat.fullPath || activeCategoryPath?.startsWith(`${cat.fullPath}/`)
65+
? "bg-[var(--color-accent)] border-[var(--color-accent)] text-white"
66+
: "border-[var(--color-border)] text-[var(--color-text-secondary)]"
67+
}`}
68+
>
69+
{cat.name} ({cat.count})
70+
</a>
71+
))}
72+
</div>
73+
</div>
74+
75+
<!-- Desktop Sidebar -->
76+
<nav class="hidden lg:flex flex-col gap-1">
77+
<h2 class="text-xs font-bold uppercase tracking-wider text-[var(--color-text-secondary)] mb-4 px-3">Categories</h2>
78+
<ul class="space-y-1">
79+
<li>
80+
<a
81+
href="/blog"
82+
class={`flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-colors duration-200 no-underline ${
83+
!activeCategoryPath
84+
? "bg-[var(--color-accent)]/10 text-[var(--color-accent)] font-semibold"
85+
: "text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-secondary)] hover:text-[var(--color-text)]"
86+
}`}
87+
>
88+
<span>All Posts</span>
89+
<span class="text-xs opacity-60">{totalPosts}</span>
90+
</a>
91+
</li>
92+
{
93+
sortedRootCategories.map((cat) => {
94+
const hasChildren = Object.keys(cat.children).length > 0;
95+
const isActive = activeCategoryPath === cat.fullPath;
96+
const isParentOfActive = activeCategoryPath?.startsWith(`${cat.fullPath}/`);
97+
const isOpen = isActive || isParentOfActive;
98+
99+
return (
100+
<li class="category-item">
101+
<div class="flex items-center">
102+
<a
103+
href={`/blog/category/${cat.fullPath}`}
104+
class={`flex-1 flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-colors duration-200 no-underline ${
105+
isActive
106+
? "bg-[var(--color-accent)]/10 text-[var(--color-accent)] font-semibold"
107+
: isParentOfActive
108+
? "text-[var(--color-accent)] font-medium"
109+
: "text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-secondary)] hover:text-[var(--color-text)]"
110+
}`}
111+
>
112+
<span class="capitalize">{cat.name}</span>
113+
<span class="text-xs opacity-60">{cat.count}</span>
114+
</a>
115+
</div>
116+
117+
{hasChildren && (
118+
<ul class={`ml-4 mt-1 space-y-1 border-l border-[var(--color-border)] ${isOpen ? 'block' : 'hidden'}`}>
119+
{Object.values(cat.children).sort((a, b) => a.name.localeCompare(b.name)).map(child => (
120+
<li>
121+
<a
122+
href={`/blog/category/${child.fullPath}`}
123+
class={`flex items-center justify-between pl-4 pr-3 py-1.5 rounded-r-lg text-xs transition-colors duration-200 no-underline ${
124+
activeCategoryPath === child.fullPath
125+
? "text-[var(--color-accent)] font-semibold border-l-2 border-[var(--color-accent)] -ml-[1px]"
126+
: "text-[var(--color-text-secondary)] hover:text-[var(--color-text)] hover:bg-[var(--color-bg-secondary)]"
127+
}`}
128+
>
129+
<span class="capitalize">{child.name}</span>
130+
<span class="text-xs opacity-60">{child.count}</span>
131+
</a>
132+
</li>
133+
))}
134+
</ul>
135+
)}
136+
</li>
137+
);
138+
})
139+
}
140+
</ul>
141+
</nav>
142+
</div>
143+
144+
<style>
145+
.no-scrollbar::-webkit-scrollbar {
146+
display: none;
147+
}
148+
.no-scrollbar {
149+
-ms-overflow-style: none;
150+
scrollbar-width: none;
151+
}
152+
</style>

src/components/PostPreview.astro

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
import { postUrl } from "../lib/slug";
3+
4+
interface Props {
5+
post: any;
6+
}
7+
8+
const { post: p } = Astro.props;
9+
---
10+
11+
<div
12+
class="group block p-4 sm:p-5 rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-secondary)] hover:border-[var(--color-accent)]/40 transition-all duration-200"
13+
>
14+
<div class="flex items-start justify-between gap-4">
15+
<div class="min-w-0 flex-1">
16+
<h2 class="text-lg font-semibold !mt-0 !mb-1 break-words">
17+
<a href={postUrl(p.slug)} class="text-[var(--color-text)] group-hover:text-[var(--color-accent)] transition-colors no-underline">
18+
{p.data.title}
19+
</a>
20+
</h2>
21+
{p.data.description && (
22+
<p class="text-sm text-[var(--color-text-secondary)] line-clamp-2 !my-0">
23+
{p.data.description}
24+
</p>
25+
)}
26+
</div>
27+
<time class="text-xs text-[var(--color-text-secondary)] whitespace-nowrap pt-1" datetime={p.data.date.toISOString()}>
28+
{p.data.date.toISOString().slice(0, 10)}
29+
</time>
30+
</div>
31+
<div class="flex items-center gap-2 mt-3 flex-wrap">
32+
{p.data.series && (
33+
<span class="text-xs px-2 py-0.5 rounded-full border border-[var(--color-accent)] text-[var(--color-accent)]">
34+
{p.data.series}{p.data.order != null ? ` · ${p.data.order}편` : ""}
35+
</span>
36+
)}
37+
<a
38+
href={`/blog/category/${p.data.category.split(">").map(s => s.trim()).join("/")}`}
39+
class="text-xs px-2 py-0.5 rounded-full bg-[var(--color-tag-bg)] text-[var(--color-tag-text)] no-underline hover:opacity-80 transition-opacity"
40+
>
41+
{p.data.category}
42+
</a>
43+
{p.data.tags.map((tag: string) => (
44+
<a
45+
href={`/blog/tag/${tag}`}
46+
class="text-xs text-[var(--color-text-secondary)] no-underline hover:text-[var(--color-accent)] transition-colors"
47+
>
48+
#{tag}
49+
</a>
50+
))}
51+
</div>
52+
</div>

src/components/Series.astro

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,21 +44,20 @@ const seriesPosts = posts
4444
<polyline points="6 9 12 15 18 9" />
4545
</svg>
4646
</summary>
47-
<ol class="space-y-2 px-4 pb-4 pt-1" type="1">
47+
<ol class="space-y-2 px-8 pb-4 pt-1 list-decimal text-sm text-[var(--color-text-secondary)]">
4848
{seriesPosts.map((post) => {
4949
const isCurrent = post.slug === currentSlug;
5050
return (
51-
<li class="flex items-start gap-2">
52-
<span class="mt-1.5 w-1.5 h-1.5 rounded-full bg-[var(--color-text-secondary)] flex-shrink-0 opacity-50" />
51+
<li>
5352
{isCurrent ? (
54-
<span class="text-sm font-medium text-[var(--color-accent)]">
53+
<span class="font-medium text-[var(--color-accent)]">
5554
{post.data.title}
5655
<span class="ml-2 text-xs text-[var(--color-text-secondary)] font-normal">(현재)</span>
5756
</span>
5857
) : (
5958
<a
6059
href={`/blog/${post.slug}`}
61-
class="text-sm text-[var(--color-text)] hover:text-[var(--color-accent)] transition-colors"
60+
class="text-[var(--color-text)] hover:text-[var(--color-accent)] transition-colors"
6261
>
6362
{post.data.title}
6463
</a>

src/content/posts/2026-02-22-g1gc-optimization/index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
title: "Java G1GC 최적화 실무기: Full GC 제거부터 tail latency 분석까지"
33
description: "JDK 8 G1GC 서비스에서 2-3초 Full GC를 제거하고, GC 로그 심층 분석으로 tail latency 원인을 추적한 실무 경험을 정리했습니다."
44
date: 2026-02-22
5-
category: "dev"
5+
category: "Java"
66
tags: ["java", "jvm", "jdk", "gc", "performance"]
77
draft: false
88
publish: true

src/content/posts/2026-02-22-micronaut-1-intro/index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
title: "AOT 기반 DI의 시대를 열다 — Micronaut 소개와 역사적 맥락"
33
description: "Spring이 reflection 기반 DI를 선택할 수밖에 없었던 2002년의 Java 생태계, 그리고 그 한계를 컴파일 타임에 해결한 Micronaut의 등장 배경을 살펴봅니다."
44
date: 2026-02-22
5-
category: "dev"
5+
category: "Java > Micronaut"
66
tags: ["Java", "Micronaut", "Quarkus", "Helidon", "Framework", "AOT", "DI", "Spring"]
77
series: "micronaut-guide"
88
order: 1

src/content/posts/2026-02-22-micronaut-2-first-project/index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
title: "첫 Micronaut 프로젝트 만들기"
33
description: "Micronaut Launch로 프로젝트를 생성하고, 첫 REST API를 만들고, Bean Scope와 테스트까지 직접 실행해봅니다."
44
date: 2026-02-22
5-
category: "dev"
5+
category: "Java > Micronaut"
66
tags: ["Java", "Micronaut", "Framework", "DI", "REST", "Spring"]
77
series: "micronaut-guide"
88
order: 2

src/content/posts/2026-02-22-micronaut-3-internals/index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
title: "컴파일 타임의 마법 — Micronaut 내부 동작 심층 분석"
33
description: "Micronaut annotation processor가 생성하는 BeanDefinition, BeanDefinitionReference, 컴파일 타임 AOP 프록시의 실제 바이트코드를 직접 확인합니다."
44
date: 2026-02-22
5-
category: "dev"
5+
category: "Java > Micronaut"
66
tags: ["Java", "Micronaut", "AOT", "AOP", "DI", "GraalVM", "Internals"]
77
series: "micronaut-guide"
88
order: 3

src/content/posts/2026-02-22-micronaut-4-vs-spring/index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
title: "Spring과 Micronaut 비교 — 무엇을 선택할까"
33
description: "시작 시간, 메모리, 생태계, 개발 경험을 실제 수치로 비교하고, 각 프레임워크가 강점을 발휘하는 상황을 정리합니다."
44
date: 2026-02-22
5-
category: "dev"
5+
category: "Java > Micronaut"
66
tags: ["Java", "Micronaut", "Spring", "Framework", "AOT", "GraalVM", "Native"]
77
series: "micronaut-guide"
88
order: 4

src/content/posts/2026-02-22-micronaut-5-http-and-vt/index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
title: "HTTP 서버 모델과 Virtual Thread — Netty, EventLoop, 그리고 가상 스레드가 바꾼 선택 기준"
33
description: "Tomcat과 Netty가 epoll 이후에 어떻게 달라지는지, Virtual Thread가 등장하면서 Spring MVC/WebFlux/Micronaut 중 무엇을 선택해야 하는지를 정리합니다."
44
date: 2026-02-22
5-
category: "dev"
5+
category: "Java > Micronaut"
66
tags: ["Java", "Micronaut", "Spring", "Netty", "EventLoop", "VirtualThread", "R2DBC", "JDBC"]
77
series: "micronaut-guide"
88
order: 5

src/content/posts/2026-02-22-micronaut-6-data-jdbc/index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
title: "Micronaut Data JDBC와 Virtual Thread — 컴파일 타임 쿼리 생성과 데이터 접근 계층"
33
description: "micronaut-data-jdbc의 컴파일 타임 쿼리 생성 원리, HikariCP 설정, @Transactional, 그리고 Virtual Thread 환경에서의 커넥션 풀 전략을 실전 예제로 설명합니다."
44
date: 2026-02-22
5-
category: "dev"
5+
category: "Java > Micronaut"
66
tags: ["Java", "Micronaut", "JDBC", "DataJDBC", "VirtualThread", "HikariCP", "Transaction"]
77
series: "micronaut-guide"
88
order: 6

0 commit comments

Comments
 (0)