.
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m4s

This commit is contained in:
wypark
2026-05-28 22:49:38 +09:00
parent 7bc75a32b2
commit 701bbdea4a
12 changed files with 169 additions and 326 deletions

View File

@@ -1,24 +1,21 @@
'use client';
import { Suspense, useMemo } from 'react';
import { useQueries, useQuery } from '@tanstack/react-query';
import { Suspense } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
import {
Archive,
ChevronRight,
Clock,
FolderTree,
Loader2,
Search,
Sparkles,
} from 'lucide-react';
import { getCategories } from '@/api/category';
import { getPosts } from '@/api/posts';
import EmptyState from '@/components/ui/EmptyState';
import StatusBadge from '@/components/ui/StatusBadge';
import Surface from '@/components/ui/Surface';
import { Category, Post, PostListResponse } from '@/types';
import { Post, PostListResponse } from '@/types';
const isNoticePost = (post: Post) => {
return post.categoryName === '공지' || post.categoryName.toLowerCase() === 'notice';
@@ -38,7 +35,7 @@ const formatDate = (value?: string) => {
};
const getSummary = (content?: string, maxLength = 118) => {
if (!content) return '아직 요약할 본문이 없습니다. 글을 열어 전체 내용을 확인해 보세요.';
if (!content) return '';
return content
.replace(/```[\s\S]*?```/g, ' ')
@@ -50,23 +47,6 @@ const getSummary = (content?: string, maxLength = 118) => {
.slice(0, maxLength);
};
const flattenCategories = (categories: Category[] = []): Category[] => {
return categories.flatMap((category) => [
category,
...flattenCategories(category.children || []),
]);
};
const getHomeCategories = (categories: Category[] = []) => {
return flattenCategories(categories)
.filter((category) => {
const name = category.name.toLowerCase();
return category.name !== '공지' && name !== 'notice' && name !== '미분류';
})
.sort((a, b) => a.id - b.id)
.slice(0, 3);
};
const getTotalElements = (data?: PostListResponse) => {
return data?.page?.totalElements ?? data?.totalElements ?? 0;
};
@@ -123,7 +103,7 @@ function NoticeStrip({ notices }: { notices: Post[] }) {
<Link key={notice.id} href={`/posts/${notice.slug}`} className="group block">
<Surface
interactive
className="flex items-center justify-between gap-4 border-red-500/10 bg-red-500/[0.06] px-4 py-3 shadow-none"
className="flex items-center justify-between gap-4 border-red-500/10 bg-red-500/[0.045] px-4 py-3 shadow-none"
>
<div className="flex min-w-0 items-center gap-3">
<StatusBadge tone="danger"></StatusBadge>
@@ -145,33 +125,33 @@ 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">
<EmptyState title="대표 글을 기다리고 있습니다." description="새 글이 발행되면 이곳에 먼저 표시됩니다." />
<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-64 flex-col p-6">
<div className="mb-5 flex items-center justify-between gap-3">
<StatusBadge tone="info">{post.categoryName || '미분류'}</StatusBadge>
<time className="text-xs font-medium text-[var(--color-text-subtle)]">
<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-3 text-sm font-semibold text-[var(--color-accent)]"> </p>
<h2 className="text-2xl font-bold leading-tight tracking-normal text-[var(--color-text)] md:text-3xl">
<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>
<p className="mt-4 line-clamp-3 text-sm leading-6 text-[var(--color-text-muted)]">
{getSummary(post.content, 160)}
</p>
<span className="mt-6 inline-flex items-center gap-1 text-sm font-semibold text-[var(--color-accent)]">
<ChevronRight size={16} className="transition group-hover:translate-x-0.5" />
</span>
{summary && (
<p className="mt-5 line-clamp-3 text-[15px] leading-7 text-[var(--color-text-muted)]">
{summary}
</p>
)}
</div>
</Surface>
</Link>
@@ -179,10 +159,12 @@ function FeaturedPost({ post }: { post?: Post }) {
}
function CompactPostRow({ post }: { post: Post }) {
const summary = getSummary(post.content, 92);
return (
<Link
href={`/posts/${post.slug}`}
className="group flex items-start justify-between gap-4 rounded-lg px-3 py-4 transition duration-150 hover:bg-black/[0.03] dark:hover:bg-white/10"
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">
<div className="mb-1.5 flex items-center gap-2">
@@ -196,69 +178,17 @@ function CompactPostRow({ post }: { post: Post }) {
<h3 className="line-clamp-1 text-base font-semibold text-[var(--color-text)] transition group-hover:text-[var(--color-accent)]">
{post.title}
</h3>
<p className="mt-1 line-clamp-2 text-sm leading-6 text-[var(--color-text-muted)]">
{getSummary(post.content, 96)}
</p>
{summary && (
<p className="mt-1 line-clamp-2 text-sm leading-6 text-[var(--color-text-muted)]">
{summary}
</p>
)}
</div>
<ChevronRight size={18} className="mt-7 shrink-0 text-[var(--color-text-subtle)] transition group-hover:translate-x-0.5 group-hover:text-[var(--color-accent)]" />
<ChevronRight size={17} className="mt-7 shrink-0 text-[var(--color-text-subtle)] transition group-hover:translate-x-0.5 group-hover:text-[var(--color-accent)]" />
</Link>
);
}
function CategoryShelf({
category,
posts,
isLoading,
}: {
category: Category;
posts: Post[];
isLoading: boolean;
}) {
return (
<Surface as="article" className="p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="min-w-0">
<h3 className="truncate text-base font-bold text-[var(--color-text)]">
{category.name}
</h3>
<p className="mt-1 text-xs text-[var(--color-text-subtle)]"> </p>
</div>
<Link
href={`/category/${category.name}`}
className="shrink-0 text-xs font-semibold text-[var(--color-accent)]"
>
</Link>
</div>
{isLoading ? (
<div className="space-y-3">
{[0, 1, 2].map((item) => (
<div key={item} className="h-10 animate-pulse rounded bg-black/[0.04] dark:bg-white/10" />
))}
</div>
) : posts.length > 0 ? (
<div className="divide-y divide-[var(--color-line)]">
{posts.slice(0, 3).map((post) => (
<Link
key={post.id}
href={`/posts/${post.slug}`}
className="group flex items-center justify-between gap-3 py-3"
>
<span className="line-clamp-1 text-sm font-semibold text-[var(--color-text-muted)] transition group-hover:text-[var(--color-accent)]">
{post.title}
</span>
<ChevronRight size={15} className="shrink-0 text-[var(--color-text-subtle)]" />
</Link>
))}
</div>
) : (
<EmptyState title="아직 글이 없습니다." className="min-h-28 py-6" />
)}
</Surface>
);
}
function HomeContent() {
const searchParams = useSearchParams();
const keyword = searchParams.get('keyword') || '';
@@ -271,19 +201,7 @@ function HomeContent() {
const { data: latestData, isLoading: isLatestLoading } = useQuery({
queryKey: ['posts', 'latest'],
queryFn: () => getPosts({ size: 8, sort: 'createdAt,desc' }),
retry: 0,
});
const { data: popularData, isLoading: isPopularLoading } = useQuery({
queryKey: ['posts', 'popular'],
queryFn: () => getPosts({ size: 5, sort: 'viewCount,desc' }),
retry: 0,
});
const { data: categories } = useQuery({
queryKey: ['categories'],
queryFn: getCategories,
queryFn: () => getPosts({ size: 7, sort: 'createdAt,desc' }),
retry: 0,
});
@@ -294,22 +212,11 @@ function HomeContent() {
retry: 0,
});
const shelfCategories = useMemo(() => getHomeCategories(categories), [categories]);
const categoryPostQueries = useQueries({
queries: shelfCategories.map((category) => ({
queryKey: ['posts', 'category-shelf', category.name],
queryFn: () => getPosts({ category: category.name, size: 3, sort: 'createdAt,desc' }),
enabled: !keyword,
retry: 0,
})),
});
const notices = noticesData?.content || [];
const latestPosts = (latestData?.content || []).filter((post) => !isNoticePost(post));
const popularPosts = (popularData?.content || []).filter((post) => !isNoticePost(post));
const featuredPost = latestPosts[0];
const latestList = latestPosts.slice(1, 7);
const isHomeLoading = !keyword && (isNoticesLoading || isLatestLoading || isPopularLoading);
const isHomeLoading = !keyword && (isNoticesLoading || isLatestLoading);
if (keyword) {
return (
@@ -328,64 +235,36 @@ function HomeContent() {
}
return (
<main className="mx-auto max-w-6xl space-y-12 px-4 py-8 md:px-6">
<section className="grid gap-6 lg:grid-cols-[1.15fr_0.85fr] lg:items-end">
<div className="max-w-2xl">
<StatusBadge tone="info" className="mb-5">
WYPark Blog
</StatusBadge>
<h1 className="text-4xl font-bold leading-tight tracking-normal text-[var(--color-text)] md:text-5xl">
<main className="mx-auto max-w-5xl space-y-10 px-4 py-8 md:px-6 md:py-12">
<section className="max-w-3xl pt-2 md:pt-8">
<h1 className="text-4xl font-bold leading-[1.08] tracking-normal text-[var(--color-text)] md:text-6xl">
</h1>
<p className="mt-5 text-base leading-7 text-[var(--color-text-muted)] md:text-lg">
, , .
</p>
<div className="mt-7 flex flex-wrap gap-3">
<Link
href="/archive"
className="inline-flex h-10 items-center gap-2 rounded-full bg-[var(--color-accent)] px-4 text-sm font-semibold text-white shadow-[var(--shadow-control)] transition hover:bg-[var(--color-accent-hover)]"
>
<Archive size={16} />
</Link>
<Link
href="#blog-search"
className="inline-flex h-10 items-center gap-2 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] px-4 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-text)]"
>
<Search size={16} />
</Link>
</div>
</div>
<Surface className="p-5">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[var(--color-accent-soft)] text-[var(--color-accent)]">
<Sparkles size={20} />
</div>
<div>
<p className="text-sm font-bold text-[var(--color-text)]"> </p>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
{featuredPost ? formatDate(featuredPost.createdAt) : '새 글을 준비 중입니다.'}
</p>
</div>
</div>
</Surface>
</h1>
<p className="mt-5 max-w-2xl text-[17px] leading-8 text-[var(--color-text-muted)]">
.
</p>
<Link
href="/archive"
className="mt-7 inline-flex h-10 items-center gap-2 rounded-full bg-[var(--color-text)] px-4 text-sm font-semibold text-[var(--color-page)] shadow-[var(--shadow-control)] transition hover:opacity-90 dark:bg-white dark:text-black"
>
<Archive size={16} />
</Link>
</section>
<NoticeStrip notices={notices} />
<section className={latestList.length > 0 ? 'grid gap-6 lg:grid-cols-[1.05fr_0.95fr]' : 'grid gap-6'}>
<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">
<div className="mb-3 flex items-center justify-between gap-3">
<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-accent)]">
<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>
@@ -399,64 +278,6 @@ function HomeContent() {
</Surface>
)}
</section>
<section>
<div className="mb-5 flex items-center gap-2">
<FolderTree size={19} className="text-[var(--color-accent)]" />
<h2 className="text-xl font-bold text-[var(--color-text)]"> </h2>
</div>
{shelfCategories.length > 0 ? (
<div className="grid gap-4 md:grid-cols-3">
{shelfCategories.map((category, index) => (
<CategoryShelf
key={category.id}
category={category}
posts={categoryPostQueries[index]?.data?.content || []}
isLoading={categoryPostQueries[index]?.isLoading || false}
/>
))}
</div>
) : (
<EmptyState title="카테고리를 불러오지 못했습니다." description="카테고리 API가 연결되면 이곳에 탐색 선반이 표시됩니다." />
)}
</section>
<section className="grid gap-6 lg:grid-cols-[0.95fr_1.05fr]">
<Surface as="section" className="p-5">
<div className="mb-3 flex items-center gap-2">
<Sparkles size={18} className="text-[var(--color-accent)]" />
<h2 className="text-lg font-bold text-[var(--color-text)]"> </h2>
</div>
{popularPosts.length > 0 ? (
<div className="divide-y divide-[var(--color-line)]">
{popularPosts.slice(0, 4).map((post) => (
<CompactPostRow key={post.id} post={post} />
))}
</div>
) : (
<EmptyState title="인기 글을 집계 중입니다." description="조회수 숫자는 공개 홈에서 강조하지 않습니다." />
)}
</Surface>
<Surface as="section" className="flex flex-col justify-between gap-8 p-6">
<div>
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-full bg-[var(--color-accent-soft)] text-[var(--color-accent)]">
<Archive size={20} />
</div>
<h2 className="text-2xl font-bold tracking-normal text-[var(--color-text)]"> .</h2>
<p className="mt-3 text-sm leading-6 text-[var(--color-text-muted)]">
.
</p>
</div>
<Link
href="/archive"
className="inline-flex w-fit items-center gap-2 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] px-4 py-2 text-sm font-semibold text-[var(--color-text)] shadow-[var(--shadow-control)] transition hover:bg-[var(--color-surface-strong)]"
>
<ChevronRight size={16} />
</Link>
</Surface>
</section>
</main>
);
}