Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FE-75 ✨무한 스크롤, 검색 결과 URL에 저장 기능 구현 #104

Merged
merged 12 commits into from
Jul 30, 2024
Merged
12 changes: 12 additions & 0 deletions src/apis/getEpigrams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { GetEpigramsParamsType, GetEpigramsResponseType, GetEpigramsResponse } from '@/schema/epigrams';
import httpClient from '.';

const getEpigrams = async (params: GetEpigramsParamsType): Promise<GetEpigramsResponseType> => {
const response = await httpClient.get(`/epigrams`, { params });

// 데이터 일치하는지 확인
const parsedResponse = GetEpigramsResponse.parse(response.data);
return parsedResponse;
};

export default getEpigrams;
44 changes: 28 additions & 16 deletions src/components/search/SearchResults.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import Link from 'next/link';
import { GetEpigramsResponseType } from '@/schema/epigrams';

Expand All @@ -7,6 +7,7 @@ import { GetEpigramsResponseType } from '@/schema/epigrams';
interface SearchResultsProps {
results: GetEpigramsResponseType | null;
query: string;
isLoading: boolean;
}

// 텍스트 하이라이팅 함수
Expand Down Expand Up @@ -35,25 +36,36 @@ function handleHighlightText(text: string, highlight: string) {
);
}

