Skip to content

Commit 200df1e

Browse files
committed
feat(home): Linear형 앱 셸 레이아웃 전환
전역 좌측 레일과 컨텍스트 패널을 도입해서 홈, 리스트, 상세 페이지의 탐색 경험을 통일했어요. 홈 피드를 검색과 정렬 중심 구조로 재구성하고 테마 토글과 검색 가시성도 함께 정리했어요.
1 parent 8deed0f commit 200df1e

19 files changed

Lines changed: 1213 additions & 335 deletions

src/app/layout.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import '@/styles/globals.css';
44
import '@/styles/tossface.css';
55

66
import AppProviders from '@/core/providers/AppProviders';
7+
import { getSortedFeedData } from '@/features/blog/services/post-repository';
8+
import { AppShell } from '@/shared/layout';
79
import {
810
SITE_AUTHOR,
911
SITE_DESCRIPTION,
@@ -61,6 +63,8 @@ export default function RootLayout({
6163
}: {
6264
children: ReactNode;
6365
}) {
66+
const posts = getSortedFeedData();
67+
6468
return (
6569
<html lang="ko" suppressHydrationWarning>
6670
<head>
@@ -81,7 +85,9 @@ export default function RootLayout({
8185
</head>
8286
<body>
8387
<AppProviders>
84-
<div id="app-root">{children}</div>
88+
<div id="app-root">
89+
<AppShell posts={posts}>{children}</AppShell>
90+
</div>
8591
<div id="overlay-root" />
8692
</AppProviders>
8793
</body>

src/features/blog/ui/components/PostCard.test.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,23 @@ describe('PostCard', () => {
8888
'/images/test.png'
8989
);
9090
});
91+
92+
it('shows category, series title, and tags in list variant', () => {
93+
const detailedPost: FeedData = {
94+
...basePost,
95+
tags: ['redis', 'cache'],
96+
series: {
97+
id: 'redis',
98+
title: 'Redis 완전정복',
99+
order: 1,
100+
},
101+
};
102+
103+
render(<PostCard post={detailedPost} variant="list" />);
104+
105+
expect(screen.getByText('Tech')).toBeInTheDocument();
106+
expect(screen.getByText('Redis 완전정복')).toBeInTheDocument();
107+
expect(screen.getByText('#redis')).toBeInTheDocument();
108+
expect(screen.getByText('#cache')).toBeInTheDocument();
109+
});
91110
});

src/features/blog/ui/components/PostCard/PostCard.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export default function PostCard({ post, variant = 'default' }: PostCardProps) {
7373
day: 'numeric',
7474
});
7575
const readingTimeLabel = post.readingTime ? `약 ${post.readingTime}분` : null;
76+
const visibleTags = post.tags?.slice(0, 3) ?? [];
7677

7778
if (variant === 'featured') {
7879
return (
@@ -118,8 +119,13 @@ export default function PostCard({ post, variant = 'default' }: PostCardProps) {
118119
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--color-text-tertiary)]">
119120
<span className="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-border)] bg-[var(--color-grey-50)] px-2 py-1 text-xs font-medium text-[var(--color-text-secondary)]">
120121
<CategoryIcon category={post.category} />
121-
{post.series ? 'Series' : post.category}
122+
{post.category}
122123
</span>
124+
{post.series ? (
125+
<span className="inline-flex items-center rounded-full border border-[var(--color-border)] bg-[var(--color-grey-50)] px-2 py-1 text-xs font-medium text-[var(--color-text-secondary)]">
126+
{post.series.title}
127+
</span>
128+
) : null}
123129
<span className="h-1 w-1 rounded-full bg-[var(--color-grey-300)]" />
124130
<time>{formattedDate}</time>
125131
{readingTimeLabel && (
@@ -135,6 +141,18 @@ export default function PostCard({ post, variant = 'default' }: PostCardProps) {
135141
<p className="mt-1 text-sm text-[var(--color-text-secondary)] line-clamp-2">
136142
{post.description}
137143
</p>
144+
{visibleTags.length > 0 ? (
145+
<div className="mt-3 flex flex-wrap gap-2">
146+
{visibleTags.map((tag) => (
147+
<span
148+
key={tag}
149+
className="rounded-full bg-[var(--color-grey-50)] px-2.5 py-1 text-xs font-medium text-[var(--color-text-tertiary)]"
150+
>
151+
#{tag}
152+
</span>
153+
))}
154+
</div>
155+
) : null}
138156
</div>
139157
{post.image ? (
140158
<div className="hidden h-20 w-32 shrink-0 overflow-hidden rounded-xl border border-[var(--color-border)] sm:block">
Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Metadata } from 'next';
22
import { getSortedFeedData } from '@/features/blog/services/post-repository';
3-
import { Header, Container } from '@/shared/layout';
3+
import { Container } from '@/shared/layout';
44
import BlogListClient from './BlogListClient';
55

66
export const metadata: Metadata = {
@@ -12,23 +12,19 @@ export default function BlogPage() {
1212
const allPosts = getSortedFeedData();
1313

1414
return (
15-
<>
16-
<Header />
15+
<main className="py-10">
16+
<Container size="md">
17+
<header className="mb-12">
18+
<h1 className="text-3xl md:text-4xl font-bold text-[var(--color-grey-900)]">
19+
Blog
20+
</h1>
21+
<p className="mt-4 text-lg text-[var(--color-grey-600)]">
22+
개발과 일상에 대한 이야기를 나눕니다
23+
</p>
24+
</header>
1725

18-
<main className="py-16">
19-
<Container size="md">
20-
<header className="mb-12">
21-
<h1 className="text-3xl md:text-4xl font-bold text-[var(--color-grey-900)]">
22-
Blog
23-
</h1>
24-
<p className="mt-4 text-lg text-[var(--color-grey-600)]">
25-
개발과 일상에 대한 이야기를 나눕니다
26-
</p>
27-
</header>
28-
29-
<BlogListClient posts={allPosts} />
30-
</Container>
31-
</main>
32-
</>
26+
<BlogListClient posts={allPosts} />
27+
</Container>
28+
</main>
3329
);
3430
}

src/features/blog/ui/pages/BlogPostPage.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
getMdxSource,
1010
parseHeadingsFromMdx,
1111
} from '@/features/blog/services/markdown-parser';
12-
import { Header, Container } from '@/shared/layout';
12+
import { Container } from '@/shared/layout';
1313
import {
1414
ReadingProgress,
1515
TableOfContents,
@@ -92,7 +92,6 @@ export default async function BlogPostPage({
9292
return (
9393
<>
9494
<ReadingProgress />
95-
<Header />
9695
<PostViewTracker
9796
slug={post.slug}
9897
category={post.category}
@@ -101,7 +100,7 @@ export default async function BlogPostPage({
101100
<DwellTimeTracker slug={post.slug} />
102101
<ScrollDepthTracker slug={post.slug} />
103102

104-
<article className="py-16">
103+
<article className="py-10">
105104
<Container size="md">
106105
{/* Header */}
107106
<header className="mb-10">
Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Metadata } from 'next';
22
import { Suspense } from 'react';
33
import { getSortedFeedData } from '@/features/blog/services/post-repository';
4-
import { Header, Container } from '@/shared/layout';
4+
import { Container } from '@/shared/layout';
55
import EngineeringPageClient from './EngineeringPageClient';
66

77
export const metadata: Metadata = {
@@ -15,25 +15,21 @@ export default function EngineeringPage() {
1515
);
1616

1717
return (
18-
<>
19-
<Header />
18+
<main className="py-10">
19+
<Container size="md">
20+
<header className="mb-10">
21+
<h1 className="text-3xl md:text-4xl font-bold text-[var(--color-grey-900)]">
22+
Engineering
23+
</h1>
24+
<p className="mt-4 text-lg text-[var(--color-grey-600)]">
25+
기술 글과 시리즈를 같은 흐름에서 살펴볼 수 있어요
26+
</p>
27+
</header>
2028

21-
<main className="py-16">
22-
<Container size="md">
23-
<header className="mb-10">
24-
<h1 className="text-3xl md:text-4xl font-bold text-[var(--color-grey-900)]">
25-
Engineering
26-
</h1>
27-
<p className="mt-4 text-lg text-[var(--color-grey-600)]">
28-
기술 글과 시리즈를 같은 흐름에서 살펴볼 수 있어요
29-
</p>
30-
</header>
31-
32-
<Suspense fallback={null}>
33-
<EngineeringPageClient posts={engineeringPosts} />
34-
</Suspense>
35-
</Container>
36-
</main>
37-
</>
29+
<Suspense fallback={null}>
30+
<EngineeringPageClient posts={engineeringPosts} />
31+
</Suspense>
32+
</Container>
33+
</main>
3834
);
3935
}

src/features/blog/ui/pages/EngineeringSeriesPage.tsx

Lines changed: 51 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import Link from 'next/link';
22
import { notFound } from 'next/navigation';
33
import { getSeriesSummaries } from '@/features/blog/model/series-group';
44
import { getSortedFeedData } from '@/features/blog/services/post-repository';
5-
import { Header, Container } from '@/shared/layout';
5+
import { Container } from '@/shared/layout';
66

77
interface EngineeringSeriesPageProps {
88
seriesId: string;
@@ -31,63 +31,59 @@ export default function EngineeringSeriesPage({
3131
}
3232

3333
return (
34-
<>
35-
<Header />
34+
<main className="py-10">
35+
<Container size="md">
36+
<Link
37+
href="/engineering"
38+
className="inline-flex items-center gap-1 text-sm font-medium text-[var(--color-toss-blue)] hover:underline"
39+
>
40+
<span aria-hidden="true"></span>
41+
Engineering으로 돌아가기
42+
</Link>
3643

37-
<main className="py-16">
38-
<Container size="md">
39-
<Link
40-
href="/engineering"
41-
className="inline-flex items-center gap-1 text-sm font-medium text-[var(--color-toss-blue)] hover:underline"
42-
>
43-
<span aria-hidden="true"></span>
44-
Engineering으로 돌아가기
45-
</Link>
44+
<header className="mb-8 mt-5">
45+
<span className="inline-flex rounded-full border border-[var(--color-category-series-border)] bg-[var(--color-category-series-bg)] px-2 py-1 text-xs font-medium text-[var(--color-category-series-text)]">
46+
Series
47+
</span>
48+
<h1 className="mt-3 text-3xl font-bold text-[var(--color-grey-900)] md:text-4xl">
49+
{seriesSummary.title}
50+
</h1>
51+
<p className="mt-3 text-sm text-[var(--color-grey-600)]">
52+
{seriesSummary.postCount}개 글을 순서대로 확인할 수 있어요.
53+
</p>
54+
</header>
4655

47-
<header className="mb-8 mt-5">
48-
<span className="inline-flex rounded-full border border-[var(--color-category-series-border)] bg-[var(--color-category-series-bg)] px-2 py-1 text-xs font-medium text-[var(--color-category-series-text)]">
49-
Series
50-
</span>
51-
<h1 className="mt-3 text-3xl font-bold text-[var(--color-grey-900)] md:text-4xl">
52-
{seriesSummary.title}
53-
</h1>
54-
<p className="mt-3 text-sm text-[var(--color-grey-600)]">
55-
{seriesSummary.postCount}개 글을 순서대로 확인할 수 있어요.
56-
</p>
57-
</header>
56+
<ol className="space-y-3">
57+
{seriesSummary.posts.map((post) => {
58+
const order = post.series?.order ?? 0;
5859

59-
<ol className="space-y-3">
60-
{seriesSummary.posts.map((post) => {
61-
const order = post.series?.order ?? 0;
62-
63-
return (
64-
<li key={post.slug}>
65-
<Link
66-
href={`/blog/${post.slug}`}
67-
className="group flex min-h-20 items-center justify-between gap-4 rounded-xl border border-[var(--color-grey-100)] bg-[var(--color-bg-primary)] px-4 py-3 transition-colors hover:border-[var(--color-grey-200)] hover:bg-[var(--color-grey-50)]"
68-
>
69-
<div className="min-w-0 flex-1">
70-
<p className="text-xs font-medium text-[var(--color-grey-500)]">
71-
{order}편 · {formatPostDate(post.date)}
72-
</p>
73-
<p className="mt-1 line-clamp-1 text-base font-semibold text-[var(--color-grey-900)] group-hover:text-[var(--color-toss-blue)]">
74-
{post.title}
75-
</p>
76-
<p className="mt-1 line-clamp-2 text-sm text-[var(--color-grey-600)]">
77-
{post.description}
78-
</p>
79-
</div>
80-
<span className="shrink-0 text-sm font-semibold text-[var(--color-toss-blue)]">
81-
글 보기
82-
</span>
83-
</Link>
84-
</li>
85-
);
86-
})}
87-
</ol>
88-
</Container>
89-
</main>
90-
</>
60+
return (
61+
<li key={post.slug}>
62+
<Link
63+
href={`/blog/${post.slug}`}
64+
className="group flex min-h-20 items-center justify-between gap-4 rounded-xl border border-[var(--color-grey-100)] bg-[var(--color-bg-primary)] px-4 py-3 transition-colors hover:border-[var(--color-grey-200)] hover:bg-[var(--color-grey-50)]"
65+
>
66+
<div className="min-w-0 flex-1">
67+
<p className="text-xs font-medium text-[var(--color-grey-500)]">
68+
{order}편 · {formatPostDate(post.date)}
69+
</p>
70+
<p className="mt-1 line-clamp-1 text-base font-semibold text-[var(--color-grey-900)] group-hover:text-[var(--color-toss-blue)]">
71+
{post.title}
72+
</p>
73+
<p className="mt-1 line-clamp-2 text-sm text-[var(--color-grey-600)]">
74+
{post.description}
75+
</p>
76+
</div>
77+
<span className="shrink-0 text-sm font-semibold text-[var(--color-toss-blue)]">
78+
글 보기
79+
</span>
80+
</Link>
81+
</li>
82+
);
83+
})}
84+
</ol>
85+
</Container>
86+
</main>
9187
);
9288
}
9389

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Metadata } from 'next';
22
import { getSortedFeedData } from '@/features/blog/services/post-repository';
3-
import { Header, Container } from '@/shared/layout';
3+
import { Container } from '@/shared/layout';
44
import { PostList } from '@/features/blog/ui/components';
55

66
export const metadata: Metadata = {
@@ -12,23 +12,19 @@ export default function LifePage() {
1212
const lifePosts = getSortedFeedData().filter((post) => post.category === 'Life');
1313

1414
return (
15-
<>
16-
<Header />
15+
<main className="py-10">
16+
<Container size="md">
17+
<header className="mb-10">
18+
<h1 className="text-3xl md:text-4xl font-bold text-[var(--color-grey-900)]">
19+
Life
20+
</h1>
21+
<p className="mt-4 text-lg text-[var(--color-grey-600)]">
22+
일상에서 배운 점과 오래 남은 생각을 차분하게 정리해요
23+
</p>
24+
</header>
1725

18-
<main className="py-16">
19-
<Container size="md">
20-
<header className="mb-10">
21-
<h1 className="text-3xl md:text-4xl font-bold text-[var(--color-grey-900)]">
22-
Life
23-
</h1>
24-
<p className="mt-4 text-lg text-[var(--color-grey-600)]">
25-
일상에서 배운 점과 오래 남은 생각을 차분하게 정리해요
26-
</p>
27-
</header>
28-
29-
<PostList posts={lifePosts} layout="list" />
30-
</Container>
31-
</main>
32-
</>
26+
<PostList posts={lifePosts} layout="list" />
27+
</Container>
28+
</main>
3329
);
3430
}

0 commit comments

Comments
 (0)