·읽는데 12분

포스트 페이지 만들기

포스트 페이지는 블로그 콘텐츠를 개별적으로 표시하는 화면으로, 제목, 작성자, 본문, 이미지, 댓글 등을 포함해 사용자와 소통하는 핵심 공간입니다. 이번에는 포스트 페이지를 제작하고, 이미지 컴포넌트를 커스터마이징하며, 갤러리 컴포넌트까지 생성해 보겠습니다.

블로그 페이지 계층화

포스트 페이지는 슬러그를 동적 파라미터로 구성하며, 글 작성자의 성향에 따라 모든 글을 blog 디렉터리 아래에 두거나, blog/2025처럼 연도별로 정리할 수도 있습니다. 이러한 요구를 충족하고 유연한 확장성을 제공하기 위해 Catch-all Route를 사용하겠습니다.

이 Catch-all Route는 /blog URL 구조 아래에서만 작동해야 하기 때문에 디렉터리 구조를 아래와 같이 변경하겠습니다.

.
├── pages
│   ├── archives.vue
│   ├── blog.vue
│   └── index.vue

포스트 가져오기

이전에 작성했던 useRepository() 함수에 포스트를 가져오는 코드를 추가 해볼껀데요. useAsyncData()의 응답 객체 중 하나인 data는 nullable 타입인 <T> | null 타입을 반환합니다. 그러나 저는 data 객체가 비어 있을 경우 404 에러로 처리하고, 항상 <T> 타입을 반환하도록 구현하겠습니다.

import type { BlogPost } from '~/types/BlogPost'

const _fetchContent = async <T>(
    queryFn: () => Promise<T>,
) => {
    const path = useRoute().path
    const { data } = await useAsyncData(path, queryFn)

    return data as Ref<T>
}

export const getPosts = () => {
    const { locale } = useI18n()
    return _fetchContent(
        queryContent<BlogPost>('blog').locale(locale.value).find,
    )
}

export const getPostBySlug = async () => {
    const { locale, t } = useI18n()
    const data = await _fetchContent(
        queryContent<BlogPost>('blog', ...(useRoute().params.slug as string))
            .locale(locale.value)
            .findOne,
    )
    if (!data.value) {
        throw createError({ statusCode: 404, message: t('pages.error.404') })
    }
    return data
}

다국어 텍스트

수정 일자를 표시하기 위한 다국어 텍스트를 추가합니다.

pages:
  index:
    title: About.
    subtitle: Besfir의 블로그 튜토리얼 입니다.
    description: 기존 블로그 튜토리얼들은 즉시 사용하기에는 디자인적으로 다소 부족한 점이 있었습니다. 이에 미니멀하면서도 바로 활용 가능한 수준의 블로그 튜토리얼을 구성해 보았습니다.
  blog:
    title: 글 목록
    subtitle: 기록은 기억을 지배한다.
    description: 기억은 시간이 지나면 흐려지거나 때로는 왜곡될 수 있습니다. 기록은 이러한 기억을 보완해 주며, 나의 성장 과정을 돌아보고
      경험을 체계적으로 쌓는 데 도움을 줍니다.
    read: 읽어보기
  slug:
    updatedAt: "{time}에 수정됨"
  error:
    404: 페이지를 찾을 수 없습니다.
reading-time: "읽는데 {time}분"

[...slug].vue 페이지 컴포넌트

페이지 컴포넌트가 catch-all route로 동작하도록 [...slug].vue로 이름 짓도록 합니다.

<script setup lang="ts">
const post = await getPostBySlug()
useContentHead(post)

const { d, t } = useI18n()

const postedTime = computed(() => {
  if (post.value.updatedAt) {
    return {
      iso: post.value.updatedAt,
      formatted: t('pages.slug.updatedAt', { time: d(post.value.updatedAt, { key: 'short' }) }),
    }
  }
  return {
    iso: post.value?.createdAt,
    formatted: d(post.value.createdAt, { key: 'short' }),
  }
})
</script>

<template>
  <the-page
    inner-class="max-w-3xl"
    class="pt-4 md:pt-8 pb-10"
    is-prose
  >
    <main>
      <div class="flex justify-between items-center mb-7">
        <div class="relative text-slate-500 dark:text-slate-400 pl-3.5">
          <div class="absolute inset-y-0 left-0 py-1">
            <div class="h-full w-0.5 rounded-full bg-slate-200 dark:bg-slate-500" />
          </div>
          <div class="flex text-base">
            <time :datetime="postedTime.iso">{{ postedTime.formatted }}</time>
            <span class="mx-1">·</span>
            <span>{{ $t('reading-time', { time: Math.ceil(post.readingTime.minutes || 1) }) }}</span>
          </div>
        </div>
      </div>
      <h1>{{ post.title }}</h1>
      <content-renderer :value="post" />
    </main>
  </the-page>
