Replace featured post with popular list
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m5s

This commit is contained in:
wypark
2026-05-28 23:16:19 +09:00
parent caa7196608
commit fa3eb96d08
2 changed files with 93 additions and 67 deletions

7
LOG.md
View File

@@ -2,6 +2,13 @@
## 2026-05-28
- 홈 대표 글 영역을 제거하고 조회수 내림차순 `viewCount,desc` 기준 인기 글 5개 패널로 대체했다.
- 인기 글과 최신 글을 같은 `lg:grid-cols-2` 반반 레이아웃으로 맞추고, 인기 글에는 순위와 조회수를 작은 보조 메타로 표시했다.
- 검증: `npm run lint` 통과, `npm run build` 통과. build 중 sitemap fetch는 로컬 네트워크 제한으로 기존처럼 경고를 출력했지만 실패하지 않았다. Browser에서 홈의 인기 글/최신 글 반반 레이아웃을 확인했다. 로컬 `localhost:3000`에서 배포 API 호출은 CORS 정책상 실데이터가 비어 보여 production Origin 배포 후 실데이터 렌더링 확인이 필요하다.
- 다음 추천 작업: 배포 후 `https://blog.wypark.me` 홈에서 인기 글 5개가 조회수 순으로 보이는지 확인한다.
## 2026-05-28
- 상단 우측의 테마 토글, 로그인/회원가입, 관리자/새 글/로그아웃 액션을 작은 메뉴 버튼 뒤로 숨기고, 클릭 시 왼쪽으로 펼쳐지는 패널 애니메이션으로 노출되도록 수정했다.
- 메뉴 바깥 클릭과 Escape 키로 닫히게 하고, 링크 이동/로그아웃 시 메뉴가 닫히도록 정리했다.
- 검증: `npm run lint` 통과, `npm run build` 통과. build 중 sitemap fetch는 로컬 네트워크 제한으로 기존처럼 경고를 출력했지만 실패하지 않았다. Browser로 데스크톱/모바일 홈에서 닫힘 상태와 펼침 상태를 확인했다.

View File

