diff --git a/apps/web/package.json b/apps/web/package.json index fde6ec0..9aa64f3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "web", - "version": "1.4.0", + "version": "1.5.0", "type": "module", "private": true, "scripts": { @@ -46,6 +46,7 @@ "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-intersection-observer": "^9.16.0", "sonner": "^2.0.3", "tailwind-merge": "^3.0.2", "tw-animate-css": "^1.2.4", diff --git a/apps/web/public/changelog.json b/apps/web/public/changelog.json index 521564b..4386806 100644 --- a/apps/web/public/changelog.json +++ b/apps/web/public/changelog.json @@ -32,5 +32,13 @@ "부를곡 페이지에서 재생목록을 통해 노래를 추가할 수 있습니다.", "로직을 수정하여 검색 페이지에서 재생목록으로 저장 시 재생목록의 개수를 확인할 수 있습니다." ] + }, + "1.5.0": { + "title": "버전 1.5.0", + "message": [ + "검색 페이지에서 무한 스크롤 기능이 추가되었습니다.", + "검색 페이지에서 검색 기록 기능이 추가되었습니다.", + "TJ 노래방의 10000개 곡을 업데이트했습니다." + ] } } diff --git a/apps/web/src/app/api/search/route.ts b/apps/web/src/app/api/search/route.ts index 2dd0f52..09557bc 100644 --- a/apps/web/src/app/api/search/route.ts +++ b/apps/web/src/app/api/search/route.ts @@ -25,6 +25,11 @@ export async function GET(request: Request): Promise to + 1, }); } const userId = await getAuthenticatedUser(supabase); // userId 가져오기 - const { data, error } = await supabase + const { data, error, count } = await supabase .from('songs') .select( ` @@ -85,8 +98,11 @@ export async function GET(request: Request): Promise to + 1, }); } catch (error) { if (error instanceof Error && error.cause === 'auth') { diff --git a/apps/web/src/app/search/HomePage.tsx b/apps/web/src/app/search/HomePage.tsx index ca3ddad..ff2bb6c 100644 --- a/apps/web/src/app/search/HomePage.tsx +++ b/apps/web/src/app/search/HomePage.tsx @@ -1,6 +1,8 @@ 'use client'; -import { Search, SearchX, X } from 'lucide-react'; +import { Loader2, Search, SearchX, X } from 'lucide-react'; +import { useEffect } from 'react'; +import { useInView } from 'react-intersection-observer'; import StaticLoading from '@/components/StaticLoading'; import { Button } from '@/components/ui/button'; @@ -9,6 +11,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { useSearchHistory } from '@/hooks/useSearchHistory'; import useSearchSong from '@/hooks/useSearchSong'; +import { SearchSong } from '@/types/song'; import AddFolderModal from './AddFolderModal'; import SearchResultCard from './SearchResultCard'; @@ -18,8 +21,14 @@ export default function SearchPage() { search, query, setSearch, - searchSongs, + + searchResults, isLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isError, + saveModalType, setSaveModalType, selectedSaveSong, @@ -33,6 +42,16 @@ export default function SearchPage() { patchSaveSong, } = useSearchSong(); + const { ref, inView } = useInView(); + + let searchSongs: SearchSong[] = []; + + if (searchResults) { + searchSongs = searchResults.pages.flatMap(page => page.data); + } + + // console.log('searchResults', searchResults); + // console.log('pages', searchSongs); const { searchHistory, addToHistory, removeFromHistory } = useSearchHistory(); // 엔터 키 처리 @@ -43,6 +62,15 @@ export default function SearchPage() { } }; + useEffect(() => { + const timeout = setTimeout(() => { + if (inView && hasNextPage && !isFetchingNextPage && !isError) { + fetchNextPage(); + } + }, 1000); // 1000ms 정도 지연 + + return () => clearTimeout(timeout); + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isError]); const handleSearchClick = () => { handleSearch(); addToHistory(search); @@ -111,7 +139,7 @@ export default function SearchPage() { )} - + {searchSongs.length > 0 && (
{searchSongs.map((song, index) => ( @@ -125,6 +153,11 @@ export default function SearchPage() { onClickSave={() => handleToggleSave(song, song.isSave ? 'PATCH' : 'POST')} /> ))} + {hasNextPage && !isFetchingNextPage && ( +
+ +
+ )}
)} {searchSongs.length === 0 && query && ( diff --git a/apps/web/src/hooks/useSearchSong.ts b/apps/web/src/hooks/useSearchSong.ts index 6c2b760..1b1855a 100644 --- a/apps/web/src/hooks/useSearchSong.ts +++ b/apps/web/src/hooks/useSearchSong.ts @@ -3,8 +3,8 @@ import { toast } from 'sonner'; import { useMoveSaveSongMutation } from '@/queries/saveSongQuery'; import { + useInfiniteSearchSongQuery, useSaveMutation, - useSearchSongQuery, useToggleLikeMutation, useToggleToSingMutation, } from '@/queries/searchSongQuery'; @@ -24,13 +24,20 @@ export default function useSearchSong() { const [searchType, setSearchType] = useState('title'); const [saveModalType, setSaveModalType] = useState(''); const [selectedSaveSong, setSelectedSaveSong] = useState(null); - const { data: searchResults, isLoading } = useSearchSongQuery(query, searchType, isAuthenticated); + // const { data: searchResults, isLoading } = useSearchSongQuery(query, searchType, isAuthenticated); const { mutate: toggleToSing } = useToggleToSingMutation(); const { mutate: toggleLike } = useToggleLikeMutation(); const { mutate: postSong } = useSaveMutation(); const { mutate: moveSong } = useMoveSaveSongMutation(); - const searchSongs = searchResults ?? []; + const { + data: searchResults, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isError, + } = useInfiniteSearchSongQuery(query, searchType, isAuthenticated); const handleSearch = () => { setQuery(search); @@ -78,8 +85,14 @@ export default function useSearchSong() { search, setSearch, query, - searchSongs, + + searchResults, + fetchNextPage, + hasNextPage, + isFetchingNextPage, isLoading, + isError, + searchType, handleSearchTypeChange, handleSearch, diff --git a/apps/web/src/lib/api/searchSong.ts b/apps/web/src/lib/api/searchSong.ts index 505886f..4e6df71 100644 --- a/apps/web/src/lib/api/searchSong.ts +++ b/apps/web/src/lib/api/searchSong.ts @@ -3,9 +3,27 @@ import { SearchSong } from '@/types/song'; import { instance } from './client'; -export async function getSearchSong(search: string, searchType: string, isAuthenticated: boolean) { +export async function getInfiniteSearchSong( + search: string, + searchType: string, + isAuthenticated: boolean, + page?: number, +) { const response = await instance.get>('/search', { - params: { q: search, type: searchType, authenticated: isAuthenticated }, + params: { q: search, type: searchType, authenticated: isAuthenticated, page }, + }); + + return response.data; +} + +export async function getSearchSong( + search: string, + searchType: string, + isAuthenticated: boolean, + page?: number, +) { + const response = await instance.get>('/search', { + params: { q: search, type: searchType, authenticated: isAuthenticated, page }, }); return response.data; diff --git a/apps/web/src/queries/searchSongQuery.ts b/apps/web/src/queries/searchSongQuery.ts index 2ccda50..6c71029 100644 --- a/apps/web/src/queries/searchSongQuery.ts +++ b/apps/web/src/queries/searchSongQuery.ts @@ -1,8 +1,8 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { deleteLikeSong, postLikeSong } from '@/lib/api/likeSong'; import { postSaveSong } from '@/lib/api/saveSong'; -import { getSearchSong } from '@/lib/api/searchSong'; +import { getInfiniteSearchSong, getSearchSong } from '@/lib/api/searchSong'; import { deleteToSingSong, postToSingSong } from '@/lib/api/tosing'; import { postTotalStat } from '@/lib/api/totalStat'; import { Method } from '@/types/common'; @@ -11,6 +11,41 @@ import { SearchSong } from '@/types/song'; let invalidateToSingTimeout: NodeJS.Timeout | null = null; let invalidateLikeTimeout: NodeJS.Timeout | null = null; +export const useInfiniteSearchSongQuery = ( + search: string, + searchType: string, + isAuthenticated: boolean, +) => { + return useInfiniteQuery({ + queryKey: ['searchSong', search, searchType], + queryFn: async ({ pageParam }) => { + const response = await getInfiniteSearchSong(search, searchType, isAuthenticated, pageParam); + + // console.log('response', response); + + if (!response.success) { + throw new Error('Search API failed'); + } + return { + data: response.data || [], + hasNext: response.hasNext, + }; + }, + + getNextPageParam: (lastPage, pages) => { + // lastPage : 직전 페이지의 데이터 + // pages : 현재까지 조회된 모든 데이터 + // console.log('lastPage', lastPage); + // console.log('pages', pages); + + if (!lastPage || lastPage.data.length === 0) return undefined; + return lastPage.hasNext ? pages.length : undefined; + }, + initialPageParam: 0, + enabled: !!search, + }); +}; + export const useSearchSongQuery = ( search: string, searchType: string, diff --git a/apps/web/src/types/apiRoute.ts b/apps/web/src/types/apiRoute.ts index 278803a..239cc83 100644 --- a/apps/web/src/types/apiRoute.ts +++ b/apps/web/src/types/apiRoute.ts @@ -1,6 +1,7 @@ export interface ApiSuccessResponse { success: true; data?: T; + hasNext?: boolean; // data: T; 타입 에러 } diff --git a/packages/crawling/src/progress.json b/packages/crawling/src/progress.json index e59647b..1837e26 100644 --- a/packages/crawling/src/progress.json +++ b/packages/crawling/src/progress.json @@ -1 +1 @@ -{"index":50515} \ No newline at end of file +{ "index": 55203 } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60699cf..7578440 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -232,6 +232,9 @@ importers: react-dom: specifier: ^19.0.0 version: 19.1.0(react@19.1.0) + react-intersection-observer: + specifier: ^9.16.0 + version: 9.16.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) sonner: specifier: ^2.0.3 version: 2.0.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -6401,6 +6404,15 @@ packages: react: ^16.6.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.6.0 || ^17.0.0 || ^18.0.0 + react-intersection-observer@9.16.0: + resolution: {integrity: sha512-w9nJSEp+DrW9KmQmeWHQyfaP6b03v+TdXynaoA964Wxt7mdR3An11z4NNCQgL4gKSK7y1ver2Fq+JKH6CWEzUA==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react-dom: + optional: true + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -14899,6 +14911,12 @@ snapshots: react-fast-compare: 3.2.2 shallowequal: 1.1.0 + react-intersection-observer@9.16.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + optionalDependencies: + react-dom: 19.1.0(react@19.1.0) + react-is@16.13.1: {} react-is@18.3.1: {}