function SearchResults({ results, query }: SearchResultsProps) {
if (!results) {
return <span>검색 결과를 불러오는 중 문제가 발생했습니다.</span>;
}
function SearchResults({ results, query, isLoading }: SearchResultsProps) {
// 태그와 내용 순서로 정렬 - 항상 useMemo를 호출하고, results가 null인 경우 빈 배열 반환
const sortedResults = useMemo(() => {
if (!results) return [];
return results.list.sort((a, b) => {
const aHasTag = a.tags.some((tag) => tag.name.includes(query));
const bHasTag = b.tags.some((tag) => tag.name.includes(query));

// 태그와 내용 순서로 정렬
const sortedResults = results.list.sort((a, b) => {
const aHasTag = a.tags.some((tag) => tag.name.includes(query));
const bHasTag = b.tags.some((tag) => tag.name.includes(query));
if (aHasTag && !bHasTag) return -1;
if (!aHasTag && bHasTag) return 1;
return 0;
});
}, [results, query]);

if (aHasTag && !bHasTag) return -1;
if (!aHasTag && bHasTag) return 1;
return 0;
});
const filteredResults = useMemo(
() => sortedResults.filter((item) => item.content.includes(query) || item.author.includes(query) || item.tags.some((tag) => tag.name.includes(query))),
[sortedResults, query],
);

// TODO useMemo 사용하는게 나을 것 같음(멘토님)
const filteredResults = sortedResults.filter((item) => item.content.includes(query) || item.author.includes(query) || item.tags.some((tag) => tag.name.includes(query)));
if (isLoading) {
return (
<div className='flex flex-col py-4 px-6 lg:p-6 gap-2 lg:gap-[16px]'>
<div className='flex flex-col gap-1 md:gap-2 lg:gap-6'>
<span className='text-black-600 font-iropkeBatang iropke-lg lg:iropke-xl'>검색 결과를 불러오는 중 입니다...</span>
</div>
</div>
);
}

if (filteredResults.length === 0) {
if (!results || filteredResults.length === 0) {
return (
<div className='flex flex-col py-4 px-6 lg:p-6 gap-2 lg:gap-[16px]'>
<div className='flex flex-col gap-1 md:gap-2 lg:gap-6'>
Expand Down
71 changes: 0 additions & 71 deletions src/components/search/test.ts

This file was deleted.

13 changes: 13 additions & 0 deletions src/hooks/useGetEpigramsHooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { useQuery } from '@tanstack/react-query';
import getEpigrams from '@/apis/getEpigrams';
import { GetEpigramsResponseType } from '@/schema/epigrams';

const useEpigrams = (query: string, page: number, limit: number = 10) =>
useQuery<GetEpigramsResponseType, Error>({
queryKey: ['epigrams', query, page, limit],
queryFn: () => getEpigrams({ keyword: query, limit, cursor: page * limit }),
enabled: !!query,
staleTime: 5 * 60 * 1000, // 데이터 신선도 설정
});

export default useEpigrams;
108 changes: 86 additions & 22 deletions src/pageLayout/SearchLayout/SearchLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,47 +1,111 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { useRouter } from 'next/router';
import Header from '@/components/Header/Header';
import SearchBar from '@/components/search/SearchBar';
import RecentSearches from '@/components/search/RecentSearches';
import SearchResults from '@/components/search/SearchResults';
import testData from '@/components/search/test';

// TODO 로그인한 사용자에 따라서 최근 검색어를 관리할 수 있도록 추후에 수정
// TODO 실제 api와 연동 후 테스트 코드 삭제
// TODO 검색 결과를 URL 에 저장, 새로고침시 데이터 분실에 대응
import useEpigrams from '@/hooks/useGetEpigramsHooks';
import { GetEpigramsResponseType } from '@/schema/epigrams';

function SearchLayout() {
const [searches, setSearches] = useState<string[]>([]);
const [currentSearch, setCurrentSearch] = useState<string>('');
const [page, setPage] = useState(0);
const [allResults, setAllResults] = useState<GetEpigramsResponseType['list']>([]);
const [totalCount, setTotalCount] = useState<number>(0);
const [nextCursor, setNextCursor] = useState<number | null>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
const router = useRouter();

const isBrowser = typeof window !== 'undefined'; // 브라우저 환경에서만 localStorage에 접근
const accessToken = isBrowser ? localStorage.getItem('accessToken') : null;
const isUserLoggedIn = !!accessToken;
const userId = isUserLoggedIn ? 'loggedInUser' : 'guest'; // 사용자 ID를 기반으로 저장 키 생성
const recentSearchesKey = `recentSearches_${userId}`;

const { data: searchResults, isLoading } = useEpigrams(currentSearch, page);

// 새로운 검색 결과를 allResults에 누적, 총 결과 개수와 다음 커서를 업데이트
useEffect(() => {
if (searchResults?.list) {
setAllResults((prevResults) => [...prevResults, ...searchResults.list]);
setTotalCount(searchResults.totalCount);
setNextCursor(searchResults.nextCursor);
}
}, [searchResults]);

// observerRef가 화면에 나타날 때 페이지 증가,추가 데이터 로드
useEffect(() => {
if (observerRef.current) observerRef.current.disconnect();

observerRef.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !isLoading && nextCursor !== null) {
setPage((prevPage) => prevPage + 1);
}
});

if (loadMoreRef.current) {
observerRef.current.observe(loadMoreRef.current);
}

// 옵저버 클린업 (메모리 누수 방지)
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
};
}, [allResults.length, isLoading, nextCursor]);

// 컴포넌트가 처음 렌더링 될 때 저장된 최근 검색어 불러오기, 로그인된 사용자 별로 최근 검색어를 구분하여 URL에 데이터 저장
useEffect(() => {
if (isBrowser) {
const storedSearches = JSON.parse(localStorage.getItem(recentSearchesKey) || '[]');
setSearches(storedSearches);
}

const searchParams = new URLSearchParams(window.location.search);
const query = searchParams.get('q');
if (query) {
setCurrentSearch(query);
}
}, [recentSearchesKey]);

// 모두지우기 클릭 시 저장된 최근 검색어 삭제
const handleClearAll = () => {
setSearches([]);
if (isBrowser) {
localStorage.removeItem(recentSearchesKey);
}
};

// 검색어가 제출될 때 작동
const handleSearch = async (search: string) => {
const handleSearch = (search: string) => {
setPage(0);
setAllResults([]);
setSearches((prevSearches) => {
// 중복되지 않는 검색어를 최근 검색어에 추가하고 최대 10개로 제한
const updatedSearches = [search, ...prevSearches.filter((item) => item !== search)].slice(0, 10);
localStorage.setItem('recentSearches', JSON.stringify(updatedSearches));
if (isBrowser) {
localStorage.setItem(recentSearchesKey, JSON.stringify(updatedSearches));
}
return updatedSearches;
});
setCurrentSearch(search);
};

// 모두지우기 클릭 시 저장된 최근 검색어 삭제
const handleClearAll = () => {
setSearches([]);
localStorage.removeItem('recentSearches');
const searchParams = new URLSearchParams(window.location.search);
searchParams.set('q', search);
router.push(`/search?${searchParams.toString()}`);
};

// 컴포넌트가 처음 렌더링 될 때 저장된 최근 검색어 불러오기
useEffect(() => {
const storedSearches = JSON.parse(localStorage.getItem('recentSearches') || '[]');
setSearches(storedSearches);
}, []);

return (
<>
<header />
<Header icon='search' routerPage='/search' isLogo insteadOfLogo='' isProfileIcon isShareIcon={false} isButton={false} textInButton='' disabled={false} onClick={() => {}} />;
<div className='container mx-auto max-w-screen-sm bg-blue-100'>
<SearchBar onSearch={handleSearch} currentSearch={currentSearch} />
<RecentSearches searches={searches} onSearch={handleSearch} onClear={handleClearAll} />
{currentSearch && <SearchResults results={testData} query={currentSearch} />}
{currentSearch && <SearchResults results={{ totalCount, nextCursor: nextCursor ?? 0, list: allResults }} query={currentSearch} isLoading={isLoading} />}
<div ref={loadMoreRef} />
</div>
</>
);
Expand Down
41 changes: 0 additions & 41 deletions src/pages/epigram/[id]/index.tsx

This file was deleted.

Loading
Loading