Next.js 블로그 제작기 (10)

user profile img

신현호

TypeScript
Next.js

블로그 제작기

Post Thumbnail

목차

    서론

    블로그를 운영한지 어언 5개월정도 된 것 같고, 꾸준히 기능 개발을 하면서 수정해나가지만 여전히 제눈에는 부족함 투성이네요
    특히 디자이너 없이 혼자서 디자인부터 개발까지 하다보니 너무 머리가 아픕니다 ㅠㅠ

    그래도 공부하는 내용을 바로바로 블로그에 써먹을 수 있다는 점이 너무너무 좋고, 앞으로도 블로그에 이런 적용(장난질)을 더 할 수 있다는게 만족스럽습니다 :)
    이번에도 여러가지 기능을 추가했는데 아래 변경점 항목에서 하나하나 설명하도록 하겠습니다.

    변경점

    시리즈 항목 추가

    기존에 유사한 기능인 태그가 존재했지만... 사실 태그와 시리즈는 느낌이 조금 다르다고 생각합니다.
    또, 똑같은 주제의 포스트를 카테고링하고 싶었습니다. 다른 블로그들 보면 글 하단에 같은 시리즈의 글들을 모아주잖아요.

    그 기능을 구현해보고자 contentlayer 설정을 뒤적뒤적거리며 수정했습니다.

    ts

    export const Post = defineDocumentType(() => ({
      name: 'Post',
      filePathPattern: `**/*.mdx`,
      contentType: 'mdx',
      fields: {
        title: {
          type: 'string',
          required: true,
        },
        description: {
          type: 'string',
          required: true,
        },
        date: {
          type: 'string',
          required: true,
        },
        thumbnail: {
          type: 'string',
          required: true,
        },
        category: {
          type: 'list',
          of: {
            type: 'string',
          },
          required: true,
        },
        series: {
          type: 'string',
          required: false,
        },
      },
      computedFields: {
        url: {
          type: 'string',
          // eslint-disable-next-line no-underscore-dangle
          resolve: (post) => `/posts/${post._raw.flattenedPath}`,
        },
      },
    
    ...
    
    series를 별도로 표시해줄 수 있도록 했고 **required****false**로 설정하여 series는 필수 입력이 아니도록 했습니다.
    이렇게 하면 아래와 같이 front-matter를 작성해 줄 수 있을겁니다.
    
    ```mdx
    ---
    title: Next.js 블로그 제작기 (10)
    description: 잡다구리 기능들 추가
    date: 2024-03-15
    thumbnail: /blogImages/image00.webp
    category:
      - TypeScript
      - Next.js
    series: 블로그 제작기
    ---
    

    이렇게 되면 contentlayer 세팅 자체는 마쳤고, 저는 util함수를 통해 contentlayer로 생성된 파일을 가져오기때문에 해당하는 util함수를 수정했습니다.

    ts

    export const getAllCategory = () => {
      const categories: Tag[] = [{ name: 'All', amount: 0 }]
      const seriesList: Tag[] = []
    
      allPosts.forEach(({ category, series }) => {
        category.forEach((categoryItem) => {
          const parsedCategory = categoryItem.replace(/ /g, '_')
          const target = categories.findIndex(
            (item) =>
              item.name === parsedCategory || item.name === `${parsedCategory}\r`
          )
    
          if (target === -1) {
            categories.push({ name: parsedCategory, amount: 1 })
          } else {
            categories[target].amount += 1
          }
        })
    
        if (series) {
          const parsedSeries = `series-${series.replace(/ /g, '_')}`
          const target = seriesList.findIndex((item) => item.name === parsedSeries)
    
          if (target === -1) {
            seriesList.push({ name: parsedSeries, amount: 1 })
          } else {
            seriesList[target].amount += 1
          }
        }
        categories[0].amount += 1
      })
    
      return { categories, series: seriesList }
    }
    

    시리즈를 카운팅할 때는 파라미터로 들어오는걸 고려해서 series- 문자열을 추가하여 계산했습니다.
    기존 설계도 있어서 이런 방법밖에는 생각나지 않더라구요. 아예 페이지를 분리하는 방법도 생각했는데, 시리즈는 글 목록에 있는게 좋을 것 같아 한 페이지로 구성했습니다.

    ts

    export const getSelectedCategoryPost = (category: string, pageNum: number) => {
      const posts = getAllPost()
      const decodedCategory = decodeURI(category).replaceAll(/_/g, ' ')
      const lastCategory = `${decodeURI(category)}\r`
    
      if (category.includes('series-')) {
        const selectedPostData = posts.filter(
          ([, post]) => `series-${post.series}` === decodedCategory
        )
    
        return {
          selectedPost: selectedPostData.slice(
            (pageNum - 1) * POST_SETTING.contentsPerPage,
            pageNum * POST_SETTING.contentsPerPage
          ),
          selectedAllPostLen: selectedPostData.length,
        }
      }
    
      if (category === 'all') {
        return {
          selectedPost: posts.slice(
            (pageNum - 1) * POST_SETTING.contentsPerPage,
            pageNum * POST_SETTING.contentsPerPage
          ),
          selectedAllPostLen: posts.length,
        }
      }
    
      const selectedPostData = posts.filter(([, post]) => {
        const lowerCategory = post.category.map((currCategory) =>
          currCategory.toLowerCase()
        )
        return (
          lowerCategory.includes(decodedCategory) ||
          lowerCategory.includes(lastCategory)
        )
      })
    
      return {
        selectedPost: selectedPostData.slice(
          (pageNum - 1) * POST_SETTING.contentsPerPage,
          pageNum * POST_SETTING.contentsPerPage
        ),
        selectedAllPostLen: selectedPostData.length,
      }
    }
    

    해당 카테고리에 속하는 포스트의 데이터만 반환하는 로직입니다. (반복되는 부분은 추후 리팩토링 할 예정입니다 ㅠㅠ)
    이 부분에서 문제가 있었는데, 태그의 이름이 한글일 때 정체불명의 문자열이 파라미터에 들어왔습니다.

    파라미터

    tsx

    export default function SeriesItem({ name, amount }: SeriesItemProps) {
      const seriesName = name.replaceAll(/series-|_/g, ' ')
    
      return (
        <Link href={`/posts/${name}/1`}>
          <div className="relative flex w-44 overflow-hidden shadow-md">
            <div className="bg-ochre_light dark:bg-ochre w-3" />
            <div className="hover:from-ochre_light dark:bg-background_component dark:hover:from-ochre dark:hover:to-background_component flex w-full justify-between bg-white bg-gradient-to-r from-transparent from-50% to-transparent to-50% bg-[length:200%_100%] bg-right-bottom p-3 transition-all duration-200 ease-in-out hover:to-white hover:bg-left-bottom hover:text-white dark:text-white">
              <p className="text-sm">{seriesName}</p>
              <p className="text-xs">{amount}</p>
            </div>
          </div>
        </Link>
      )
    }
    

    (filter 컴포넌트도 유사한 구조입니다)
    이 부분에 대해서 알아보니 한글은 파라미터로 들어갈 때 인코딩되어서 들어간다는 사실을 알 수 있었습니다.

    그래서 위의 getSelectedCategoryPost를 잘 보시면 해당 처리를 위한 로직이 존재하는 걸 확인할 수 있습니다.

    tsx

    export const getSelectedCategoryPost = (category: string, pageNum: number) => {
      const posts = getAllPost()
      const decodedCategory = decodeURI(category).replaceAll(/_/g, ' ')
      const lastCategory = `${decodeURI(category)}\r`
      ...
    

    한글이 인코딩되는 해당 문제는 decodeURI를 사용하시면 해결하실 수 있습니다.
    인코딩이 되어있다는 사실을 알았으니 디코딩해주면 되는 것입니다.

    이렇게 필요한 데이터를 받아와서 시리즈 항목을 신설했습니다. 시리즈의 주요 기능은 동일한 주제의 포스트를 묶어주는 것입니다 :)
    특정 시리즈에 속하는 글 하단에는 해당 시리즈의 글 목록이 노출됩니다.

    또, 글 목록 / 메인페이지 카드 컴포넌트에는 폴더 아이콘을 통해 해당 포스트가 시리즈에 속해있는지의 유무를 확인할 수 있도록 했습니다!

    글 목록 페이지에 애니메이션 추가

    글 목록이 너무 심심하게 출력되는 것 같아 Framer를 통해 애니메이션을 추가 적용시켜주었습니다.
    저는 글 목록에 stagger 애니메이션을 적용시켜주었습니다. 아래는 stagger 애니메이션의 예시입니다 :)

    (Menu버튼을 클릭하면 item 목록들이 하나씩 출력되는 모습을 볼 수 있습니다)

    해당 공식 예제를 참고하면 간단하게 구현할 수 있습니다. 저는 useStaggerAnimation 이라는 이름의 custom-hook으로 기능을 분리해줬습니다!

    ts

    import { stagger, useAnimate } from 'framer-motion'
    import { useEffect } from 'react'
    
    const staggerItems = stagger(0.15, { startDelay: 0.15 })
    
    const useStaggerAnimation = () => {
      const [scope, animate] = useAnimate()
    
      useEffect(() => {
        animate(
          'li',
          { opacity: 1, scale: 1 },
          { duration: 0.5, delay: staggerItems }
        )
      }, [animate])
    
      return scope
    }
    
    export default useStaggerAnimation
    

    staggerItems의 값을 조정하여 애니메이션을 조정할 수 있는데요, 공식문서에 따르면 다음과 같습니다

    ts

    stagger(0.1, { from: 'center' })
    

    첫 번째 인자는 애니메이션의 간격, 두 번째 인자는 옵션입니다. 옵션은 3개인데 각각 from, ease, startDelay입니다.

    from은 first, last, center 혹은 number값이 들어올 수 있는데 이는 애니메이션의 시작 인덱스를 지정하는 옵션입니다.
    ease는 css의 ease-in / ease-out 그 기능입니다. 애니메이션의 진행 속도를 지정할 수 있습니다.
    startDelay는 stagger를 시작하기 전의 딜레이를 지정할 수 있습니다.

    useAnimate는 animate 함수와 scope라는 ref를 반환합니다. 또, useAnimateuseRef를 사용합니다.
    따라서 Next의 서버 컴포넌트에서는 사용할 수 없습니다. 반드시 클라이언트 컴포넌트에서 사용하시길 바랍니다.

    사용은 다음과 같이 하시면 되겠습니다 :)

    tsx

    'use client'
    
    import PostItem from '@/containers/posts/list/PostItem'
    import { type Post } from '@/contentlayer/generated'
    import useStaggerAnimation from '@/hooks/useStaggerAnimation'
    
    interface PostListProps {
      posts: Array<[number, Post]>
      allPostLen: number
    }
    
    export default function PostList({ posts, allPostLen }: PostListProps) {
      const scope = useStaggerAnimation()
    
      return (
        <div className="grid w-full gap-10">
          <ul ref={scope} className="grid w-full gap-8">
            {posts?.map(([id, post]) => (
              <PostItem key={post.url} id={allPostLen - id - 1} post={post} />
            ))}
          </ul>
        </div>
      )
    }
    

    참고

    Profile Image

    신현호

    Frontend Developer

    프론트엔드 개발자를 꿈꾸고 있는 대학생입니다. 끊임없이 배우고 성장하는 개발자가 되기 위해 노력하고 있습니다.

    블로그 제작기

    총 11개의 포스트가 존재합니다.