Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 21 additions & 15 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import "@/shared/styles/globals.css";
import Header from "@/widgets/header/ui/Header";
import { ModalContainer } from "@/shared/ui/ModalContainer/ModalContainer";
import ChatContainer from "@/features/chat/ui/ChatContainer";
import { ReactQueryProvider } from "@/shared/lib/provider";
import { ClientLayout } from "@/shared/lib/ClientLayout";

const myFont = localFont({
src: "../shared/fonts/PretendardVariable.woff2",
Expand All @@ -23,25 +25,29 @@ export default function RootLayout({
return (
<html lang="kr" className={myFont.className}>
<body className="mx-auto max-w-[1200px] bg-[#1c1c22] pt-[70px] md:pt-[80px] xl:pt-[100px]">
<Suspense fallback={null}>
<Header />
</Suspense>
<ReactQueryProvider>
<ClientLayout>
<Suspense fallback={null}>
<Header />
</Suspense>

<Suspense
fallback={<div className="p-4 text-gray-400">Loading...</div>}
>
{children}
</Suspense>
<Suspense
fallback={<div className="p-4 text-gray-400">Loading...</div>}
>
{children}
</Suspense>

<div id="modal-root" />
<div id="modal-root" />

<Suspense fallback={null}>
<ChatContainer />
</Suspense>
<Suspense fallback={null}>
<ChatContainer />
</Suspense>

<Suspense fallback={null}>
<ModalContainer />
</Suspense>
<Suspense fallback={null}>
<ModalContainer />
</Suspense>
</ClientLayout>
</ReactQueryProvider>
</body>
</html>
);
Expand Down
62 changes: 28 additions & 34 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,48 +1,42 @@
import SideMenuWrapper from "@/widgets/main/ui/SideMenu/SideMenuWrapper";
import PostList from "@/entities/post/ui/list/PostList";
import { serverFetch } from "@/shared/api/fetcher.server";
import { POST_PAGE_SIZE } from "@/entities/post/model/constants/api";
import type { Post } from "@/entities/post/model/types/post";
import {
QueryClient,
dehydrate,
HydrationBoundary,
} from "@tanstack/react-query";
import { getPosts } from "@/entities/post/api/getPosts";
import HomePageClient from "@/widgets/main/ui/Client/HomePage.client";

export default async function HomePage({
searchParams,
}: {
searchParams: Promise<Record<string, string>>;
}) {
const params = await searchParams;
const initialCategory = params?.category ?? "전체";
const initialSort = params?.sort ?? "latest";
const initialKeyword = (params?.keyword ?? "").trim();

const selectedCategory = params?.category ?? "전체";
const selectedSortOption = params?.sort ?? "latest";
const page = Number(params?.page ?? 1);
const keyword = (params?.keyword ?? "").trim();
const queryClient = new QueryClient();

const query = new URLSearchParams({
page: String(page),
size: String(POST_PAGE_SIZE),
sort: selectedSortOption,
await queryClient.prefetchInfiniteQuery({
queryKey: ["posts", initialCategory, initialSort, initialKeyword],
queryFn: ({ pageParam = 1 }) =>
getPosts({
category: initialCategory,
sort: initialSort,
page: pageParam,
keyword: initialKeyword,
}),
initialPageParam: 1,
});
if (selectedCategory !== "전체") query.append("category", selectedCategory);
if (keyword) query.append("keyword", keyword);

const { data: posts } = await serverFetch<{ data: Post[] }>(
`/api/postings?${query.toString()}`,
{ method: "GET" },
);

return (
<div className="flex flex-col gap-[60px] md:block">
<aside>
<SideMenuWrapper selectedCategory={selectedCategory} />
</aside>

<main className="mb-[30px] md:ml-[160px] md:pr-[30px] md:pl-[25px] lg:pr-[60px] lg:pl-[90px]">
<PostList
initialPosts={posts}
selectedCategory={selectedCategory}
selectedSortOption={selectedSortOption}
selectedKeyword={keyword}
/>
</main>
</div>
<HydrationBoundary state={dehydrate(queryClient)}>
<HomePageClient
initialCategory={initialCategory}
initialSort={initialSort}
initialKeyword={initialKeyword}
/>
</HydrationBoundary>
);
}
30 changes: 30 additions & 0 deletions src/entities/post/api/getPosts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { serverFetch } from "@/shared/api/fetcher.server";
import { POST_PAGE_SIZE } from "@/entities/post/model/constants/api";
import type { Post } from "@/entities/post/model/types/post";

export async function getPosts({
category,
keyword,
sort,
page,
}: {
category: string;
keyword?: string;
sort?: string;
page?: number;
}) {
const query = new URLSearchParams({
page: String(page),
size: String(POST_PAGE_SIZE),
});

if (category !== "전체") query.append("category", category);
if (keyword) query.append("keyword", keyword);
if (sort) query.append("sort", sort);

const { data } = await serverFetch<{ data: Post[] }>(
`/api/postings?${query.toString()}`,
{ method: "GET" },
);
return data;
}
95 changes: 31 additions & 64 deletions src/entities/post/ui/list/PostList.tsx
Original file line number Diff line number Diff line change
@@ -1,85 +1,52 @@
"use client";

import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch } from "@/shared/api/fetcher";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useInfiniteScroll } from "@/shared/lib/useInfiniteScroll";
import { getPosts } from "@/entities/post/api/getPosts";
import { POST_PAGE_SIZE } from "@/entities/post/model/constants/api";
import { SORT_OPTION_LIST } from "@/widgets/main/model/constants";
import SortMenu from "@/widgets/main/ui/Select/Select";
import PostCard from "@/entities/post/ui/card/PostCard";
import type { Post } from "@/entities/post/model/types/post";

interface Props {
initialPosts: Post[];
selectedCategory: string;
selectedSortOption: string;
onSortChange: (value: string) => void;
selectedKeyword?: string;
}

export default function PostList({
initialPosts,
selectedCategory,
selectedSortOption,
onSortChange,
selectedKeyword,
}: Props) {
const router = useRouter();
const sp = useSearchParams();

const [posts, setPosts] = useState<Post[]>(initialPosts);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(
initialPosts.length === POST_PAGE_SIZE,
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: [
"posts",
selectedCategory,
selectedSortOption,
selectedKeyword,
],
queryFn: ({ pageParam = 1 }) =>
getPosts({
category: selectedCategory,
sort: selectedSortOption,
page: pageParam,
keyword: selectedKeyword,
}),
getNextPageParam: (lastPage, allPages) =>
lastPage.length === POST_PAGE_SIZE ? allPages.length + 1 : undefined,
initialPageParam: 1,
});

const posts = data?.pages.flat() ?? [];
const lastRef = useInfiniteScroll(
() => fetchNextPage(),
isFetchingNextPage,
hasNextPage,
);
const [isLoading, setIsLoading] = useState(false);

useEffect(() => {
setPosts(initialPosts);
setPage(1);
setHasMore(initialPosts.length === POST_PAGE_SIZE);
}, [initialPosts]);

const fetchMore = async () => {
if (isLoading || !hasMore) return;
setIsLoading(true);

try {
const nextPage = page + 1;

const query = new URLSearchParams({
page: String(nextPage),
size: String(POST_PAGE_SIZE),
sort: selectedSortOption,
});
if (selectedCategory && selectedCategory !== "전체") {
query.append("category", selectedCategory);
}
if (selectedKeyword) {
query.append("keyword", selectedKeyword);
}

const res = await apiFetch<{ data: Post[] }>(
`/api/postings?${query.toString()}`,
{ method: "GET" },
);

setPosts((prev) => [...prev, ...res.data]);
setHasMore(res.data.length === POST_PAGE_SIZE);
setPage(nextPage);
} finally {
setIsLoading(false);
}
};

const lastRef = useInfiniteScroll(fetchMore, isLoading, hasMore);

const handleSortChange = (value: string) => {
const params = new URLSearchParams(sp.toString());
params.set("sort", value);

const qs = params.toString();
router.replace(qs ? `/?${qs}` : "/", { scroll: true });
};

return (
<section className="flex flex-col gap-[30px] px-[20px]">
Expand All @@ -90,7 +57,7 @@ export default function PostList({
<SortMenu
items={SORT_OPTION_LIST}
selectedItem={selectedSortOption}
onSelect={handleSortChange}
onSelect={onSortChange}
/>
</div>

Expand All @@ -106,7 +73,7 @@ export default function PostList({
)}
</div>

{isLoading && (
{isFetchingNextPage && (
<p className="mt-4 text-center text-gray-400">불러오는 중...</p>
)}
</section>
Expand Down
11 changes: 11 additions & 0 deletions src/shared/commands/SearchCommands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { useSearchStore } from "@/shared/model/search.store";

export const SearchCommands = {
changeCategory: (category: string) =>
useSearchStore.getState().setCategory(category),

changeSort: (sort: string) => useSearchStore.getState().setSort(sort),

changeKeyword: (keyword: string) =>
useSearchStore.getState().setKeyword(keyword),
};
8 changes: 8 additions & 0 deletions src/shared/lib/ClientLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"use client";

import { useResetKeywordOnPathChange } from "@/shared/lib/useResetKeyword";

export function ClientLayout({ children }: { children: React.ReactNode }) {
useResetKeywordOnPathChange();
return <>{children}</>;
}
33 changes: 33 additions & 0 deletions src/shared/lib/provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react";

export function ReactQueryProvider({
children,
}: {
children: React.ReactNode;
}) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60,
gcTime: 1000 * 60 * 5,
refetchOnWindowFocus: false,
},
},
}),
);

return (
<QueryClientProvider client={queryClient}>
{children}
{process.env.NODE_ENV === "development" && (
<ReactQueryDevtools initialIsOpen={false} />
)}
</QueryClientProvider>
);
}
18 changes: 18 additions & 0 deletions src/shared/lib/useResetKeyword.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"use client";

import { useEffect, useRef } from "react";
import { usePathname } from "next/navigation";
import { SearchCommands } from "@/shared/commands/SearchCommands";

export function useResetKeywordOnPathChange() {
const pathname = usePathname();
const prevPathRef = useRef<string>("");

useEffect(() => {
if (prevPathRef.current && prevPathRef.current !== pathname) {
SearchCommands.changeKeyword("");
}

prevPathRef.current = pathname;
}, [pathname]);
}
Loading