Skip to content

Commit 8e53ba8

Browse files
kickbelldevclaude
andcommitted
feat: 카테고리 기반 페이지 및 컴포넌트 시스템 구현
- [category]/[slug] 동적 라우팅으로 카테고리별 포스트 상세 페이지 구현 - [category] 페이지로 카테고리별 포스트 목록 제공 - 기존 posts 컴포넌트들을 카테고리 기반으로 마이그레이션 - PostContent, PostHeader, PostNavigation 등 재사용 가능한 컴포넌트 - CategoryBadge, CategoryList 등 카테고리 전용 컴포넌트 추가 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent b682bc0 commit 8e53ba8

File tree

15 files changed

+248
-43
lines changed

15 files changed

+248
-43
lines changed

contents/dev/category.json

Lines changed: 0 additions & 6 deletions
This file was deleted.

contents/life/category.json

Lines changed: 0 additions & 6 deletions
This file was deleted.
Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,20 @@ import {
66
PostHeader,
77
PostNavigation,
88
RelatedPosts,
9-
} from '@/app/posts/_components'
9+
} from '@/app/[category]/_components'
10+
import { type CategoryId, isValidCategoryId } from '@/entities/categories'
1011
import { getAllPosts, getPostNavigation } from '@/entities/posts'
1112
import { getRelatedPostsByTags } from '@/entities/tags'
1213

