Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions apps/blog/e2e/not-found.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
16 changes: 4 additions & 12 deletions apps/blog/src/app/(main)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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) => ({
Expand Down
68 changes: 68 additions & 0 deletions apps/blog/src/app/not-found.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<MainClientLayout seriesList={seriesModels} tags={tags} posts={posts}>
<ArticleContainer>
<div className="flex flex-col items-center gap-6 py-[120px] text-center md:py-[160px]">
<p className="text-6xl font-bold text-gray-800 md:text-8xl">404</p>

<div className="flex flex-col gap-1 text-gray-600">
<p className="text-lg font-medium">페이지를 찾을 수 없습니다.</p>
<p className="text-sm">주소가 바뀌었거나 삭제된 글일 수 있어요. 아래에서 다시 시작해 보세요.</p>
</div>

{/* 복구 링크 — 검색은 헤더의 검색 버튼으로 제공된다. */}
<div className="flex gap-5">
<Link
href={PageLinkMap.main.landing()}
className="font-medium underline hover:text-gray-900"
>
홈으로
</Link>
<Link
href={PageLinkMap.tags.index()}
className="font-medium underline hover:text-gray-900"
>
전체 태그
</Link>
</div>

{/* 최근 글 바로가기 */}
{recentPosts.length > 0 && (
<div className="mt-6 w-full max-w-[640px] text-left">
<p className="mb-3 text-sm font-medium text-gray-500">최근 글</p>
<ul className="flex flex-col gap-2">
{recentPosts.map((post) => (
<li key={post.id}>
<Link
href={post.route}
className="text-sm text-gray-700 underline-offset-2 hover:underline"
>
{post.title}
</Link>
</li>
))}
</ul>
</div>
)}
</div>
</ArticleContainer>
</MainClientLayout>
);
}
9 changes: 7 additions & 2 deletions apps/blog/src/components/post-detail/post-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ 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';
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 {
Expand Down Expand Up @@ -209,7 +211,10 @@ export function PostDetail({ children, series, post }: PostDetailProps) {
{/* POST DETAIL Footer */}
{/* ------------------------------------------------------ */}
<footer className="post-detail-footer pb-15 md:pb-25 w-full">
<div className="flex gap-10 flex-col md:flex-row">
{/* 글 공유: 정식 절대 URL을 넘겨 localhost가 아닌 canonical 링크가 공유되게 한다. */}
<ShareButtons url={`${Constants.siteConfig.url}${post.route}`} title={post.title} />

<div className="mt-10 flex gap-10 flex-col md:flex-row">
{post.prevPost ? (
<PostNavigator mode="prev" post={post.prevPost} />
) : (
Expand All @@ -223,7 +228,7 @@ export function PostDetail({ children, series, post }: PostDetailProps) {
)}
</div>

<PostRecommendation />
<PostRecommendation currentPost={post} />

{/* === 댓글 (giscus · GitHub Discussions 기반, DB 없음) === */}
<div className="mt-16 md:mt-24">
Expand Down
23 changes: 15 additions & 8 deletions apps/blog/src/components/post-detail/post-recommendation.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@
'use client';

import { Constants } from '@libs/constants';
import { MainLayoutContext } from 'src/app/(main)/main-client-layout';
import { recommendPosts } from '@libs/recommend-posts';
import { PostModel } from '@libs/types/commons';
import Link from 'next/link';
import { useContext, useMemo } from 'react';

export function PostRecommendation() {
export interface PostRecommendationProps {
// 현재 보고 있는 글. 이 글과의 유사도로 추천 목록을 만든다.
currentPost: PostModel;
}

export function PostRecommendation({ currentPost }: PostRecommendationProps) {
// 전체 글 목록은 레이아웃 컨텍스트에 이미 있어 추가 fetch가 필요 없다.
const { posts } = useContext(MainLayoutContext);

const recommendedPosts = useMemo(() => {
const ids = Constants.recommendation.postIds;
return ids
.map((id) => posts.find((p) => p.id === id))
.filter((p): p is NonNullable<typeof p> => p !== undefined);
}, [posts]);
// 태그 겹침 + 같은 시리즈 가중치로 상위 4개를 산출한다(현재 글 제외).
const recommendedPosts = useMemo(
() => recommendPosts(currentPost, posts, 4),
[currentPost, posts],
);

// 접점 있는 글이 하나도 없으면 섹션 자체를 숨긴다.
if (recommendedPosts.length === 0) {
return null;
}
Expand Down
53 changes: 53 additions & 0 deletions apps/blog/src/components/post-detail/share-buttons.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { ShareButtons } from './share-buttons';

const url = 'https://blog.malloc72p.com/posts/frontend/my-post';
const title = '내 포스트 제목';

describe('ShareButtons', () => {
const writeText = jest.fn();

beforeEach(() => {
writeText.mockReset().mockResolvedValue(undefined);
Object.assign(navigator, { clipboard: { writeText } });
// 기본 환경(jsdom)에는 navigator.share가 없다고 가정한다.
Object.assign(navigator, { share: undefined });
});

it('링크 복사 버튼과 X 공유 링크(인텐트 URL)를 렌더한다', () => {
render(<ShareButtons url={url} title={title} />);

expect(screen.getByRole('button', { name: '링크 복사' })).toBeInTheDocument();

const xLink = screen.getByRole('link', { name: 'X(트위터)에 공유' });
expect(xLink).toHaveAttribute(
'href',
`https://twitter.com/intent/tweet?text=${encodeURIComponent(title)}&url=${encodeURIComponent(url)}`,
);
// 새 탭 + 안전한 rel을 보장한다.
expect(xLink).toHaveAttribute('target', '_blank');
expect(xLink).toHaveAttribute('rel', 'noopener noreferrer');
});

it('링크 복사 클릭 시 canonical URL을 클립보드에 쓰고 "복사됨"으로 피드백한다', async () => {
render(<ShareButtons url={url} title={title} />);

fireEvent.click(screen.getByRole('button', { name: '링크 복사' }));

expect(writeText).toHaveBeenCalledWith(url);
expect(await screen.findByRole('button', { name: '링크 복사됨' })).toBeInTheDocument();
});

it('navigator.share 미지원 환경에서는 네이티브 공유 버튼을 노출하지 않는다', () => {
render(<ShareButtons url={url} title={title} />);

expect(screen.queryByRole('button', { name: '공유하기' })).not.toBeInTheDocument();
});

it('navigator.share 지원 환경에서는 네이티브 공유 버튼을 노출한다', () => {
Object.assign(navigator, { share: jest.fn().mockResolvedValue(undefined) });
render(<ShareButtons url={url} title={title} />);

expect(screen.getByRole('button', { name: '공유하기' })).toBeInTheDocument();
});
});
92 changes: 92 additions & 0 deletions apps/blog/src/components/post-detail/share-buttons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
'use client';

import { IconBrandX, IconCheck, IconLink, IconShare } from '@tabler/icons-react';
import classNames from 'classnames';
import { useEffect, useState } from 'react';

export interface ShareButtonsProps {
// 공유할 글의 정식(canonical) 절대 URL.
url: string;
// 공유 텍스트로 사용할 글 제목.
title: string;
}

// 공통 버튼 스타일: 터치 타겟(약 44px) + 포커스 링.
const buttonClass = classNames(
'flex items-center justify-center p-2.5 rounded-md text-gray-500',
'hover:text-black hover:bg-gray-100 active:bg-gray-200',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400',
);

export function ShareButtons({ url, title }: ShareButtonsProps) {
// 링크 복사 성공 피드백 상태.
const [copied, setCopied] = useState(false);
// navigator.share 지원 여부. SSR과 마크업이 어긋나지 않도록 마운트 후에만 반영한다.
const [canNativeShare, setCanNativeShare] = useState(false);

useEffect(() => {
setCanNativeShare(typeof navigator !== 'undefined' && typeof navigator.share === 'function');
}, []);

const copyLink = async () => {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
// 1.5초 뒤 원래 아이콘으로 되돌린다.
setTimeout(() => setCopied(false), 1500);
} catch {
// 클립보드 접근이 거부된 환경에서는 조용히 무시한다.
}
};

const nativeShare = async () => {
try {
await navigator.share({ title, url });
} catch {
// 사용자가 공유 시트를 취소하면 무시한다.
}
};

// X(트위터) 공유 인텐트 URL. 제목과 링크를 쿼리로 안전하게 인코딩한다.
const xShareUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(
title,
)}&url=${encodeURIComponent(url)}`;

return (
<div className="post-share mt-10 flex items-center gap-2">
<span className="mr-1 text-sm font-medium text-gray-500">이 글 공유하기</span>

{/* 링크 복사 */}
<button
type="button"
onClick={copyLink}
aria-label={copied ? '링크 복사됨' : '링크 복사'}
className={buttonClass}
>
{copied ? (
<IconCheck className="h-5 w-5 stroke-green-500" />
) : (
<IconLink className="h-5 w-5" />
)}
</button>

{/* X(트위터) 공유 */}
<a
href={xShareUrl}
target="_blank"
rel="noopener noreferrer"
aria-label="X(트위터)에 공유"
className={buttonClass}
>
<IconBrandX className="h-5 w-5" />
</a>

{/* 모바일 등 지원 환경에서만 OS 네이티브 공유 시트를 띄운다. */}
{canNativeShare && (
<button type="button" onClick={nativeShare} aria-label="공유하기" className={buttonClass}>
<IconShare className="h-5 w-5" />
</button>
)}
</div>
);
}
27 changes: 27 additions & 0 deletions apps/blog/src/libs/api/load-layout-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { findPosts } from './find-posts';
import { findSeriesList } from './find-series';
import { findTags } from './find-tags';
import { Mapper } from '../mapper';
import { PostModel, SeriesModel, TagModel } from '../types/commons';

export interface MainLayoutData {
seriesModels: SeriesModel[];
tags: TagModel[];
// 시리즈 미존재로 스킵된 글을 제거한 PostModel 목록(정렬은 호출 측에서 필요에 맞게).
posts: PostModel[];
}

/**
* (main) 레이아웃과 커스텀 404 등에서 공통으로 쓰는 시리즈/태그/글 데이터를 로드한다.
* 데이터 소스·매핑을 한 곳에 모아 중복을 없앤다.
*/
export async function loadMainLayoutData(): Promise<MainLayoutData> {
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);

return { seriesModels, tags, posts };
}
22 changes: 0 additions & 22 deletions apps/blog/src/libs/constants.test.ts

This file was deleted.

3 changes: 0 additions & 3 deletions apps/blog/src/libs/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,6 @@ export const Constants = {
// skip 링크(본문 바로가기)와 <main> 타깃을 잇는 앵커 id. 두 파일이 같은 값을 공유하도록 단일화한다.
mainContentId: 'main-content',
},
recommendation: {
postIds: ['ai-tool', 'sse-exam', 'nextjs-grid-app-1', 'closure', 'execution-context'] as string[],
},
siteConfig: {
name: 'Malloc72p.TechBlog',
title: 'Malloc72P의 기술블로그',
Expand Down
Loading
Loading