[common/features] refactor: 통합 검색 기능 공통 기능으로 변경

This commit is contained in:
2025-08-02 02:42:20 +09:00
parent 98cc45162d
commit aabd810571
17 changed files with 126 additions and 43 deletions
@@ -0,0 +1,21 @@
import { client } from "@/shared/api";
import type {
SearchParams,
SearchResult,
SearchService,
} from "../model/search-interface";
import { ENDPOINT } from "@/shared/config";
export class SearchApiService implements SearchService {
public async searchPost(params: SearchParams): Promise<SearchResult> {
const response = await client.get<SearchResult>(
ENDPOINT.BOARD_SERVICE.SEARCH_POST,
{
params,
},
);
return response.data;
}
}
export const SearchApi = new SearchApiService();
@@ -0,0 +1 @@
export { SearchPage } from "./ui/search-page";
@@ -0,0 +1,45 @@
import type { PageableList, PageableParams } from "@/shared/model";
export interface SearchParams extends PageableParams {
keywordType?: string;
keyword?: string;
siteId?: string;
}
export interface Post {
bbsId: number;
bbsNm: string;
pstNo: number;
pstTtl: string;
pstCn: string;
inqNocs: number;
wrtrNm: string;
wrtrCplc: string;
regDt: string;
rgtrNm: string;
}
export interface Board {
bbsId: number;
bbsNm: string;
fullMenuPath: string;
urlPath: string;
posts: Post[];
}
export interface Contents {
contsSn: number;
contsTtl: string;
contsCn: string;
fullMenuPath: string;
urlPath: string;
}
export interface SearchResult {
boardList: PageableList<Board>;
contsList: Contents[];
}
export interface SearchService {
searchPost(params: SearchParams): Promise<SearchResult>;
}
@@ -0,0 +1,247 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import {
Button,
Input,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@common/components";
import { ENDPOINT } from "@/shared/config";
import { SearchApi } from "../api/search-service";
import type { Board, Post, Contents } from "../model/search-interface";
import { getCookie } from "@/shared/lib";
const RENDER_LIMIT = 5;
const siteId = getCookie("site-id-key") ?? "";
function SearchPage() {
const router = useRouter();
const searchParams = useSearchParams();
const keywordParam = searchParams?.get("keyword");
const [keyword, setKeyword] = useState(keywordParam ?? "");
const [tab, setTab] = useState("all");
const { data } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.SEARCH_POST, keyword],
queryFn: () => SearchApi.searchPost({ keyword: keyword, siteId }),
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
setKeyword(formData.get("keyword") as string);
const params = new URLSearchParams(searchParams?.toString());
params.set("keyword", formData.get("keyword") as string);
router.replace(`?${params.toString()}`);
};
return (
<div className="-mb-2">
<h2 className="text-center text-4xl font-bold">통합검색</h2>
<form
onSubmit={handleSubmit}
className="relative mx-auto my-8 flex w-[52rem] items-center justify-center gap-2"
>
<Input
name="keyword"
className="h-[5rem] w-[48rem] rounded-[4rem] pl-8 pr-40 !text-2xl"
placeholder="검색어를 입력해주세요."
/>
<Button
size="icon"
className="absolute right-28 top-2 size-16 rounded-full"
>
<span className="sr-only">검색</span>
<i className="diveicon di-search text-2xl" />
</Button>
<Button
size="icon"
color="white"
className="absolute right-10 top-2 size-16 rounded-full"
>
<span className="sr-only">초기화</span>
<i className="diveicon di-reset text-2xl" />
</Button>
</form>
<div className="bg-background pb-20">
<div className="flex items-center justify-center">
<p className="py-16 text-3xl font-bold">
검색어 <span className="text-primary">&apos;{keyword}&apos;</span>로
검색한 결과입니다.
</p>
</div>
<div className="px-[10vw]">
<Tabs value={tab} onValueChange={setTab}>
<div className="flex justify-center">
<TabsList className="mx-auto">
<TabsTrigger value="all">
전체 (
{data?.contsList?.length ??
0 + (data?.boardList?.totalElements ?? 0)}
건)
</TabsTrigger>
<TabsTrigger value="contents">
웹페이지 ({data?.contsList?.length ?? 0}건)
</TabsTrigger>
<TabsTrigger value="board">
게시판 ({data?.boardList?.totalElements ?? 0}건)
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="all">
<div className="space-y-20 p-10">
<div>
<h3 className="mb-4 border-b-2 border-border pb-4 text-3xl font-bold">
웹페이지 ({data?.contsList?.length ?? 0}건)
</h3>
{renderContents(data?.contsList)}
{(data?.contsList?.length ?? 0) > RENDER_LIMIT && (
<button
className="h-[4rem] w-full hover:underline"
onClick={() => setTab("contents")}
>
검색 결과 더보기
</button>
)}
</div>
<div>
<h3 className="mb-4 border-b-2 border-border pb-4 text-3xl font-bold">
게시판 ({data?.boardList?.totalElements ?? 0}건)
</h3>
{renderPost(data?.boardList.content)}
{(data?.boardList?.totalElements ?? 0) > RENDER_LIMIT && (
<button
className="h-[4rem] w-full hover:underline"
onClick={() => setTab("board")}
>
검색 결과 더보기
</button>
)}
</div>
</div>
</TabsContent>
<TabsContent value="contents">
{renderContents(data?.contsList, 100)}
</TabsContent>
<TabsContent value="board">
{renderPost(data?.boardList.content, 100)}
</TabsContent>
</Tabs>
</div>
</div>
</div>
);
}
interface ContentItemProps {
content: Contents;
}
function ContentItem({ content }: ContentItemProps) {
return (
<li className="border-b border-border px-6 py-8">
<Link
href={content.urlPath}
className="hover:underline hover:underline-offset-1"
>
<span className="text-lg">{content.fullMenuPath}</span>
<div className="mt-2 flex flex-col gap-1">
<em
className="font-bold not-italic"
dangerouslySetInnerHTML={{
__html: content.contsTtl,
}}
/>
<p
dangerouslySetInnerHTML={{
__html: content.contsCn,
}}
/>
</div>
</Link>
</li>
);
}
function renderContents(contents?: Contents[], limit = RENDER_LIMIT) {
if (!contents || !contents.length) return null;
return (
<>
{contents.map((content, index) => {
if (limit <= index) return null;
return (
<ul key={content.contsSn} className="flex flex-col">
<ContentItem key={content.contsSn} content={content} />
</ul>
);
})}
</>
);
}
interface PostItemProps {
post: Post;
board: Board;
}
function PostItem({ post, board }: PostItemProps) {
const postUrl = !!board.urlPath ? `${board.urlPath}/${post.pstNo}` : "";
return (
<li className="border-b border-border px-6 py-8">
<Link href={postUrl} className="hover:underline hover:underline-offset-1">
<span className="text-lg">{board.fullMenuPath}</span>
<div className="mt-2 flex flex-col gap-1">
<em
className="font-bold not-italic"
dangerouslySetInnerHTML={{
__html: post.pstTtl,
}}
/>
<p
dangerouslySetInnerHTML={{
__html: post.pstCn,
}}
/>
</div>
</Link>
</li>
);
}
function renderPost(boards?: Board[], limit = RENDER_LIMIT) {
if (!boards || !boards.length) return null;
let remainingPosts = limit;
return (
<>
{boards.map((board) => {
if (remainingPosts <= 0) return null;
const postsToShow = Math.min(board.posts.length, remainingPosts);
if (postsToShow <= 0) return null;
remainingPosts -= postsToShow;
return (
<ul key={board.bbsId} className="flex flex-col">
{board.posts.slice(0, postsToShow).map((post) => (
<PostItem key={post.pstNo} post={post} board={board} />
))}
</ul>
);
})}
</>
);
}
export { SearchPage };
@@ -44,6 +44,7 @@ export const ENDPOINT = {
POSTS_USER: "/board-service/api/v1/posts/user",
POSTS_COMMENTS: "/board-service/api/v1/posts-cmnts",
POST_EVALUATION: "/board-service/api/v1/pstEvl",
SEARCH_POST: "/board-service/api/v1/isearch/pst",
},
};
@@ -12,6 +12,8 @@ type PostActionProps = {
handleDelete: () => Promise<void>;
};
const basePath = process.env.NODE_ENV === "production" ? "/cms" : "";
function PostAction({ registerId, children, handleDelete }: PostActionProps) {
const router = useRouter();
const pathname = usePathname();
@@ -19,6 +21,7 @@ function PostAction({ registerId, children, handleDelete }: PostActionProps) {
const { copyText, copied } = useClipboard();
const bbsInfo = useMenuStore((state) => state.bbsInfo);
const userInfo = useUserStore((state) => state.userInfo);
const postPageLink = `${basePath}/${bbsInfo?.bbsTypeCd.toLowerCase()}/${bbsInfo?.bbsId}`;
const actionButtonAuthrt =
userInfo?.adminRoleYn === "Y" || userInfo?.userUnqId === registerId;
@@ -60,7 +63,7 @@ function PostAction({ registerId, children, handleDelete }: PostActionProps) {
size="lg"
color="white"
type="button"
onClick={() => router.back()}
onClick={() => router.push(postPageLink)}
>
목록
</Button>
@@ -13,6 +13,7 @@ import {
Skeleton,
} from "@common/components";
import { AttachmentApi } from "@/entities/attachment";
import { BoardApi } from "@/entities/board";
import { PostApi } from "@/entities/post";
import {
useAlertStore,
@@ -31,10 +32,17 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
const alert = useAlertStore((state) => state.setMessage);
const userInfo = useUserStore((state) => state.userInfo);
const bbsInfo = useMenuStore((state) => state.bbsInfo);
const setBbsInfo = useMenuStore((state) => state.setBbsInfo);
const [filename, setFilename] = useState("");
const [evlScr, setEvlScr] = useState(5);
const { data: boardInfo } = useQuery({
queryKey: [bbsId],
queryFn: () => BoardApi.getBoardInfo(bbsId),
enabled: !bbsInfo,
});
const { data: postData } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo],
queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)),
@@ -142,6 +150,12 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
}
}, [downloadResponse]);
useEffect(() => {
if (!boardInfo) return;
if (bbsInfo) return;
setBbsInfo(boardInfo);
}, [boardInfo, bbsInfo]);
return (
<>
<PostAction registerId={postData?.rgtr} handleDelete={handleDelete}>