diff --git a/apps/blog/e2e/not-found.spec.ts b/apps/blog/e2e/not-found.spec.ts index 1718fad..c827412 100644 --- a/apps/blog/e2e/not-found.spec.ts +++ b/apps/blog/e2e/not-found.spec.ts @@ -8,8 +8,12 @@ test.describe('존재하지 않는 경로', () => { // HTTP 응답 상태가 404여야 한다(가장 안정적인 계약). expect(response?.status()).toBe(404); - // 화면에 404가 노출되는지 확인한다. Next 내부 문구 전체에 의존하지 않도록 "404"만 검증한다. - // (커스텀 404는 별도 이슈 #93) + // 화면에 404가 노출되어야 한다. await expect(page.locator('body')).toContainText('404'); + + // 커스텀 404(#93): 헤더와 복구 동선(홈으로/전체 태그)이 포함되어야 한다. + await expect(page.locator('header.blog-main-header')).toBeVisible(); + await expect(page.getByRole('link', { name: '홈으로' })).toBeVisible(); + await expect(page.getByRole('link', { name: '전체 태그' })).toBeVisible(); }); }); diff --git a/apps/blog/src/app/(main)/layout.tsx b/apps/blog/src/app/(main)/layout.tsx index b158ddf..7c8e205 100644 --- a/apps/blog/src/app/(main)/layout.tsx +++ b/apps/blog/src/app/(main)/layout.tsx @@ -1,10 +1,6 @@ -import { findSeriesList } from '@libs/api/find-series'; -import { findTags } from '@libs/api/find-tags'; -import { Mapper } from '@libs/mapper'; -import { PostModel } from '@libs/types/commons'; +import { loadMainLayoutData } from '@libs/api/load-layout-data'; import { PropsWithChildren } from 'react'; import MainClientLayout from './main-client-layout'; -import { findPosts } from '@libs/api/find-posts'; export const dynamic = 'force-static'; @@ -14,13 +10,9 @@ export const dynamic = 'force-static'; * 포스트, 시리즈, 태그 페이지는 해당 레이아웃을 기본적으로 사용한다 */ export default async function MainLayout({ children }: PropsWithChildren) { - const seriesModels = (await findSeriesList()).map(Mapper.toSeriesModel); - const tags = (await findTags()).map(Mapper.toTagModel); - const posts = (await findPosts()) - .map((item) => Mapper.toPostModel({ item, seriesModels })) - // 시리즈 미존재로 스킵된 포스트(null)를 제거해 PostModel[]로 좁힌다. - .filter((post): post is PostModel => post !== null) - .sort((a, b) => a.date.localeCompare(b.date)); + const { seriesModels, tags, posts: loadedPosts } = await loadMainLayoutData(); + // 시리즈 내 이전/다음 글 계산을 위해 날짜 오름차순으로 정렬한다. + const posts = [...loadedPosts].sort((a, b) => a.date.localeCompare(b.date)); const postDesc = posts.slice().reverse(); const preparedPosts = posts.map((currentPost) => ({ diff --git a/apps/blog/src/app/not-found.tsx b/apps/blog/src/app/not-found.tsx new file mode 100644 index 0000000..b2c580e --- /dev/null +++ b/apps/blog/src/app/not-found.tsx @@ -0,0 +1,68 @@ +import { ArticleContainer } from '@components/article-container'; +import { loadMainLayoutData } from '@libs/api/load-layout-data'; +import { PageLinkMap } from '@libs/page-link-map'; +import Link from 'next/link'; +import MainClientLayout from 'src/app/(main)/main-client-layout'; + +/** + * 전역 커스텀 404 페이지. + * + * 헤더(검색 포함)·푸터를 그대로 쓰기 위해 (main) 레이아웃과 동일하게 데이터를 준비해 + * MainClientLayout으로 감싼다. 막다른 길에서 홈·태그·최근 글로 복귀할 동선을 제공한다. + */ +export default async function NotFound() { + const { seriesModels, tags, posts } = await loadMainLayoutData(); + + // 별도 인기도 데이터가 없어 최신 글 4개를 복구 동선으로 제시한다. + const recentPosts = [...posts].sort((a, b) => b.date.localeCompare(a.date)).slice(0, 4); + + return ( + + +
+

404

+ +
+

페이지를 찾을 수 없습니다.

+

주소가 바뀌었거나 삭제된 글일 수 있어요. 아래에서 다시 시작해 보세요.

+
+ + {/* 복구 링크 — 검색은 헤더의 검색 버튼으로 제공된다. */} +
+ + 홈으로 + + + 전체 태그 + +
+ + {/* 최근 글 바로가기 */} + {recentPosts.length > 0 && ( +
+

최근 글

+
    + {recentPosts.map((post) => ( +
  • + + {post.title} + +
  • + ))} +
+
+ )} +
+
+
+ ); +} diff --git a/apps/blog/src/components/post-detail/post-detail.tsx b/apps/blog/src/components/post-detail/post-detail.tsx index 7511e0d..9b5f610 100644 --- a/apps/blog/src/components/post-detail/post-detail.tsx +++ b/apps/blog/src/components/post-detail/post-detail.tsx @@ -4,6 +4,7 @@ import { ArticleHeader } from '@components/article'; import { ArticleContainer } from '@components/article-container'; import { Divider } from '@components/divider'; import { PostJsonLd } from '@components/post-json-ld'; +import { Constants } from '@libs/constants'; import { PostModel, SeriesModel, TagModel } from '@libs/types/commons'; import classNames from 'classnames'; import { PropsWithChildren, useEffect, useRef, useState } from 'react'; @@ -11,6 +12,7 @@ import classes from './post-detail.module.scss'; import { Toc, TocItem } from './toc'; import { PostNavigator, PostNavigatorPlaceholder } from './post-navigator'; import { PostRecommendation } from './post-recommendation'; +import { ShareButtons } from './share-buttons'; import { GiscusComments } from './giscus-comments'; export interface PostDetailProps extends PropsWithChildren { @@ -209,7 +211,10 @@ export function PostDetail({ children, series, post }: PostDetailProps) { {/* POST DETAIL Footer */} {/* ------------------------------------------------------ */}