·읽는데 9분

블로그 게시글 목록 페이지 만들기

블로그 게시글 목록 페이지를 만드는 방법을 소개합니다. 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