diff --git a/docs/exec-plans/active/2026-04-17-home-feed-layout-review.md b/docs/exec-plans/active/2026-04-17-home-feed-layout-review.md new file mode 100644 index 00000000..5ed21eb8 --- /dev/null +++ b/docs/exec-plans/active/2026-04-17-home-feed-layout-review.md @@ -0,0 +1,167 @@ +# 홈 메인 피드형 레이아웃 전환 검토 + +## Goal + +메인 페이지를 큰 히어로 중심 소개형 랜딩에서, 콘텐츠 접근성이 높은 +피드형 홈으로 전환하기 위한 방향과 판단 근거를 정리한다. + +이번 문서는 실제 코드 구현보다 앞선 실행 메모 성격의 문서다. 홈의 역할을 +`자기소개 우선`에서 `콘텐츠 발견 우선`으로 재정의하고, 어떤 구조와 원칙으로 +전환할지 결정 완료 상태로 남긴다. + +## Scope + +- 현재 홈 구조의 한계와 전환 배경 정리 +- TO-BE 레이아웃과 정보 구조 정리 +- 프로필 이미지 없이도 성립하는 브랜딩 방식 정리 +- 외부 참고 서비스 관점의 평가 정리 +- 구현 시 바로 참고할 수 있는 메모 정리 + +이번 범위에는 실제 화면 구현, 컴포넌트 수정, 라우팅 변경은 포함하지 않는다. + +## Constraints + +- 기존 코드 구조는 유지한다. + - `src/features/home` + - `src/features/blog` + - `src/shared` +- 메인 홈은 피드 중심으로 재구성하되, 블로그 전체를 평범한 게시판처럼 + 보이게 만들면 안 된다. +- 우측 브랜딩 영역은 프로필 이미지 의존성을 두지 않는다. +- 첫 버전에서 보조 위젯은 최소화한다. +- 상단 탐색 컨트롤은 `검색 + 정렬 + 카테고리`까지 포함한다. +- 모바일에서는 1컬럼 중심으로 자연스럽게 접혀야 한다. + +## Milestones + +### 1. 배경 + +현재 홈은 큰 히어로를 중심으로 구성되어 있고, 사용자가 먼저 보게 되는 것은 +소개와 브랜딩이다. 이 구조는 처음 방문한 사람에게 정체성을 전달하는 데는 +유리하지만, 글을 바로 읽고 싶은 사용자 입장에서는 첫 클릭까지 거리가 있다. + +블로그 글이 충분히 쌓인 시점에는 "내가 누구인가"보다 "무엇을 꾸준히 쓰는가"를 +빠르게 보여주는 편이 더 설득력 있다. 따라서 홈의 중심축을 소개에서 피드로 +옮기는 것이 적절하다고 판단했다. + +### 2. AS-IS 진단 + +- 브랜딩 전달은 강하지만 콘텐츠 탐색이 느리다. +- 홈의 중심이 소개형 섹션이라 최신 글 소비 흐름이 약하다. +- 글이 늘어날수록 홈의 정보 구조가 확장성보다는 랜딩 성격에 머무를 수 있다. +- 대표 글, 시리즈, 이력 요약이 분리 섹션으로 존재해 한 화면에서의 집중도가 + 분산된다. + +### 3. TO-BE 방향 + +메인 홈은 3컬럼 구조로 재구성한다. + +- 좌측: 탐색 + - 카테고리 또는 핵심 진입점 + - 데스크톱에서는 고정 또는 스티키 + - 모바일에서는 상단으로 접거나 가로 스크롤 형태로 축약 +- 중앙: 피드 메인 + - 작은 인트로 스트립 + - 검색 입력 + - 정렬 컨트롤 + - 카테고리 필터 + - 썸네일, 제목, 날짜, 요약, 태그를 포함한 포스트 피드 +- 우측: 브랜딩 + - 이미지 없는 텍스트형 프로필 카드 + - 이름, 한 줄 소개, 역할/관심사 + - 이력서, GitHub, 이메일 등 핵심 링크 + +핵심 원칙은 `중앙 피드가 주인공`이어야 한다는 점이다. 좌우 영역은 메인을 +보조해야 하며, 메인보다 더 튀거나 더 많은 판단을 요구하면 안 된다. + +### 4. 텍스트 기반 브랜딩 카드 원칙 + +우측 카드는 프로필 이미지를 넣지 않아도 매력적으로 보여야 한다. + +- 얼굴 이미지 대신 문장과 타이포로 인상을 만든다. +- 이름보다 "무엇을 쓰는 사람인가"가 더 빨리 읽혀야 한다. +- 링크는 많기보다 선명해야 한다. +- 카드 자체가 화려하기보다 중앙 피드의 신뢰도를 높이는 역할을 해야 한다. + +즉, 브랜딩을 제거하는 것이 아니라 압축하는 방향이다. + +### 5. 외부 관점 평가 + +아래 평가는 공개된 제품 패턴과 문서를 바탕으로 정리한 추론이다. + +#### 토스 관점 + +토스 계열의 UX 감각에서는 탐색 진입점의 명확성과 사용자의 이동 비용 감소가 +긍정적으로 평가될 가능성이 높다. 큰 소개보다 바로 읽을 수 있는 글 피드를 +앞세우는 방향은 서비스형 탐색 경험과 잘 맞는다. + +다만 주변 위젯이 많아지거나 좌우 정보가 과도하면, 메인 액션이 흐려진다는 +지적을 받을 수 있다. + +#### Medium / Substack 관점 + +콘텐츠 플랫폼 관점에서는 메인 화면의 주인공이 콘텐츠여야 한다는 점에서 +방향성이 맞다. 특히 최신 글, 발견, 추천의 흐름이 홈에 직접 드러나는 구조는 +자연스럽다. + +반대로 모든 카드가 같은 무게로만 쌓이면 에디토리얼 위계가 약해 보일 수 있다. +첫 번째 대표 글 또는 고정 글에 대한 강조가 추후 필요할 수 있다. + +#### LinkedIn / SNS 크리에이터 관점 + +작성자 정체성은 남겨야 하지만, 압축된 형태여야 한다는 평가가 예상된다. +프로필 이미지를 빼는 선택은 가능하지만, 대신 한 줄 소개와 역할 정의가 더 +정확해야 한다. + +잘 되면 성숙한 텍스트 기반 브랜딩으로 보이지만, 약하면 익명성 높은 목록형 +페이지처럼 보일 위험이 있다. + +### 6. 최종 의사결정 + +- 큰 히어로는 줄인다. +- 홈의 중심은 중앙 피드로 바꾼다. +- 우측은 이미지 없는 텍스트형 프로필 카드로 간다. +- 첫 버전은 보조 위젯을 넣지 않는다. +- 상단 컨트롤은 `검색 + 정렬 + 카테고리`를 포함한다. +- 홈은 소개형 랜딩보다 콘텐츠 접근성이 우선인 구조로 전환한다. + +### 7. 구현 메모 + +- `HomePage`는 기존의 큰 `HeroSection` 의존을 줄이고, 홈 전용 레이아웃을 + 중심으로 재구성한다. +- 글 데이터는 기존 블로그 도메인과 리스트/카드 컴포넌트를 최대한 재사용한다. +- 리스트 UI는 현재 `PostList`와 `PostCard`의 `list` 변형을 기반으로 조정한다. +- 모바일에서는 좌우 컬럼을 접고 중앙 피드 우선 구조를 유지한다. +- 첫 구현에서는 `Friends`, `Old Blog` 같은 부가 위젯은 제외한다. + +## Verification + +- 홈의 목적이 `소개 우선`에서 `콘텐츠 발견 우선`으로 명확히 전환되었는지 + 문서 수준에서 설명 가능해야 한다. +- 좌측 탐색, 중앙 피드, 우측 브랜딩의 역할이 중복 없이 분리되어 있어야 한다. +- 프로필 이미지 없이도 브랜딩 카드가 성립하는 이유가 문서에 드러나야 한다. +- 외부 참고 서비스 관점에서의 긍정 포인트와 리스크가 함께 정리되어 있어야 + 한다. +- 구현자가 이 문서만 읽고 홈 구조 개편의 의도와 우선순위를 이해할 수 있어야 + 한다. + +## Rollback + +실제 구현 단계에서 아래 신호가 보이면 피드형 전환 강도를 낮춘다. + +- 좌우 영역 때문에 메인 피드 집중도가 떨어지는 경우 +- 검색/정렬/카테고리가 한 화면에서 과밀하게 느껴지는 경우 +- 텍스트형 브랜딩 카드가 충분한 존재감을 만들지 못하는 경우 +- 전체 홈이 generic한 블로그 목록처럼 보이는 경우 + +이 경우 롤백 방향은 `완전한 피드형 폐기`가 아니라, 상단 compact intro를 조금 +키우거나 대표 글 1개를 더 강하게 강조하는 방식으로 위계를 보정하는 것이다. + +## References + +- [Toss Navigation Score 사례](https://toss.tech/article/Toss_Navigation_Score) +- [Toss Engineering 카테고리](https://toss.tech/category/engineering) +- [Medium homepage help](https://help.medium.com/hc/en-us/articles/115012586467-Your-homepage) +- [Substack app home guide](https://support.substack.com/hc/en-us/articles/19291693034004-Getting-started-on-the-Substack-app) +- [Substack homepage layouts](https://support.substack.com/hc/en-us/articles/360039015892-How-do-I-switch-my-publication-s-homepage-to-a-different-layout) +- [LinkedIn articles/newsletters guide](https://members.linkedin.com/content/dam/me/members/en-us/pdf/articlesnewsletters.pdf) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f1dbf6ec..6f837312 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,8 @@ import '@/styles/globals.css'; import '@/styles/tossface.css'; import AppProviders from '@/core/providers/AppProviders'; +import { getSortedFeedData } from '@/features/blog/services/post-repository'; +import { AppShell } from '@/shared/layout'; import { SITE_AUTHOR, SITE_DESCRIPTION, @@ -61,6 +63,8 @@ export default function RootLayout({ }: { children: ReactNode; }) { + const posts = getSortedFeedData(); + return ( @@ -81,7 +85,9 @@ export default function RootLayout({ -
{children}
+
+ {children} +
diff --git a/src/features/blog/ui/components/PostCard.test.tsx b/src/features/blog/ui/components/PostCard.test.tsx index da4ec606..bcd287da 100644 --- a/src/features/blog/ui/components/PostCard.test.tsx +++ b/src/features/blog/ui/components/PostCard.test.tsx @@ -88,4 +88,23 @@ describe('PostCard', () => { '/images/test.png' ); }); + + it('shows category, series title, and tags in list variant', () => { + const detailedPost: FeedData = { + ...basePost, + tags: ['redis', 'cache'], + series: { + id: 'redis', + title: 'Redis 완전정복', + order: 1, + }, + }; + + render(); + + expect(screen.getByText('Tech')).toBeInTheDocument(); + expect(screen.getByText('Redis 완전정복')).toBeInTheDocument(); + expect(screen.getByText('#redis')).toBeInTheDocument(); + expect(screen.getByText('#cache')).toBeInTheDocument(); + }); }); diff --git a/src/features/blog/ui/components/PostCard/PostCard.tsx b/src/features/blog/ui/components/PostCard/PostCard.tsx index 6767449d..cefd1ea7 100644 --- a/src/features/blog/ui/components/PostCard/PostCard.tsx +++ b/src/features/blog/ui/components/PostCard/PostCard.tsx @@ -73,6 +73,7 @@ export default function PostCard({ post, variant = 'default' }: PostCardProps) { day: 'numeric', }); const readingTimeLabel = post.readingTime ? `약 ${post.readingTime}분` : null; + const visibleTags = post.tags?.slice(0, 3) ?? []; if (variant === 'featured') { return ( @@ -118,8 +119,13 @@ export default function PostCard({ post, variant = 'default' }: PostCardProps) {
- {post.series ? 'Series' : post.category} + {post.category} + {post.series ? ( + + {post.series.title} + + ) : null} {readingTimeLabel && ( @@ -135,6 +141,18 @@ export default function PostCard({ post, variant = 'default' }: PostCardProps) {

{post.description}

+ {visibleTags.length > 0 ? ( +
+ {visibleTags.map((tag) => ( + + #{tag} + + ))} +
+ ) : null}
{post.image ? (
diff --git a/src/features/blog/ui/pages/BlogListPage.tsx b/src/features/blog/ui/pages/BlogListPage.tsx index e90911c0..69d159af 100644 --- a/src/features/blog/ui/pages/BlogListPage.tsx +++ b/src/features/blog/ui/pages/BlogListPage.tsx @@ -1,6 +1,6 @@ import { Metadata } from 'next'; import { getSortedFeedData } from '@/features/blog/services/post-repository'; -import { Header, Container } from '@/shared/layout'; +import { Container } from '@/shared/layout'; import BlogListClient from './BlogListClient'; export const metadata: Metadata = { @@ -12,23 +12,19 @@ export default function BlogPage() { const allPosts = getSortedFeedData(); return ( - <> -
+
+ +
+

+ Blog +

+

+ 개발과 일상에 대한 이야기를 나눕니다 +

+
-
- -
-

- Blog -

-

- 개발과 일상에 대한 이야기를 나눕니다 -

-
- - -
-
- + +
+
); } diff --git a/src/features/blog/ui/pages/BlogPostPage.tsx b/src/features/blog/ui/pages/BlogPostPage.tsx index 55a4f5c6..16c7cb0a 100644 --- a/src/features/blog/ui/pages/BlogPostPage.tsx +++ b/src/features/blog/ui/pages/BlogPostPage.tsx @@ -9,7 +9,7 @@ import { getMdxSource, parseHeadingsFromMdx, } from '@/features/blog/services/markdown-parser'; -import { Header, Container } from '@/shared/layout'; +import { Container } from '@/shared/layout'; import { ReadingProgress, TableOfContents, @@ -92,7 +92,6 @@ export default async function BlogPostPage({ return ( <> -
-
+
{/* Header */}
diff --git a/src/features/blog/ui/pages/EngineeringPage.tsx b/src/features/blog/ui/pages/EngineeringPage.tsx index d9045b5a..e470b1a0 100644 --- a/src/features/blog/ui/pages/EngineeringPage.tsx +++ b/src/features/blog/ui/pages/EngineeringPage.tsx @@ -1,7 +1,7 @@ import { Metadata } from 'next'; import { Suspense } from 'react'; import { getSortedFeedData } from '@/features/blog/services/post-repository'; -import { Header, Container } from '@/shared/layout'; +import { Container } from '@/shared/layout'; import EngineeringPageClient from './EngineeringPageClient'; export const metadata: Metadata = { @@ -15,25 +15,21 @@ export default function EngineeringPage() { ); return ( - <> -
+
+ +
+

+ Engineering +

+

+ 기술 글과 시리즈를 같은 흐름에서 살펴볼 수 있어요 +

+
-
- -
-

- Engineering -

-

- 기술 글과 시리즈를 같은 흐름에서 살펴볼 수 있어요 -

-
- - - - -
-
- + + + +
+
); } diff --git a/src/features/blog/ui/pages/EngineeringSeriesPage.tsx b/src/features/blog/ui/pages/EngineeringSeriesPage.tsx index 5f42e550..a0b32813 100644 --- a/src/features/blog/ui/pages/EngineeringSeriesPage.tsx +++ b/src/features/blog/ui/pages/EngineeringSeriesPage.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; import { notFound } from 'next/navigation'; import { getSeriesSummaries } from '@/features/blog/model/series-group'; import { getSortedFeedData } from '@/features/blog/services/post-repository'; -import { Header, Container } from '@/shared/layout'; +import { Container } from '@/shared/layout'; interface EngineeringSeriesPageProps { seriesId: string; @@ -31,63 +31,59 @@ export default function EngineeringSeriesPage({ } return ( - <> -
+
+ + + + Engineering으로 돌아가기 + -
- - - - Engineering으로 돌아가기 - +
+ + Series + +

+ {seriesSummary.title} +

+

+ {seriesSummary.postCount}개 글을 순서대로 확인할 수 있어요. +

+
-
- - Series - -

- {seriesSummary.title} -

-

- {seriesSummary.postCount}개 글을 순서대로 확인할 수 있어요. -

-
+
    + {seriesSummary.posts.map((post) => { + const order = post.series?.order ?? 0; -
      - {seriesSummary.posts.map((post) => { - const order = post.series?.order ?? 0; - - return ( -
    1. - -
      -

      - {order}편 · {formatPostDate(post.date)} -

      -

      - {post.title} -

      -

      - {post.description} -

      -
      - - 글 보기 - - -
    2. - ); - })} -
    - -
- + return ( +
  • + +
    +

    + {order}편 · {formatPostDate(post.date)} +

    +

    + {post.title} +

    +

    + {post.description} +

    +
    + + 글 보기 + + +
  • + ); + })} + +
    +
    ); } diff --git a/src/features/blog/ui/pages/LifePage.tsx b/src/features/blog/ui/pages/LifePage.tsx index e19c1c4c..ca8b1788 100644 --- a/src/features/blog/ui/pages/LifePage.tsx +++ b/src/features/blog/ui/pages/LifePage.tsx @@ -1,6 +1,6 @@ import { Metadata } from 'next'; import { getSortedFeedData } from '@/features/blog/services/post-repository'; -import { Header, Container } from '@/shared/layout'; +import { Container } from '@/shared/layout'; import { PostList } from '@/features/blog/ui/components'; export const metadata: Metadata = { @@ -12,23 +12,19 @@ export default function LifePage() { const lifePosts = getSortedFeedData().filter((post) => post.category === 'Life'); return ( - <> -
    +
    + +
    +

    + Life +

    +

    + 일상에서 배운 점과 오래 남은 생각을 차분하게 정리해요 +

    +
    -
    - -
    -

    - Life -

    -

    - 일상에서 배운 점과 오래 남은 생각을 차분하게 정리해요 -

    -
    - - -
    -
    - + +
    +
    ); } diff --git a/src/features/blog/ui/pages/SeriesPage.tsx b/src/features/blog/ui/pages/SeriesPage.tsx index 96a7dd1b..74e2b638 100644 --- a/src/features/blog/ui/pages/SeriesPage.tsx +++ b/src/features/blog/ui/pages/SeriesPage.tsx @@ -1,6 +1,6 @@ import { Metadata } from 'next'; import { getSortedFeedData } from '@/features/blog/services/post-repository'; -import { Header, Container } from '@/shared/layout'; +import { Container } from '@/shared/layout'; import { EmptyState } from '@/shared/ui'; import { getSeriesSummaries } from '@/features/blog/model/series-group'; import { SeriesHubList } from '@/features/blog/ui/components'; @@ -19,40 +19,36 @@ export default function SeriesPage() { ); return ( - <> -
    +
    + +
    +

    + 시리즈 +

    +

    + 하나의 주제를 깊게 다룬 연재 글을 모아봤습니다 +

    +

    + {seriesSummaries.length}개 시리즈 · {totalEpisodes}개 글 +

    +
    -
    - -
    -

    - 시리즈 -

    -

    - 하나의 주제를 깊게 다룬 연재 글을 모아봤습니다 -

    -

    - {seriesSummaries.length}개 시리즈 · {totalEpisodes}개 글 -

    -
    - - {seriesSummaries.length === 0 ? ( - 📚} - title="아직 등록된 시리즈가 없어요" - description="새로운 시리즈를 준비 중입니다. 먼저 블로그의 다른 글을 둘러보세요." - size="sm" - action={{ - label: 'Engineering 둘러보기', - href: '/engineering', - variant: 'secondary', - }} - /> - ) : ( - - )} -
    -
    - + {seriesSummaries.length === 0 ? ( + 📚} + title="아직 등록된 시리즈가 없어요" + description="새로운 시리즈를 준비 중입니다. 먼저 블로그의 다른 글을 둘러보세요." + size="sm" + action={{ + label: 'Engineering 둘러보기', + href: '/engineering', + variant: 'secondary', + }} + /> + ) : ( + + )} +
    +
    ); } diff --git a/src/features/home/model/home-feed.test.ts b/src/features/home/model/home-feed.test.ts new file mode 100644 index 00000000..6fef3e06 --- /dev/null +++ b/src/features/home/model/home-feed.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import type { FeedData } from '@/domains/post/model/types'; +import { + buildHomeCategoryCounts, + filterHomePosts, + type HomePopularView, +} from './home-feed'; + +function createPost( + slug: string, + category: FeedData['category'], + date: string, + overrides: Partial = {} +): FeedData { + return { + slug, + title: slug, + description: `${slug} description`, + date, + category, + ...overrides, + }; +} + +describe('home-feed model', () => { + it('builds category counts for all home filters', () => { + const posts = [ + createPost('one', 'Tech', '2026-04-01'), + createPost('two', 'Life', '2026-04-02'), + createPost('three', 'Tech', '2026-04-03'), + ]; + + expect(buildHomeCategoryCounts(posts)).toEqual({ + All: 3, + Tech: 2, + Life: 1, + }); + }); + + it('filters posts by query across title, description, tags, and series', () => { + const posts = [ + createPost('redis', 'Tech', '2026-04-03', { + title: 'Redis 운영 회고', + tags: ['Cache'], + }), + createPost('travel', 'Life', '2026-04-02', { + title: '베트남 여행 후기', + }), + createPost('flink-series', 'Tech', '2026-04-01', { + title: '체크포인트 정리', + series: { + id: 'flink', + title: 'Flink 완전정복', + order: 1, + }, + }), + ]; + + expect( + filterHomePosts(posts, [], { + query: 'cache', + category: 'All', + sortOrder: 'latest', + }).map((post) => post.slug) + ).toEqual(['redis']); + + expect( + filterHomePosts(posts, [], { + query: 'flink', + category: 'All', + sortOrder: 'latest', + }).map((post) => post.slug) + ).toEqual(['flink-series']); + }); + + it('sorts by popularity first and falls back to latest order', () => { + const posts = [ + createPost('older-popular', 'Tech', '2026-04-01'), + createPost('latest-normal', 'Tech', '2026-04-03'), + createPost('mid-popular', 'Life', '2026-04-02'), + ]; + const popularViews: HomePopularView[] = [ + { slug: 'mid-popular', count: 10 }, + { slug: 'older-popular', count: 20 }, + ]; + + expect( + filterHomePosts(posts, popularViews, { + query: '', + category: 'All', + sortOrder: 'popular', + }).map((post) => post.slug) + ).toEqual(['older-popular', 'mid-popular', 'latest-normal']); + }); +}); diff --git a/src/features/home/model/home-feed.ts b/src/features/home/model/home-feed.ts new file mode 100644 index 00000000..3876b3fa --- /dev/null +++ b/src/features/home/model/home-feed.ts @@ -0,0 +1,83 @@ +import type { FeedData } from '@/domains/post/model/types'; +import type { Category } from '@/features/blog/ui/components/CategoryFilter'; + +export interface HomePopularView { + slug: string; + count: number; +} + +export type HomeSortOrder = 'latest' | 'popular'; + +interface HomeFeedOptions { + query: string; + category: Category; + sortOrder: HomeSortOrder; +} + +export function buildHomeCategoryCounts( + posts: FeedData[] +): Record { + return { + All: posts.length, + Tech: posts.filter((post) => post.category === 'Tech').length, + Life: posts.filter((post) => post.category === 'Life').length, + }; +} + +function buildSearchText(post: FeedData): string { + return [ + post.title, + post.description, + post.category, + post.series?.title, + ...(post.tags ?? []), + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); +} + +function compareByDateDesc(a: FeedData, b: FeedData): number { + if (a.date === b.date) { + return a.slug.localeCompare(b.slug); + } + + return a.date < b.date ? 1 : -1; +} + +export function filterHomePosts( + posts: FeedData[], + popularViews: HomePopularView[], + { query, category, sortOrder }: HomeFeedOptions +): FeedData[] { + const normalizedQuery = query.trim().toLowerCase(); + const popularCountBySlug = new Map( + popularViews.map((entry) => [entry.slug, entry.count]) + ); + + return [...posts] + .filter((post) => { + if (category !== 'All' && post.category !== category) { + return false; + } + + if (!normalizedQuery) { + return true; + } + + return buildSearchText(post).includes(normalizedQuery); + }) + .sort((left, right) => { + if (sortOrder === 'popular') { + const countGap = + (popularCountBySlug.get(right.slug) ?? 0) - + (popularCountBySlug.get(left.slug) ?? 0); + + if (countGap !== 0) { + return countGap; + } + } + + return compareByDateDesc(left, right); + }); +} diff --git a/src/features/home/ui/pages/HomePage.test.tsx b/src/features/home/ui/pages/HomePage.test.tsx index 73cf0437..12db3797 100644 --- a/src/features/home/ui/pages/HomePage.test.tsx +++ b/src/features/home/ui/pages/HomePage.test.tsx @@ -4,30 +4,21 @@ import type { FeedData } from '@/domains/post/model/types'; import HomePage from './HomePage'; const mockGetSortedFeedData = vi.fn<() => FeedData[]>(() => []); -const mockGetSeriesSummaries = vi.fn(() => []); const mockGetPopularViewsInRecentDays = vi.fn(async () => []); vi.mock('@/features/blog/services/post-repository', () => ({ getSortedFeedData: () => mockGetSortedFeedData(), })); -vi.mock('@/features/blog/model/series-group', () => ({ - getSeriesSummaries: (...args: unknown[]) => mockGetSeriesSummaries(...args), -})); - vi.mock('@/app/actions/view', () => ({ getPopularViewsInRecentDays: (...args: unknown[]) => mockGetPopularViewsInRecentDays(...args), })); -vi.mock('@/shared/layout', () => ({ - Header: () =>
    , -})); - -const mockHeroSection = vi.fn(() =>
    ); +const mockHomePageClient = vi.fn(() =>
    ); -vi.mock('@/features/home/ui/sections/HeroSection', () => ({ - default: (props: unknown) => mockHeroSection(props), +vi.mock('./HomePageClient', () => ({ + default: (props: unknown) => mockHomePageClient(props), })); function createPost(index: number): FeedData { @@ -41,7 +32,7 @@ function createPost(index: number): FeedData { } describe('HomePage', () => { - it('fills popular list with latest non-series posts when view data is empty', async () => { + it('passes posts and normalized popular views to the home client', async () => { mockGetSortedFeedData.mockReturnValue([ createPost(1), createPost(2), @@ -50,23 +41,28 @@ describe('HomePage', () => { createPost(5), createPost(6), ]); - mockGetSeriesSummaries.mockReturnValue([]); - mockGetPopularViewsInRecentDays.mockResolvedValue([]); + mockGetPopularViewsInRecentDays.mockResolvedValue([ + { + slug: 'post-2', + count: 17, + updated_at: '2026-03-20T00:00:00.000Z', + }, + ]); const element = await HomePage(); render(element); - expect(screen.getByTestId('mock-header')).toBeInTheDocument(); - expect(screen.getByTestId('hero')).toBeInTheDocument(); + expect(screen.getByTestId('home-page-client')).toBeInTheDocument(); + expect(mockGetPopularViewsInRecentDays).toHaveBeenCalledWith(30, 6); - const firstCallArgs = mockHeroSection.mock.calls[0]?.[0] as { - popularPosts: Array<{ post: FeedData; viewCount: number | null }>; + const firstCallArgs = mockHomePageClient.mock.calls[0]?.[0] as { + posts: FeedData[]; + popularViews: Array<{ slug: string; count: number }>; }; - expect(firstCallArgs.popularPosts).toHaveLength(5); - expect( - firstCallArgs.popularPosts.every((item) => item.viewCount === null) - ).toBe(true); - expect(firstCallArgs.popularPosts[0].post.slug).toBe('post-1'); + expect(firstCallArgs.posts).toHaveLength(6); + expect(firstCallArgs.popularViews).toEqual([ + { slug: 'post-2', count: 17 }, + ]); }); }); diff --git a/src/features/home/ui/pages/HomePage.tsx b/src/features/home/ui/pages/HomePage.tsx index 1c6b463d..dac4ab1c 100644 --- a/src/features/home/ui/pages/HomePage.tsx +++ b/src/features/home/ui/pages/HomePage.tsx @@ -1,106 +1,25 @@ -import { Header } from '@/shared/layout'; -import type { FeedData } from '@/domains/post/model/types'; import { getPopularViewsInRecentDays } from '@/app/actions/view'; import { getSortedFeedData } from '@/features/blog/services/post-repository'; -import { getSeriesSummaries } from '@/features/blog/model/series-group'; -import HeroSection from '@/features/home/ui/sections/HeroSection'; +import HomePageClient from './HomePageClient'; -const POPULAR_POST_LIMIT = 5; const POPULAR_VIEW_DAYS = 30; -export interface HomePopularPost { - post: FeedData; - viewCount: number | null; -} - -function buildPopularPosts( - allPosts: FeedData[], - popularViews: Array<{ slug: string; count: number }> -): HomePopularPost[] { - const nonSeriesPosts = allPosts.filter((post) => !post.series); - const postMap = new Map(nonSeriesPosts.map((post) => [post.slug, post])); - const pickedSlugs = new Set(); - - const rankedMatches = popularViews - .map((entry) => { - const post = postMap.get(entry.slug); - - if (!post) { - return null; - } - - return { - post, - viewCount: entry.count, - }; - }) - .filter((entry): entry is HomePopularPost => entry !== null) - .sort((a, b) => { - const countGap = (b.viewCount ?? 0) - (a.viewCount ?? 0); - if (countGap !== 0) { - return countGap; - } - - return a.post.date < b.post.date ? 1 : -1; - }); - - const result: HomePopularPost[] = []; - - for (const entry of rankedMatches) { - if (result.length >= POPULAR_POST_LIMIT) { - break; - } - - if (pickedSlugs.has(entry.post.slug)) { - continue; - } - - pickedSlugs.add(entry.post.slug); - result.push(entry); - } - - if (result.length >= POPULAR_POST_LIMIT) { - return result; - } - - for (const post of nonSeriesPosts) { - if (result.length >= POPULAR_POST_LIMIT) { - break; - } - - if (pickedSlugs.has(post.slug)) { - continue; - } - - pickedSlugs.add(post.slug); - result.push({ - post, - viewCount: null, - }); - } - - return result; -} - export default async function HomePage() { const sortedPosts = getSortedFeedData(); - const seriesSummaries = getSeriesSummaries(sortedPosts); const popularViews = await getPopularViewsInRecentDays( POPULAR_VIEW_DAYS, - POPULAR_POST_LIMIT + Math.max(sortedPosts.length, 1) ); - const popularPosts = buildPopularPosts(sortedPosts, popularViews); return ( - <> -
    -
    - -
    - +
    + ({ + slug, + count, + }))} + /> +
    ); } diff --git a/src/features/home/ui/pages/HomePageClient.tsx b/src/features/home/ui/pages/HomePageClient.tsx new file mode 100644 index 00000000..9251d17c --- /dev/null +++ b/src/features/home/ui/pages/HomePageClient.tsx @@ -0,0 +1,145 @@ +'use client'; + +import { useDeferredValue, useMemo, useState } from 'react'; +import { clsx } from 'clsx'; +import type { FeedData } from '@/domains/post/model/types'; +import { + CategoryFilter, + PostList, + type Category, +} from '@/features/blog/ui/components'; +import { + buildHomeCategoryCounts, + filterHomePosts, + type HomePopularView, + type HomeSortOrder, +} from '@/features/home/model/home-feed'; +import { Container } from '@/shared/layout'; + +interface HomePageClientProps { + posts: FeedData[]; + popularViews: HomePopularView[]; +} + +const FEED_CATEGORIES: Category[] = ['All', 'Tech', 'Life']; +const SORT_OPTIONS: Array<{ value: HomeSortOrder; label: string }> = [ + { value: 'latest', label: '최신순' }, + { value: 'popular', label: '인기순' }, +]; + +export default function HomePageClient({ + posts, + popularViews, +}: HomePageClientProps) { + const [searchQuery, setSearchQuery] = useState(''); + const [activeCategory, setActiveCategory] = useState('All'); + const [sortOrder, setSortOrder] = useState('latest'); + const deferredSearchQuery = useDeferredValue(searchQuery); + + const categoryCounts = useMemo(() => buildHomeCategoryCounts(posts), [posts]); + const filteredPosts = useMemo( + () => + filterHomePosts(posts, popularViews, { + query: deferredSearchQuery, + category: activeCategory, + sortOrder, + }), + [activeCategory, deferredSearchQuery, popularViews, posts, sortOrder] + ); + + const activeSortLabel = + SORT_OPTIONS.find((option) => option.value === sortOrder)?.label ?? + '최신순'; + + return ( + +
    +
    +
    +
    +

    + Archive +

    +

    + {filteredPosts.length}개의 글 + + {activeSortLabel} +

    +
    + + {deferredSearchQuery.trim() ? ( +

    + "{deferredSearchQuery.trim()}" 검색 결과 +

    + ) : null} +
    + + +
    + + setSearchQuery(event.target.value)} + placeholder="글 제목, 설명, 태그로 검색해보세요" + className="w-full bg-transparent text-sm text-[var(--color-grey-900)] outline-none placeholder:text-[var(--color-grey-400)]" + /> +
    + +
    + + +
    + {SORT_OPTIONS.map((option) => ( + + ))} +
    +
    +
    + + +
    +
    + ); +} + +export type { HomePageClientProps }; diff --git a/src/features/resume/ui/pages/ResumePage.tsx b/src/features/resume/ui/pages/ResumePage.tsx index 32e872a5..67811d50 100644 --- a/src/features/resume/ui/pages/ResumePage.tsx +++ b/src/features/resume/ui/pages/ResumePage.tsx @@ -1,6 +1,6 @@ import { Metadata } from 'next'; import type { ReactNode } from 'react'; -import { Header, Container } from '@/shared/layout'; +import { Container } from '@/shared/layout'; import { experiences, personalInfo, @@ -104,11 +104,8 @@ export default function ResumePage() { }; return ( - <> -
    - -
    - +
    + {/* Profile Header */}

    @@ -171,7 +168,7 @@ export default function ResumePage() {

    {/* Skills Section */} -
    +

    🛠️ Skills

    @@ -188,7 +185,7 @@ export default function ResumePage() {
    {/* Experience Section */} -
    +

    💼 Experience @@ -281,7 +278,7 @@ export default function ResumePage() {

    {/* Personal Projects Section */} -
    +

    🚀 Personal Projects

    @@ -368,7 +365,7 @@ export default function ResumePage() {
    {/* Education Section */} -
    +

    🎓 Education

    @@ -463,7 +460,6 @@ export default function ResumePage() {
    - - + ); } diff --git a/src/shared/layout/AppShell/AppShell.tsx b/src/shared/layout/AppShell/AppShell.tsx new file mode 100644 index 00000000..4006e51c --- /dev/null +++ b/src/shared/layout/AppShell/AppShell.tsx @@ -0,0 +1,616 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import type { ReactNode } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { useKBar } from 'kbar'; +import { clsx } from 'clsx'; +import type { FeedData } from '@/domains/post/model/types'; +import { getSeriesSummaries } from '@/features/blog/model/series-group'; +import { personalInfo } from '@/features/resume/model/resume-data'; +import ThemeToggle from '@/shared/ui/ThemeToggle'; + +type AppSection = 'home' | 'engineering' | 'life' | 'resume'; + +interface AppShellProps { + children: ReactNode; + posts: FeedData[]; +} + +interface RailNavItem { + id: AppSection; + href: string; + label: string; + description: string; + icon: ReactNode; +} + +interface ExternalLinkItem { + href: string; + label: string; + icon: ReactNode; +} + +const DESKTOP_PANEL_WIDTH = 'clamp(220px, 18vw, 280px)'; + +const RAIL_NAV_ITEMS: RailNavItem[] = [ + { + id: 'home', + href: '/', + label: '홈', + description: '전체 피드와 최근 글', + icon: ( + + ), + }, + { + id: 'engineering', + href: '/engineering', + label: 'Engineering', + description: '기술 글과 시리즈', + icon: ( + + ), + }, + { + id: 'life', + href: '/life', + label: 'Life', + description: '에세이와 회고', + icon: ( + + ), + }, + { + id: 'resume', + href: '/resume', + label: 'Resume', + description: '경력과 프로젝트', + icon: ( + + ), + }, +]; + +const EXTERNAL_LINKS: ExternalLinkItem[] = [ + { + href: personalInfo.github, + label: 'GitHub', + icon: ( + + ), + }, + { + href: `mailto:${personalInfo.email}`, + label: 'Email', + icon: ( + + ), + }, + { + href: '/feed.xml', + label: 'RSS', + icon: ( + + ), + }, +]; + +function formatDate(value: string): string { + return new Date(value).toLocaleDateString('ko-KR', { + month: 'long', + day: 'numeric', + }); +} + +function resolveSection(pathname: string, posts: FeedData[]): AppSection { + if (pathname.startsWith('/resume')) { + return 'resume'; + } + + if (pathname.startsWith('/life')) { + return 'life'; + } + + if (pathname.startsWith('/engineering') || pathname.startsWith('/series')) { + return 'engineering'; + } + + if (pathname.startsWith('/blog/')) { + const slug = pathname.slice('/blog/'.length); + const matchedPost = posts.find((post) => post.slug === slug); + + if (matchedPost?.category === 'Life') { + return 'life'; + } + + if (matchedPost?.category === 'Tech') { + return 'engineering'; + } + } + + return 'home'; +} + +function PanelSection({ + title, + children, +}: { + title: string; + children: ReactNode; +}) { + return ( +
    +

    + {title} +

    +
    {children}
    +
    + ); +} + +function SidebarRow({ + href, + title, + detail, + active = false, +}: { + href: string; + title: string; + detail?: string; + active?: boolean; +}) { + return ( + +

    + {title} +

    + {detail ? ( +

    {detail}

    + ) : null} + + ); +} + +function SearchButton() { + const { query } = useKBar(); + + return ( + + ); +} + +export default function AppShell({ children, posts }: AppShellProps) { + const pathname = usePathname(); + const [desktopPanelOpen, setDesktopPanelOpen] = useState(true); + const [mobilePanelOpen, setMobilePanelOpen] = useState(false); + + useEffect(() => { + setMobilePanelOpen(false); + }, [pathname]); + + const activeSection = useMemo( + () => resolveSection(pathname, posts), + [pathname, posts] + ); + + const techPosts = useMemo( + () => posts.filter((post) => post.category === 'Tech'), + [posts] + ); + const lifePosts = useMemo( + () => posts.filter((post) => post.category === 'Life'), + [posts] + ); + const seriesSummaries = useMemo(() => getSeriesSummaries(techPosts), [techPosts]); + + const sidebarContent = useMemo(() => { + switch (activeSection) { + case 'engineering': + return ( + <> + + + + + + + {techPosts.slice(0, 6).map((post) => ( + + ))} + + + ); + case 'life': + return ( + <> + + + + + + {lifePosts.slice(0, 6).map((post) => ( + + ))} + + + ); + case 'resume': + return ( + <> + + + + + + + + + + + + ); + case 'home': + default: + return ( + <> + + + + + + + + {posts.slice(0, 7).map((post) => ( + + ))} + + + ); + } + }, [activeSection, lifePosts, pathname, posts, seriesSummaries, techPosts]); + + const desktopPanelWidth = desktopPanelOpen ? DESKTOP_PANEL_WIDTH : '0px'; + + return ( +
    + + + + +
    +
    +
    + + +
    + +
    + +
    + +
    +
    +
    + +
    {children}
    +
    + + + {mobilePanelOpen ? ( + <> + setMobilePanelOpen(false)} + /> + +
    +
    + {RAIL_NAV_ITEMS.map((item) => { + const isActive = item.id === activeSection; + + return ( + + {item.icon} + + ); + })} +
    + +
    + {EXTERNAL_LINKS.map((item) => ( + + {item.icon} + + ))} +
    +
    + +
    + {sidebarContent} +
    +
    + + ) : null} +
    +
    + ); +} diff --git a/src/shared/layout/AppShell/index.ts b/src/shared/layout/AppShell/index.ts new file mode 100644 index 00000000..5541a059 --- /dev/null +++ b/src/shared/layout/AppShell/index.ts @@ -0,0 +1 @@ +export { default as AppShell } from './AppShell'; diff --git a/src/shared/layout/index.ts b/src/shared/layout/index.ts index ca37cdca..3b41d3fa 100644 --- a/src/shared/layout/index.ts +++ b/src/shared/layout/index.ts @@ -2,4 +2,5 @@ export { Header } from './Header'; export { Footer } from './Footer'; export { Container } from './Container'; +export { AppShell } from './AppShell'; export type { ContainerProps } from './Container'; diff --git a/src/shared/ui/ThemeToggle.tsx b/src/shared/ui/ThemeToggle.tsx index d17148f6..8eea8a0b 100644 --- a/src/shared/ui/ThemeToggle.tsx +++ b/src/shared/ui/ThemeToggle.tsx @@ -2,7 +2,7 @@ import { useTheme } from 'next-themes'; import { useEffect, useState } from 'react'; -import { motion, AnimatePresence } from 'framer-motion'; +import { motion } from 'framer-motion'; import { AnalyticsEvents, trackEvent } from '@/shared/analytics/lib/analytics'; export default function ThemeToggle() { @@ -15,8 +15,8 @@ export default function ThemeToggle() { if (!mounted) { return ( -
    -
    +
    +
    ); } @@ -25,7 +25,8 @@ export default function ThemeToggle() { const nextTheme = isDark ? 'light' : 'dark'; return ( - { setTheme(nextTheme); trackEvent(AnalyticsEvents.theme, { @@ -33,60 +34,63 @@ export default function ThemeToggle() { to_theme: nextTheme, }); }} - className="relative p-2 rounded-lg hover:bg-[var(--color-grey-100)] transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-toss-blue)]" + className="relative inline-flex h-11 w-[88px] items-center rounded-full border border-[var(--color-grey-200)] bg-[var(--color-grey-50)] px-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-toss-blue)]" aria-label={`${isDark ? '라이트' : '다크'} 모드로 전환`} - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} > - - - - + + + + + + ); }