블로그 게시글 목록 페이지 만들기
블로그 게시글 목록 페이지를 만드는 방법을 소개합니다. Nuxt 3의 기능을 활용하여 blog.vue
페이지 컴포넌트와 PostList
, PostCard
컴포넌트를 구현하고, i18n 설정 및 데이터를 비동기로 가져오는 퍼사드 패턴까지 다뤄보겠습니다.
blog.vue
페이지 컴포넌트
blog
페이지 컴포넌트는 인덱스 페이지처럼 히어로 영역과 컨텐츠 영역으로 분리하여 개발해보겠습니다.
<script setup lang="ts">
const posts = await getPosts()
const { t } = useI18n()
useSeoMeta({
title: t('pages.blog.title'),
description: t('pages.blog.description'),
})
</script>
<template>
<the-page inner-class="max-w-3xl">
<blog-hero />
<blog-post-list :posts="posts" />
</the-page>
</template>
i18n
블로그 페이지에서 사용할 다국어 파일을 업데이트합니다. 단일 페이지에서만 사용하는 값이 아니라면, 최상위 수준에 선언하도록 설계했습니다. 예를 들어, reading-time
은 블로그 슬러그 페이지에서도 사용될 것이므로 최상위 수준에 정의했습니다.
pages:
index:
title: About.
subtitle: Besfir의 블로그 튜토리얼 입니다.
description: 기존 블로그 튜토리얼들은 즉시 사용하기에는 디자인적으로 다소 부족한 점이 있었습니다. 이에 미니멀하면서도 바로 활용 가능한 수준의 블로그 튜토리얼을 구성해 보았습니다.
blog:
title: 글 목록
subtitle: 기록은 기억을 지배한다.
description: 기억은 시간이 지나면 흐려지거나 때로는 왜곡될 수 있습니다. 기록은 이러한 기억을 보완해 주며, 나의 성장 과정을 돌아보고 경험을 체계적으로 쌓는 데 도움을 줍니다.
read: 읽어보기
reading-time: 읽는데 {time}분
포스트 목록 가져오기
저는 비동기 통신 로직을 작성할 때 퍼사드(Facade) 패턴으로 구현하는 것을 선호합니다. 복잡한 구현부를 숨길 수 있고, 한곳에 비슷한 기능을 모아 놓아서 유지보수도 굉장히 용이하기 때문입니다. 따라서 Nuxt Content로 데이터를 가져오는 부분을 useRepository
컴포저블 내에 퍼사드 패턴으로 구현해 보겠습니다.
import type { BlogPost } from '~/types/BlogPost'
export const getPosts = async () => {
const path = useRoute().path
const { locale } = useI18n()
const { data } = await useAsyncData(path, queryContent<BlogPost>('blog').locale(locale.value).find)
return data as Ref<BlogPost[]>
}
히어로 컴포넌트
인덱스 페이지와 크게 다르지 않으므로 설명은 생략합니다.
<template>
<div class="pt-4 space-y-6 md:space-y-10">
<h1 class="text-3xl md:text-5xl font-bold py-0.5 md:py-3 dark:text-white">
{{ $t('pages.blog.subtitle') }}
</h1>
<p class="text-lg hidden md:block">
{{ $t('pages.blog.description') }}
</p>
</div>
</template>
게시글 목록 컴포넌트
블로그 게시글 목록을 표시하는 PostList
컴포넌트에 props로 마크다운 파일의 타입을 넘겨줘야 합니다. 그런데 우리는 생성일, 수정일, 읽는 데 걸리는 시간 정보도 함께 기입할 것이기 때문에 기본 타입은 사용할 수 없습니다. 따라서 먼저 MarkdownParsedContent
타입을 확장한 BlogPost
타입을 정의하여 올바른 타입을 지원한 뒤, PostList
컴포넌트를 작성하겠습니다.
import type { MarkdownParsedContent } from '@nuxt/content'
import type { ReadTimeResults } from 'reading-time'
export interface BlogPost extends MarkdownParsedContent {
createdAt: string
updatedAt: string
readingTime: ReadTimeResults
}
게시글 카드 컴포넌트
<script setup lang="ts">
import type { BlogPost } from '~/types/BlogPost'
interface Props {
post: BlogPost
}
const props = defineProps<Props>()
const { t, d } = useI18n()
const readingTime = t('reading-time', { time: Math.ceil(props.post.readingTime.minutes || 1) })
const formattedTime = d(props.post.createdAt, { key: 'short' })
</script>
<template>
<article class="grid items-start grid-cols-4 gap-8">
<div class="relative z-10 order-first mb-3 text-slate-500 dark:text-slate-400 flex-col hidden md:flex text-sm">
<div class="flex flex-col">
<time :datetime="post.createdAt">{{ formattedTime }}</time>
<span>{{ readingTime }}</span>
</div>
</div>
<div class="col-span-4 md:col-span-3">
<div class="relative flex flex-col items-start group ">
<div class="relative z-10 order-first mb-3 flex text-slate-500 dark:text-slate-400 md:hidden pl-3.5 text-sm">
<span class="absolute inset-y-0 left-0 flex items-center py-1">
<span class="h-full w-0.5 rounded-full bg-slate-200 dark:bg-slate-400" />
</span>
<div class="flex">
<time :datetime="post.createdAt">{{ formattedTime }}</time>
<span class="mx-1">·</span> <span>{{ readingTime }}</span>
</div>
</div>
<div class="text-base font-semibold tracking-tight text-slate-800 dark:text-slate-100">
<div class="absolute z-0 transition scale-95 opacity-0 -inset-y-6 -inset-x-4 bg-slate-50 group-hover:scale-100 group-hover:opacity-100 dark:bg-slate-800/50 sm:-inset-x-6 sm:rounded-2xl" />
<nuxt-link-locale :to="post._path">
<span class="absolute z-20 -inset-y-6 -inset-x-4 sm:-inset-x-6 sm:rounded-2xl" />
<span class="relative z-10 text-xl">{{ post.title }}</span>
</nuxt-link-locale>
</div>
<p class="relative z-10 flex-1 mt-2 prose prose-slate dark:prose-invert line-clamp-3 md:line-clamp-none">
{{ post.description }}
</p>
<div class="relative z-10 flex items-center mt-4">
<div class="flex items-center text-primary-500 dark:text-primary-400">
<span class="font-medium">{{ $t('pages.blog.read') }}</span>
<icon
name="ph:arrow-right-bold"
size="16"
class="ml-1"
/>
</div>
</div>
</div>
</div>
</article>
</template>
- 11행:
minute
값은 실수값으로 제공됩니다. 때문에 현실 세계에 맞도록 올림하여 표시하도록 하겠습니다. - 12행: 날짜 형식을 표시 위한 헬퍼 함수
d()
와i18n.config.ts
설정한 날짜 형식을 조합하여 원하는 형식으로 날짜를 표현할 수 있습니다.
여기까지 프로젝트 구조
소스 코드의 파일 경로는 이 섹션을 참고해주세요
.
├── README.md
├── app.vue
├── assets
│ ├── css
│ │ └── main.pcss
│ └── icons
│ └── logo.svg
├── bun.lockb
├── components
│ ├── App
│ │ ├── Button.vue
│ │ ├── Link.vue
│ │ └── SnsLinks.vue
│ ├── Blog
│ │ ├── Hero.vue
│ │ ├── PostCard.vue
│ │ └── PostList.vue
│ ├── Index
│ │ ├── Hero.vue
│ │ └── Snaps.vue
│ └── The
│ ├── Footer.vue
│ ├── Header.vue
│ ├── LangSwitcher.vue
│ ├── Page.vue
│ └── ThemeSwitcher.vue
├── composables
│ └── useRepository.ts
├── constant
│ └── ButtonStyle.ts
├── content
│ ├── en
│ │ └── blog
│ │ └── 1.style guide.md
│ └── ko
│ └── blog
│ └── 1.style guide.md
├── eslint.config.mjs
├── i18n
│ ├── i18n.config.ts
│ └── locales
│ ├── en.yaml
│ └── ko.yaml
├── nuxt.config.ts
├── package.json
├── pages
│ ├── archives.vue
│ ├── blog.vue
│ └── index.vue
├── public
│ ├── _robots.txt
│ ├── favicon.ico
│ └── images
│ └── index
│ ├── hero.jpeg
│ ├── snap-1.jpg
│ ├── snap-2.jpg
│ ├── snap-3.jpg
│ ├── snap-4.jpg
│ └── snap-5.jpg
├── server
│ └── tsconfig.json
├── tailwind.config.ts
├── tsconfig.json
└── types
├── BlogPost.ts
└── Button.ts