Redesign blog as desktop OS
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m10s

This commit is contained in:
박원엽
2026-05-29 09:46:00 +09:00
parent ab585b5faa
commit 7f9bc1f83b
24 changed files with 1016 additions and 592 deletions

View File

@@ -2,31 +2,36 @@
import { use, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getPostsByCategory } from '@/api/posts'; // 🛠️ 수정된 API 사용
import { clsx } from 'clsx';
import { ChevronLeft, ChevronRight, LayoutGrid, List, Loader2, Search as SearchIcon } from 'lucide-react';
import { getPostsByCategory } from '@/api/posts';
import EmptyState from '@/components/ui/EmptyState';
import PostCard from '@/components/post/PostCard';
import PostListItem from '@/components/post/PostListItem';
import PostSearch from '@/components/post/PostSearch'; // 🆕 검색 컴포넌트
import { Loader2, ChevronLeft, ChevronRight, LayoutGrid, List, Search as SearchIcon } from 'lucide-react';
import { clsx } from 'clsx';
import EmptyState from '@/components/ui/EmptyState';
import PostSearch from '@/components/post/PostSearch';
import Surface from '@/components/ui/Surface';
import WindowSurface from '@/components/ui/WindowSurface';
const PAGE_SIZE = 10;
const isNoticeCategoryName = (categoryName: string) => {
return categoryName === '공지' || categoryName === '怨듭?' || categoryName.toLowerCase() === 'notice';
};
export default function CategoryPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const categoryName = decodeURIComponent(id);
const apiCategoryName = categoryName === 'uncategorized' ? '미분류' : categoryName;
const isNoticeCategory = apiCategoryName === '공지' || apiCategoryName.toLowerCase() === 'notice';
const isNoticeCategory = isNoticeCategoryName(apiCategoryName);
const [page, setPage] = useState(0);
const [keyword, setKeyword] = useState(''); // 🆕 검색어 상태
const [keyword, setKeyword] = useState('');
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
if (isNoticeCategory || typeof window === 'undefined') return isNoticeCategory ? 'list' : 'grid';
const savedMode = localStorage.getItem('postViewMode');
return savedMode === 'list' ? 'list' : 'grid';
});
const PAGE_SIZE = 10;
const activeViewMode = isNoticeCategory ? 'list' : viewMode;
const handleViewModeChange = (mode: 'grid' | 'list') => {
@@ -36,22 +41,15 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
}
};
// 🆕 검색어가 변경되면 페이지를 0으로 초기화
const handleSearch = (newKeyword: string) => {
setKeyword(newKeyword);
setPage(0);
};
const { data: postsData, isLoading, error, isPlaceholderData } = useQuery({
queryKey: ['posts', 'category', apiCategoryName, page, keyword], // 🔑 쿼리 키에 keyword 추가
queryFn: () => getPostsByCategory(apiCategoryName, page, PAGE_SIZE, keyword), // 🆕 검색어 전달
// 🛠️ 수정됨: 검색어가 바뀌면 이전 데이터를 보여주지 않고 로딩 상태로 전환
// (페이지 이동 시에는 부드럽게 보여주기 위해 유지)
placeholderData: (previousData, previousQuery) => {
const prevKeyword = previousQuery?.queryKey[4];
if (prevKeyword !== keyword) return undefined;
return previousData;
},
queryKey: ['posts', 'category', apiCategoryName, page, keyword],
queryFn: () => getPostsByCategory(apiCategoryName, page, PAGE_SIZE, keyword),
placeholderData: (previousData) => previousData,
});
if (isLoading || (postsData === undefined && !error)) {
@@ -64,56 +62,55 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
if (error) {
return (
<Surface className="mx-auto max-w-3xl px-5 py-10 text-center text-red-500">
.<br/>
<WindowSurface className="mx-auto max-w-3xl" bodyClassName="px-5 py-10 text-center text-red-500">
.
<br />
<span className="text-sm text-[var(--color-text-subtle)]"> .</span>
</Surface>
</WindowSurface>
);
}
const posts = postsData?.content || [];
// 🛠️ 백엔드 PagedModel 구조 대응 (page 필드 내부에 메타데이터가 있을 수 있음)
const pagingData = postsData?.page || postsData;
const totalElements = pagingData?.totalElements ?? 0;
const totalPages = pagingData?.totalPages ?? 0;
// page.number가 존재하면 계산해서 isLast 판단, 아니면 기존 last 필드 사용
const isLast = pagingData?.number !== undefined
? (pagingData.number + 1 >= pagingData.totalPages)
const isLast = pagingData?.number !== undefined
? pagingData.number + 1 >= pagingData.totalPages
: (postsData?.last ?? true);
const handlePrevPage = () => setPage((old) => Math.max(old - 1, 0));
const handleNextPage = () => {
if (!isLast) {
setPage((old) => old + 1);
}
if (!isLast) setPage((old) => old + 1);
};
return (
<div className="mx-auto max-w-5xl px-4 py-8">
{/* 헤더 영역 */}
<Surface strong className="mb-8 p-5">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<main className="mx-auto max-w-5xl px-1 py-4 md:px-3 md:py-6">
<WindowSurface
title="Finder"
subtitle={`${apiCategoryName} posts`}
bodyClassName="p-5 md:p-6"
>
<div className="mb-8 flex flex-col justify-between gap-4 border-b border-[var(--color-line)] pb-5 md:flex-row md:items-center">
<h1 className="flex shrink-0 items-baseline gap-2 text-2xl font-bold tracking-normal text-[var(--color-text)]">
{apiCategoryName} <span className="text-lg font-normal text-[var(--color-text-subtle)]"> </span>
{apiCategoryName}
<span className="text-lg font-normal text-[var(--color-text-subtle)]"> </span>
</h1>
<div className="flex w-full items-center gap-3 md:w-auto">
{/* 🔍 카테고리 내 검색바 */}
<PostSearch
onSearch={handleSearch}
placeholder={`${apiCategoryName} 내 검색`}
className="w-full md:w-64"
/>
{/* 뷰 모드 버튼 */}
<div className="flex shrink-0 items-center gap-1 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] p-1 shadow-[var(--shadow-control)] backdrop-blur-xl">
<button
type="button"
onClick={() => handleViewModeChange('grid')}
className={clsx(
'rounded-full p-2 transition-all duration-150',
activeViewMode === 'grid'
? 'bg-[var(--color-surface-strong)] text-[var(--color-accent)] shadow-sm'
? 'bg-[var(--window-bg-strong)] text-[var(--color-accent)] shadow-sm'
: 'text-[var(--color-text-subtle)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
)}
title="카드형 보기"
@@ -123,11 +120,12 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
<LayoutGrid size={18} />
</button>
<button
type="button"
onClick={() => handleViewModeChange('list')}
className={clsx(
'rounded-full p-2 transition-all duration-150',
activeViewMode === 'list'
? 'bg-[var(--color-surface-strong)] text-[var(--color-accent)] shadow-sm'
? 'bg-[var(--window-bg-strong)] text-[var(--color-accent)] shadow-sm'
: 'text-[var(--color-text-subtle)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
)}
title="리스트형 보기"
@@ -139,64 +137,65 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
</div>
</div>
</div>
</Surface>
{/* 검색 결과 안내 (검색 중일 때만 표시) */}
{keyword && (
<div className="mb-6 flex items-center gap-2 rounded-lg border border-[var(--color-line)] bg-[var(--color-accent-soft)] px-4 py-3 text-sm text-[var(--color-text-muted)]">
<SearchIcon size={16} className="text-[var(--color-accent)]" />
<span>
<strong>{keyword}</strong> : <strong>{totalElements}</strong>
</span>
</div>
)}
{posts.length === 0 ? (
<EmptyState
title={keyword ? `${keyword}에 대한 검색 결과가 없습니다.` : '아직 작성된 글이 없습니다.'}
className="py-20"
/>
) : (
<>
{activeViewMode === 'grid' ? (
<div className="grid gap-6 md:grid-cols-2">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
) : (
<Surface className="flex flex-col p-2 shadow-none">
{posts.map((post) => (
<PostListItem key={post.id} post={post} />
))}
</Surface>
)}
<div className="mb-8 mt-12 flex items-center justify-center gap-6">
<button
onClick={handlePrevPage}
disabled={page === 0}
className="rounded-full p-2 text-[var(--color-text-muted)] transition-colors hover:bg-black/[0.04] hover:text-[var(--color-text)] disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:hover:bg-white/10"
aria-label="이전 페이지"
>
<ChevronLeft size={24} />
</button>
<span className="text-sm font-medium text-[var(--color-text-muted)]">
<span className="font-bold text-[var(--color-text)]">{page + 1}</span> {totalPages > 0 && `/ ${totalPages}`}
{keyword && (
<div className="mb-6 flex items-center gap-2 rounded-lg border border-[var(--color-line)] bg-[var(--color-accent-soft)] px-4 py-3 text-sm text-[var(--color-text-muted)]">
<SearchIcon size={16} className="text-[var(--color-accent)]" />
<span>
<strong>{keyword}</strong> : <strong>{totalElements}</strong>
</span>
<button
onClick={handleNextPage}
disabled={isLast || isPlaceholderData}
className="rounded-full p-2 text-[var(--color-text-muted)] transition-colors hover:bg-black/[0.04] hover:text-[var(--color-text)] disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:hover:bg-white/10"
aria-label="다음 페이지"
>
<ChevronRight size={24} />
</button>
</div>
</>
)}
</div>
)}
{posts.length === 0 ? (
<EmptyState
title={keyword ? `${keyword}에 대한 검색 결과가 없습니다.` : '아직 작성된 글이 없습니다.'}
className="py-20"
/>
) : (
<>
{activeViewMode === 'grid' ? (
<div className="grid gap-6 md:grid-cols-2">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
) : (
<Surface className="flex flex-col p-2 shadow-none">
{posts.map((post) => (
<PostListItem key={post.id} post={post} />
))}
</Surface>
)}
<div className="mb-2 mt-10 flex items-center justify-center gap-6">
<button
type="button"
onClick={handlePrevPage}
disabled={page === 0}
className="rounded-full p-2 text-[var(--color-text-muted)] transition-colors hover:bg-black/[0.04] hover:text-[var(--color-text)] disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:hover:bg-white/10"
aria-label="이전 페이지"
>
<ChevronLeft size={24} />
</button>
<span className="text-sm font-medium text-[var(--color-text-muted)]">
<span className="font-bold text-[var(--color-text)]">{page + 1}</span> {totalPages > 0 && `/ ${totalPages}`}
</span>
<button
type="button"
onClick={handleNextPage}
disabled={isLast || isPlaceholderData}
className="rounded-full p-2 text-[var(--color-text-muted)] transition-colors hover:bg-black/[0.04] hover:text-[var(--color-text)] disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:hover:bg-white/10"
aria-label="다음 페이지"
>
<ChevronRight size={24} />
</button>
</div>
</>
)}
</WindowSurface>
</main>
);
}