jay.log

posts.

portfolio.

기록

Next.js 15 App Router 환경에서 블로그 만들기 (2)

이제 각각 생성한 클래스를 가지고 페이지를 만들어봅시다.

9분

2024. 11. 30

리스트 페이지

우선 블로그 포스팅 리스트를 만들어 봅시다

app/posts/page.tsx
export default async function Page({ searchParams }: { searchParams: Promise<{ filter?: string }> }) {
  const { filter } = await searchParams;
  const { posts } = new PostsImpl(filter);
 
  return <PostList posts={posts} />;
}

리스트 페이지는 filter라는 쿼리 파라미터로 리스트를 카테고리별로 필터링 할 수 있게 만들었습니다. Next.js를 이용한 서버 컴포넌트에서는 hook을 사용할 수 없기 때문에 page.tsx에서 쿼리 파라미터를 searchParams props에서 가져와 사용하도록 합시다. 가져온 filter 값을 인스턴스에 주입시켜서 포스트 목록을 생성하여 Pages 레이어전달해줍니다.

_pages/PostList.tsx
export function PostList({ posts }: { posts: Post[] }) {
  return (
    <PageLayout>
      <PostsCategoryNav />
      <br />
      <PostItems posts={posts} />
    </PageLayout>
  );
}

위젯 레이어

Pages 레이어에는 두 Widget 레이어 컴포넌트들로 구성되어 있습니다. 필터할 카테고리 목록을 보여주는 PostsCategoryNav와 포스트 리스트를 보여줄 PostItems입니다.

widgets/PostCategoryNav.tsx
export function PostsCategoryNav() {
  const categories = new CategoriesImpl();
 
  return (
    <ul className='flex flex-wrap'>
      {categories.getAll().map((category, index) => {
        if (category.name === '전체') {
          return (
            <NavigateToHref key={`category_${index}`} href='/posts'>
              <SingleCategory category={category} />
            </NavigateToHref>
          );
        } else {
          return (
            <NavigateToHref key={`category_${index}`} href={`/posts?filter=${category.name}`}>
              <SingleCategory category={category} />
            </NavigateToHref>
          );
        }
      })}
    </ul>
  );
}
 
function SingleCategory({ category }: { category: { name: string; fileCount: number } }) {
  return <li className='m-1 rounded bg-cool-gray-reverse p-2 text-warm-gray'>{`${category.name} (${category.fileCount})`}</li>;
}
widgets/PostItems.tsx
export function PostItems({ posts }: { posts: Post[] }) {
  return posts.map((post, index) => {
    return (
      <div key={`post_${index}`}>
        <PostItemCard post={post} />
        {posts.length - 1 !== index && <div className='h-4' />}
      </div>
    );
  });
}
 
function PostItemCard({ post }: { post: Post }) {
  return (
    <NavigateToHref href={`/posts/${post.category}/${post.id}`}>
      <div className='rounded-lg border border-warm-gray p-4'>
        <h2 className='truncate text-xl font-semibold'>{post.data.title}</h2>
        <div className='h-2' />
        <p className='min-h-16 truncate text-pretty break-words'>{post.data.description}</p>
        <div className='h-2' />
        <div className='flex justify-end text-cool-gray'>
          <img src={'/clock.svg'} alt='' className='aspect-square w-4 object-contain' />
          <div className='w-1' />
          <span>{post.readingTime}</span>
          <div className='w-2' />
          <span>{post.data.createdAt}</span>
        </div>
      </div>
    </NavigateToHref>
  );
}

각각의 컴포넌트들은 그려주는 역할만 하고 네비게이트 이벤트는 Features 레이어NavigateToHref 컴포넌트가 담당합니다.

features/NavigateToHref.tsx
export function NavigateToHref({ children, href, replace, isBlank }: { children: React.ReactNode; href: string; replace?: boolean; isBlank?: boolean }) {
  return (
    <Link href={href} className='no-underline' replace={replace} target={isBlank ? '_blank' : ''}>
      {children}
    </Link>
  );
}

상세 페이지

상세 페이지도 앞서 살펴본 리스트 페이지처럼 FSD 아키텍처를 활용한 페이지 구성입니다.

pages/PostDetail.tsx
export function PostDetail({ post }: { post: PostImpl }) {
  return (
    <PageLayout>
      <ScrollProgressBar />
      <div className='relative'>
        <PostSummary category={post.category} data={post.data} readingTime={post?.readingTime} />
        <MDXComponent content={post.content} />
        <div className='h-12' />
        <Profile />
        <div className='h-6' />
        <TableOfContents />
      </div>
    </PageLayout>
  );
}

상세 페이지에는 좀 더 다양한 컴포넌트들로 구성되어 있습니다.

  1. 스크롤 정도에 따라 진행도를 보여주는 ScrollProgressBar
  2. 마크다운 프론트메터를 보여주는 PostSummary
  3. 마크다운 본문을 파싱해주는 MDXComponent
  4. 프로필 컴포넌트 Profile
  5. 헤더 인덱스들을 보여주는 TableOfContents

여기서 2, 4는 데이터를 그대로 보여주는 역할밖에 없기 때문에 1, 3, 5번만 다루도록 하겠습니다.

스크롤 진행도

스크롤 진행도를 보여주려면 윈도우 객체에 이벤트 리스너를 등록해 구현하였습니다.

widgets/ScrollProgressBar.tsx
'use client';
 
import { useEffect, useState } from 'react';
 