1314
export async function generateStaticParams() {
1415
const posts = await getAllPosts()
1516

16-
const params = posts.map((post) => ({
17-
slug: encodeURIComponent(post.slug),
18-
}))
17+
const params = posts
18+
.filter((post) => post.data.category) // 카테고리가 있는 포스트만
19+
.map((post) => ({
20+
category: post.data.category as CategoryId,
21+
slug: encodeURIComponent(post.slug),
22+
}))
1923

2024
return params
2125
}
@@ -24,17 +28,29 @@ export default async function PostPage({
2428
params,
2529
}: {
2630
params: Promise<{
31+
category: string
2732
slug: string
2833
}>
2934
}) {
30-
const { slug } = await params
35+
const { category, slug } = await params
3136
const decodedSlug = decodeURIComponent(slug)
3237

38+
// 카테고리 유효성 검증
39+
if (!isValidCategoryId(category)) {
40+
notFound()
41+
}
42+
3343
try {
44+
// 카테고리 기반 경로로 MDX 파일 import
3445
const { default: Post, frontmatter } = await import(
35-
`@/contents/${decodedSlug}.mdx`
46+
`@/contents/${category}/${decodedSlug}`
3647
)
3748

49+
// 포스트의 실제 카테고리와 URL 카테고리가 일치하는지 확인
50+
if (frontmatter.category && frontmatter.category !== category) {
51+
notFound()
52+
}
53+
3854
const { previousPost, nextPost } = await getPostNavigation(decodedSlug)
3955
const relatedPosts = await getRelatedPostsByTags(decodedSlug)
4056

@@ -44,11 +60,7 @@ export default async function PostPage({
4460
<PostContent>
4561
<Post />
4662
</PostContent>
47-
<PostFooter
48-
previousPost={previousPost}
49-
nextPost={nextPost}
50-
relatedPosts={relatedPosts}
51-
/>
63+
<PostFooter />
5264
<PostNavigation
5365
previousPost={previousPost}
5466
nextPost={nextPost}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { cn } from '@/app/_lib/cn'
2+
import { type CategoryId, getCategoryById } from '@/entities/categories'
3+
4+
interface CategoryBadgeProps {
5+
categoryId: CategoryId
6+
className?: string
7+
showIcon?: boolean
8+
}
9+
10+
export async function CategoryBadge({
11+
categoryId,
12+
className,
13+
showIcon = true,
14+
}: CategoryBadgeProps) {
15+
const category = await getCategoryById(categoryId)
16+
17+
if (!category) {
18+
return null
19+
}
20+
21+
const colorClasses = {
22+
blue: 'bg-blue-100 text-blue-800 border-blue-200 hover:bg-blue-200',
23+
green: 'bg-green-100 text-green-800 border-green-200 hover:bg-green-200',
24+
red: 'bg-red-100 text-red-800 border-red-200 hover:bg-red-200',
25+
yellow:
26+
'bg-yellow-100 text-yellow-800 border-yellow-200 hover:bg-yellow-200',
27+
purple:
28+
'bg-purple-100 text-purple-800 border-purple-200 hover:bg-purple-200',
29+
gray: 'bg-gray-100 text-gray-800 border-gray-200 hover:bg-gray-200',
30+
}
31+
32+
return (
33+
<span
34+
className={cn(
35+
'inline-flex items-center gap-1 px-2 py-1 text-sm font-medium rounded-md border transition-colors',
36+
colorClasses[category.color as keyof typeof colorClasses] ||
37+
colorClasses.gray,
38+
className
39+
)}
40+
title={category.description}
41+
>
42+
{showIcon && category.icon && (
43+
<span
44+
className="text-xs"
45+
aria-hidden="true"
46+
>
47+
{category.icon}
48+
</span>
49+
)}
50+
{category.name}
51+
</span>
52+
)
53+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { cn } from '@/app/_lib/cn'
2+
import {
3+
type CategoryWithCount,
4+
getAllCategories,
5+
getCategoriesWithCount,
6+
} from '@/entities/categories'
7+
8+
import { CategoryBadge } from './CategoryBadge'
9+
10+
interface CategoryListProps {
11+
categories?: CategoryWithCount[]
12+
showCount?: boolean
13+
className?: string
14+
}
15+
16+
export function CategoryList({
17+
categories,
18+
showCount = false,
19+
className,
20+
}: CategoryListProps) {
21+
const displayCategories =
22+
categories ||
23+
getAllCategories().map((cat) => ({
24+
...cat,
25+
count: 0,
26+
}))
27+
28+
if (displayCategories.length === 0) {
29+
return null
30+
}
31+
32+
return (
33+
<div className={cn('flex flex-wrap gap-2', className)}>
34+
{displayCategories.map((category) => (
35+
<div
36+
key={category.id}
37+
className="flex items-center gap-1"
38+
>
39+
<CategoryBadge categoryId={category.id} />
40+
{showCount && (
41+
<span className="text-sm text-stone-500">({category.count})</span>
42+
)}
43+
</div>
44+
))}
45+
</div>
46+
)
47+
}
File renamed without changes.

src/app/posts/_components/PostFooter.tsx renamed to src/app/[category]/_components/PostFooter.tsx

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,11 @@
11
import { cn } from '@/app/_lib/cn'
2-
import type { Post } from '@/entities/posts/types'
3-
4-
import { PostNavigation } from './PostNavigation'
5-
import { RelatedPosts } from './RelatedPosts'
62

73
interface PostFooterProps {
8-
previousPost?: Pick<Post, 'slug' | 'data'>
9-
nextPost?: Pick<Post, 'slug' | 'data'>
10-
relatedPosts?: Array<Pick<Post, 'slug' | 'data'>>
114
author?: string
125
className?: string
136
}
147

15-
export function PostFooter({
16-
previousPost,
17-
nextPost,
18-
relatedPosts,
19-
author,
20-
className,
21-
}: PostFooterProps) {
8+
export function PostFooter({ author, className }: PostFooterProps) {
229
return (
2310
<footer className={cn('pt-8', className)}>
2411
{/* TODO: Implement AuthorInfo */}

src/app/posts/_components/PostHeader.tsx renamed to src/app/[category]/_components/PostHeader.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import { cn } from '@/app/_lib/cn'
22
import { formatDate } from '@/app/_lib/formatDate'
3+
import type { CategoryId } from '@/entities/categories/types'
34

5+
import { CategoryBadge } from './CategoryBadge'
46
import { TagList } from './TagList'
57

68
interface PostHeaderProps {
79
title: string
810
date: string
911
tags: string[]
12+
category?: CategoryId
1013
readingTime?: number
1114
author?: string
1215
className?: string
@@ -16,15 +19,19 @@ export function PostHeader({
1619
title,
1720
date,
1821
tags,
22+
category,
1923
readingTime,
2024
author,
2125
className,
2226
}: PostHeaderProps) {
2327
return (
2428
<header className={cn('mb-8 pb-8 ', className)}>
25-
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-stone-900 dark:text-stone-100 mb-4 leading-tight">
26-
{title}
27-
</h1>
29+
<div className="flex items-center gap-3 mb-4">
30+
{category && <CategoryBadge categoryId={category} />}
31+
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-stone-900 dark:text-stone-100 leading-tight">
32+
{title}
33+
</h1>
34+
</div>
2835
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-2 sm:gap-4 text-stone-600 dark:text-stone-400">
2936
<time
3037
dateTime={date}

src/app/posts/_components/PostNavigation.tsx renamed to src/app/[category]/_components/PostNavigation.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export function PostNavigation({
2828
<div className="flex-1 max-w-[calc(50%-0.5rem)]">
2929
{previousPost && (
3030
<Link
31-
href={`/posts/${previousPost.slug}`}
31+
href={`/${previousPost.data.category || 'uncategorized'}/${previousPost.slug}`}
3232
className={cn(
3333
'flex items-center gap-2 p-3 rounded-lg w-full',
3434
'transition-colors duration-200',
@@ -61,7 +61,7 @@ export function PostNavigation({
6161
<div className="flex-1 flex justify-end max-w-[calc(50%-0.5rem)]">
6262
{nextPost && (
6363
<Link
64-
href={`/posts/${nextPost.slug}`}
64+
href={`/${nextPost.data.category || 'uncategorized'}/${nextPost.slug}`}
6565
className={cn(
6666
'flex items-center gap-2 p-3 rounded-lg text-right w-full',
6767
'transition-colors duration-200',

src/app/posts/_components/RelatedPostItem.tsx renamed to src/app/[category]/_components/RelatedPostItem.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export function RelatedPostItem({
1313
return (
1414
<Link
1515
key={post.slug}
16-
href={`/posts/${post.slug}`}
16+
href={`/${post.data.category || 'uncategorized'}/${post.slug}`}
1717
className="block w-full max-w-sm"
1818
>
1919
<div className="border rounded-lg p-4 h-full duration-200">

0 commit comments

Comments
 (0)