@@ -1,6 +1,6 @@
'use client';
import { Suspense } from 'react';
import { Suspense, type ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
@@ -10,6 +10,7 @@ import {
Clock,
Loader2,
Search,
TrendingUp,
} from 'lucide-react';
import { getPosts } from '@/api/posts';
import EmptyState from '@/components/ui/EmptyState';
@@ -122,43 +123,15 @@ function NoticeStrip({ notices }: { notices: Post[] }) {
);
}
function FeaturedPost({ post }: { post?: Post }) {
if (!post) {
return (
<Surface as="section" strong className="flex min-h-72 items-center justify-center p-6 shadow-none">
<EmptyState title="아직 공개된 글이 없습니다." />
</Surface>
);
}
const summary = getSummary(post.content, 170);
return (
<Link href={`/posts/${post.slug}`} className="group block h-full">
<Surface as="article" strong interactive className="flex h-full min-h-72 flex-col p-7 shadow-none md:p-9">
<div className="mb-6 flex items-center justify-between gap-3">
<StatusBadge tone="neutral">{post.categoryName || '미분류'}</StatusBadge>
<time className="text-xs font-medium tabular-nums text-[var(--color-text-subtle)]">
{formatDate(post.createdAt)}
</time>
</div>
<div className="flex flex-1 flex-col">
<p className="mb-4 text-sm font-semibold text-[var(--color-accent)]"> </p>
<h2 className="text-3xl font-bold leading-tight tracking-normal text-[var(--color-text)] md:text-4xl">
{post.title}
</h2>
{summary && (
<p className="mt-5 line-clamp-3 text-[15px] leading-7 text-[var(--color-text-muted)]">
{summary}
</p>
)}
</div>
</Surface>
</Link>
);
}
function CompactPostRow({ post }: { post: Post }) {
function CompactPostRow({
post,
rank,
showViews = false,
}: {
post: Post;
rank?: number;
showViews?: boolean;
}) {
const summary = getSummary(post.content, 92);
return (
@@ -166,7 +139,12 @@ function CompactPostRow({ post }: { post: Post }) {
href={`/posts/${post.slug}`}
className="group flex items-start justify-between gap-4 rounded-lg px-1 py-4 transition duration-150 hover:bg-black/[0.025] hover:px-3 dark:hover:bg-white/[0.07]"
>
<div className="min-w-0">
{rank !== undefined && (
<span className="mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[var(--color-accent-soft)] text-xs font-bold tabular-nums text-[var(--color-accent)]">
{rank}
</span>
)}
<div className="min-w-0 flex-1">
<div className="mb-1.5 flex items-center gap-2">
<StatusBadge tone={isNoticePost(post) ? 'danger' : 'neutral'} className="shrink-0">
{post.categoryName || '미분류'}
@@ -174,6 +152,11 @@ function CompactPostRow({ post }: { post: Post }) {
<time className="text-xs text-[var(--color-text-subtle)]">
{formatDate(post.createdAt)}
</time>
{showViews && (
<span className="text-xs tabular-nums text-[var(--color-text-subtle)]">
{post.viewCount.toLocaleString()}
</span>
)}
</div>
<h3 className="line-clamp-1 text-base font-semibold text-[var(--color-text)] transition group-hover:text-[var(--color-accent)]">
{post.title}
@@ -189,6 +172,48 @@ function CompactPostRow({ post }: { post: Post }) {
);
}
function PostListPanel({
title,
icon,
posts,
isPopular = false,
}: {
title: string;
icon: ReactNode;
posts: Post[];
isPopular?: boolean;
}) {
return (
<Surface as="section" className="p-5 shadow-none md:p-6">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
{icon}
<h2 className="text-lg font-bold text-[var(--color-text)]">{title}</h2>
</div>
<Link href="/archive" className="inline-flex items-center gap-1 text-sm font-semibold text-[var(--color-text-muted)] transition hover:text-[var(--color-accent)]">
<ChevronRight size={15} />
</Link>
</div>
{posts.length > 0 ? (
<div className="divide-y divide-[var(--color-line)]">
{posts.map((post, index) => (
<CompactPostRow
key={post.id}
post={post}
rank={isPopular ? index + 1 : undefined}
showViews={isPopular}
/>
))}
</div>
) : (
<EmptyState title={isPopular ? '인기 글을 집계 중입니다.' : '아직 공개된 글이 없습니다.'} className="min-h-72" />
)}
</Surface>
);
}
function HomeContent() {
const searchParams = useSearchParams();
const keyword = searchParams.get('keyword') || '';
@@ -201,7 +226,13 @@ function HomeContent() {
const { data: latestData, isLoading: isLatestLoading } = useQuery({
queryKey: ['posts', 'latest'],
queryFn: () => getPosts({ size: 7, sort: 'createdAt,desc' }),
queryFn: () => getPosts({ size: 8, sort: 'createdAt,desc' }),
retry: 0,
});
const { data: popularData, isLoading: isPopularLoading } = useQuery({
queryKey: ['posts', 'popular'],
queryFn: () => getPosts({ size: 8, sort: 'viewCount,desc' }),
retry: 0,
});
@@ -213,10 +244,9 @@ function HomeContent() {
});
const notices = noticesData?.content || [];
const latestPosts = (latestData?.content || []).filter((post) => !isNoticePost(post));
const featuredPost = latestPosts[0];
const latestList = latestPosts.slice(1, 7);
const isHomeLoading = !keyword && (isNoticesLoading || isLatestLoading);
const latestList = (latestData?.content || []).filter((post) => !isNoticePost(post)).slice(0, 5);
const popularList = (popularData?.content || []).filter((post) => !isNoticePost(post)).slice(0, 5);
const isHomeLoading = !keyword && (isNoticesLoading || isLatestLoading || isPopularLoading);
if (keyword) {
return (
@@ -254,29 +284,18 @@ function HomeContent() {
<NoticeStrip notices={notices} />
<section className={latestList.length > 0 ? 'grid gap-6 lg:grid-cols-[1.1fr_0.9fr]' : 'grid gap-6'}>
<FeaturedPost post={featuredPost} />
{latestList.length > 0 && (
<Surface as="section" className="p-5 shadow-none md:p-6">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Clock size={18} className="text-[var(--color-accent)]" />
<h2 className="text-lg font-bold text-[var(--color-text)]"> </h2>
</div>
<Link href="/archive" className="inline-flex items-center gap-1 text-sm font-semibold text-[var(--color-text-muted)] transition hover:text-[var(--color-accent)]">
<ChevronRight size={15} />
</Link>
</div>
<div className="divide-y divide-[var(--color-line)]">
{latestList.map((post) => (
<CompactPostRow key={post.id} post={post} />
))}
</div>
</Surface>
)}
<section className="grid gap-6 lg:grid-cols-2">
<PostListPanel
title="인기 글"
icon={<TrendingUp size={18} className="text-[var(--color-accent)]" />}
posts={popularList}
isPopular
/>
<PostListPanel
title="최신 글"
icon={<Clock size={18} className="text-[var(--color-accent)]" />}
posts={latestList}
/>
</section>
</main>
);