export function ScrollProgressBar() {
  const [progress, setProgress] = useState('');
 
  useEffect(() => {
    window.addEventListener('scroll', () => {
      const currentY = window.scrollY;
      const totalY = window.document.documentElement.scrollHeight - window.innerHeight;
 
      setProgress(((currentY / totalY) * 100).toFixed(3));
    });
  }, []);
 
  return <div className={`fixed left-0 top-0 z-10 h-2 bg-warm-gray`} style={{ width: `${progress}%` }} />;
}

진행도를 동적으로 관리해야하는데 useEffectuseState가 필요해 클라이언트 컴포넌트로 구현했습니다.

마크다운 본문

shared/MDXComponent.tsx
export function MDXComponent({ content }: { content?: string }) {
  return (
    <article className='prose mx-auto w-full'>
      <MDXRemote
        source={content || ''}
        options={{
          mdxOptions: {
            remarkPlugins: [remarkGfm, remarkBreaks, [remarkToc, { heading: 'structure' }]],
            rehypePlugins: [
              [
                rehypePrettyCode,
                {
                  keepBackground: false,
                  theme: { dark: 'plastic', light: 'github-light' }
                }
              ],
              rehypeSlug,
              [
                rehypeAutolinkHeadings,
                {
                  properties: {
                    className: ['anchor']
                  }
                }
              ]
            ]
          }
        }}
        components={{
          a: ({ children, href, ...rest }) => {
            return (
              <a {...rest} target='_blank' href={href?.toString()}>
                {children}
              </a>
            );
          },
          img: (imageComponent) => {
            return <img {...imageComponent} className='aspect-video rounded-lg bg-warm-gray object-contain' />;
          }
        }}
      />
    </article>
  );
}

마크다운 본문은 MDXRemote를 통해 스타일을 별도로 지정하지 않고 간단하게 바꿀 수 있도록 하였습니다. 필요한 플러그인들을 넣어줘 편하게 마크다운을 html로 변환하여 관리할 수 있도록 하였습니다.

components 프롭스는 html로 변환하는 과정에서 일치하는 태그를 오버라이딩 할 수 있게 도와줍니다. 저는 a태그와 img태그를 제가 원하는 데로 수정하였습니다.

현재는 options나 components가 그렇게 많지 않아 인라인으로 작성하였으나, 좀 더 규모가 커진다면 분리해서 관리할 예정입니다.

TOC

마지막으로 헤더들을 모아두고, 네비게이트까지 담당하는 TableOfContents 컴포넌트입니다

features/TableOfContents.tsx
'use client';
 
import { useEffect, useState } from 'react';
 
export function TableOfContents() {
  const [toc, setToc] = useState<{ id: string; text: string; level: number }[]>([]);
  const [activeId, setActiveId] = useState('');
 
  useEffect(() => {
    const headings = window.document.querySelector('article')?.querySelectorAll('h2, h3, h4') as NodeListOf<HTMLElement>;
    const extractedHeadings = Array.from(headings).map((heading) => ({ id: heading.id, text: heading.innerText, level: parseInt(heading.tagName.substring(1), 10) }));
 
    setToc(extractedHeadings);
 
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            setActiveId(entry.target.id);
          }
        });
      },
      { rootMargin: '0% 0% -95% 0%' }
    );
 
    headings.forEach((element) => {
      observer.observe(element);
    });
 
    return () => {
      headings.forEach((element) => {
        observer.unobserve(element);
      });
    };
  }, []);
 
  const handleScroll = (id: string) => {
    const element = document.getElementById(id);
    if (element) {
      element.scrollIntoView({ behavior: 'smooth', block: 'start' });
    }
  };
 
  return (
    <aside className='absolute left-full top-0 hidden h-full w-52 sm:block'>
      <nav className='sticky top-[10vh] ml-8 w-52 rounded-lg bg-cool-gray-reverse p-4'>
        <p className='font-semibold text-warm-gray'>contents.</p>
        <div className='h-2' />
        <ul className='text-sm'>
          {toc.map((content, index) => {
            return (
              <li
                key={`toc_${index}`}
                className={`cursor-pointer`}
                onClick={(e) => {
                  e.preventDefault(); // 기본 해시 이동 동작 방지
                  handleScroll(content.id);
                }}
                style={{ marginLeft: `${(content.level - 2) * 8}px`, color: content.id === activeId ? '#f3aa51' : '', marginBottom: toc.length - 1 !== index ? '4px' : '' }}
              >
                {content.text}
              </li>
            );
          })}
        </ul>
      </nav>
    </aside>
  );
}

이 컴포넌트도 hooks를 사용해야하기 때문에 클라이언트 컴포넌트로 구현하였습니다.

전역 객체를 활용해 현재 그려지는 html에서 헤더 태그들을 모아 IntersectionObserver를 활용해 스크롤을 감지하여 강조표시를 할 수 있도록 구현하였습니다.

마무리

이렇게 Next.js와 markdown 파일을 이용해 블로그를 만드는 법을 간단하게 알아보았습니다.
추가적인 기능은 시간이 생길때마다 하나씩 더 작업할 에정입니다.

자세한 코드는 제 깃 레포지토리에서 확인해볼 수 있습니다.

블로그 레포지토리

안녕하세요, 프론트엔드 개발자 이진웅입니다!

확장성이 뛰어나고 유지보수가 용이한 개발 방법론에 큰 관심을 가지고 있습니다.

© jay.log powered by Next.js, Vercel