</template>
  • 3행: useContentHead() 함수는 Nuxt Content 모듈에서 제공하는 유용한 헬퍼 함수입니다. 이 함수는 ParsedContent 타입의 데이터를 받아 SEO 설정을 간편하게 구성할 수 있도록 도와줍니다.
  • 7행-18행: 포스팅한 시간은 수정 시간을 우선적으로 표시하고, 수정 시간이 없는 경우 작성 시간을 대신 보여줍니다. 개발 분야는 정보의 최신성이 글의 가치에 많은 영향을 미치기 때문에, 콘텐츠가 얼마나 최신 상태인지를 명확히 전달하는 것이 중요하다고 판단하여 이러한 방식을 채택했습니다.

해딩 컴포넌트 커스터마이징

H2~H4 태그에 기존 border 스타일을 지우고, hover 시에 해쉬 문자가 보이도록 커스터마이징 해보겠습니다.

<template>
  <h2 :id="id">
    <a
        v-if="id && generate"
        :href="`#${id}`"
        class="-ml-2 pl-2 border-none group relative leading-none"
    >
      <span
          class="absolute -ml-[38px] hidden items-center border-0 opacity-0 group-hover:opacity-100 group-focus:opacity-100 lg:flex"
      >
        <span
            class="flex h-[30px] w-[30px] items-center justify-center rounded-md text-slate-400 shadow-sm ring-1 ring-slate-900/5 hover:text-slate-700 hover:shadow hover:ring-slate-900/10 bg-white dark:bg-slate-800 dark:text-slate-400 dark:shadow-none dark:ring-0 dark:hover:bg-slate-700 dark:hover:text-slate-200"
        >
          <icon name="ph:hash-straight-bold" />
        </span>
      </span>
      <slot />
    </a>
    <slot v-else />
  </h2>
</template>

<script setup lang="ts">
  const props = defineProps<{ id?: string }>()

  const { headings } = useRuntimeConfig().public.mdc
  const generate = computed(() => props.id && ((typeof headings?.anchorLinks === 'boolean' && headings?.anchorLinks === true) || (typeof headings?.anchorLinks === 'object' && headings?.anchorLinks?.h2)))
</script>

ProseImg 컴포넌트

이미지 컴포넌트에 캡션을 추가하고, 선택적으로 이미지가 전체 화면을 차지할 수 있도록 커스터마이징을 해보겠습니다.

<template>
  <nuxt-picture
    :src="refinedSrc"
    :alt="alt"
    :width="width"
    :height="height"
    format="avif,webp"
    :img-attrs="{ class: 'rounded-md', loading: 'lazy' }"
    sizes="sm:100vw 2xl:1536px"
    density="1x 2x"
    v-bind="$attrs"
  />
  <em class="not-italic text-xs text-center block font-normal my-4">{{ alt }}</em>
</template>

<script setup lang="ts">
import { joinURL, withLeadingSlash, withTrailingSlash } from 'ufo'

defineOptions({ inheritAttrs: false })

const props = defineProps({
  src: {
    type: String,
    default: '',
  },
  alt: {
    type: String,
    default: '',
  },
  width: {
    type: [String, Number],
    default: undefined,
  },
  height: {
    type: [String, Number],
    default: undefined,
  },
})

const refinedSrc = computed(() => {
  if (props.src?.startsWith('/') && !props.src.startsWith('//')) {
    const _base = withLeadingSlash(withTrailingSlash(useRuntimeConfig().app.baseURL))
    if (_base !== '/' && !props.src.startsWith(_base)) {
      return joinURL(_base, props.src)
    }
  }
  return props.src
})
</script>

<style scoped>
picture {
  @apply my-0;
}
.wide :deep(img) {
  @apply w-[calc(100vw-15px)] ml-[50%] -translate-x-1/2 max-w-none relative z-10 rounded-none;
}
</style>
  • 2-12행: Nuxt Image 모듈을 사용해서 이미지를 랜더링 하도록 합니다.
  • 13행: alt 속성값을 캡션으로 사용합니다.
  • 55-57행: 이미지가 전체 화면을 차지하도록 하는 CSS class를 정의합니다.

갤러리 컴포넌트

여행 사진처럼 여러 장의 사진을 한번에 첨부해야 하는 경우에 유용한 갤러리 컴포넌트도 만들어 보겠습니다.

<script setup lang="ts">
  defineProps<{ caption: string }>()
</script>

<template>
  <div class="my-[1.7777778em]">
    <div class="grid grid-cols-3 justify-center items-center gap-3 md:gap-5 *:m-0">
      <content-slot unwrap="p" />
    </div>
    <em class="not-italic text-xs text-center block font-normal my-4">
      {{ caption }}
    </em>
  </div>
</template>

<style scoped>
  :deep(picture) {
    @apply h-full w-full;
  }

  :deep(picture~em) {
    @apply hidden;
  }
</style>

샘플 문서

스타일 가이드 역할을 하는 샘플 문서를 공유드리겠습니다.

여기까지 구조

소스 코드의 파일 경로는 이 섹션을 참고해주세요

.
├── 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
│   └── content
│       ├── Gallery.vue
│       ├── ProseH2.vue
│       ├── ProseH3.vue
│       ├── ProseH4.vue
│       └── ProseImg.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
│   │   ├── [...slug].vue
│   │   └── index.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