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
3 changes: 2 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "1.4.0",
"version": "1.5.0",
"type": "module",
"private": true,
"scripts": {
Expand Down Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions apps/web/public/changelog.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,13 @@
"부를곡 페이지에서 재생목록을 통해 노래를 추가할 수 있습니다.",
"로직을 수정하여 검색 페이지에서 재생목록으로 저장 시 재생목록의 개수를 확인할 수 있습니다."
]
},
"1.5.0": {
"title": "버전 1.5.0",
"message": [
"검색 페이지에서 무한 스크롤 기능이 추가되었습니다.",
"검색 페이지에서 검색 기록 기능이 추가되었습니다.",
"TJ 노래방의 10000개 곡을 업데이트했습니다."
]
}
}
23 changes: 20 additions & 3 deletions apps/web/src/app/api/search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ export async function GET(request: Request): Promise<NextResponse<ApiResponse<Se
const type = searchParams.get('type') || 'title';
const authenticated = searchParams.get('authenticated') === 'true';

const page = parseInt(searchParams.get('page') || '0', 10);
const size = 20;
const from = page * size;
const to = from + size - 1;

if (!query) {
return NextResponse.json(
{
Expand All @@ -38,7 +43,13 @@ export async function GET(request: Request): Promise<NextResponse<ApiResponse<Se
const supabase = await createClient();

if (!authenticated) {
const { data, error } = await supabase.from('songs').select('*').ilike(type, `%${query}%`);
const { data, error, count } = await supabase
.from('songs')
.select('*', { count: 'exact' })
.ilike(type, `%${query}%`)
.order('release', { ascending: false })
.range(from, to);

if (error) {
return NextResponse.json(
{
Expand All @@ -65,12 +76,14 @@ export async function GET(request: Request): Promise<NextResponse<ApiResponse<Se
return NextResponse.json({
success: true,
data: songs,
// 전체 개수가 현재 페이지 번호 * 페이지 크기(범위의 끝이 되는 index) 보다 크면 다음 페이지가 있음
hasNext: (count ?? 0) > to + 1,
});
}

const userId = await getAuthenticatedUser(supabase); // userId 가져오기

const { data, error } = await supabase
const { data, error, count } = await supabase
.from('songs')
.select(
`
Expand All @@ -85,8 +98,11 @@ export async function GET(request: Request): Promise<NextResponse<ApiResponse<Se
user_id
)
`,
{ count: 'exact' },
)
.ilike(type, `%${query}%`);
.ilike(type, `%${query}%`)
.order('release', { ascending: false })
.range(from, to);

if (error) {
return NextResponse.json(
Expand Down Expand Up @@ -116,6 +132,7 @@ export async function GET(request: Request): Promise<NextResponse<ApiResponse<Se
return NextResponse.json({
success: true,
data: songs,
hasNext: (count ?? 0) > to + 1,
});
} catch (error) {
if (error instanceof Error && error.cause === 'auth') {
Expand Down
39 changes: 36 additions & 3 deletions apps/web/src/app/search/HomePage.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -18,8 +21,14 @@ export default function SearchPage() {
search,
query,
setSearch,
searchSongs,

searchResults,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isError,

saveModalType,
setSaveModalType,
selectedSaveSong,
Expand All @@ -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();

// 엔터 키 처리
Expand All @@ -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);
Expand Down Expand Up @@ -111,7 +139,7 @@ export default function SearchPage() {
</div>
)}
</div>
<ScrollArea className="h-[calc(100vh-16rem)]">
<ScrollArea className="h-[calc(100vh-20rem)]">
{searchSongs.length > 0 && (
<div className="flex w-[360px] flex-col gap-3 px-2 py-4">
{searchSongs.map((song, index) => (
Expand All @@ -125,6 +153,11 @@ export default function SearchPage() {
onClickSave={() => handleToggleSave(song, song.isSave ? 'PATCH' : 'POST')}
/>
))}
{hasNextPage && !isFetchingNextPage && (
<div ref={ref} className="flex h-10 items-center justify-center p-2">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
)}
</div>
)}
{searchSongs.length === 0 && query && (
Expand Down
21 changes: 17 additions & 4 deletions apps/web/src/hooks/useSearchSong.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { toast } from 'sonner';

import { useMoveSaveSongMutation } from '@/queries/saveSongQuery';
import {
useInfiniteSearchSongQuery,
useSaveMutation,
useSearchSongQuery,
useToggleLikeMutation,
useToggleToSingMutation,
} from '@/queries/searchSongQuery';
Expand All @@ -24,13 +24,20 @@ export default function useSearchSong() {
const [searchType, setSearchType] = useState<SearchType>('title');
const [saveModalType, setSaveModalType] = useState<SaveModalType>('');
const [selectedSaveSong, setSelectedSaveSong] = useState<SearchSong | null>(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);
Expand Down Expand Up @@ -78,8 +85,14 @@ export default function useSearchSong() {
search,
setSearch,
query,
searchSongs,

searchResults,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isError,

searchType,
handleSearchTypeChange,
handleSearch,
Expand Down
22 changes: 20 additions & 2 deletions apps/web/src/lib/api/searchSong.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ApiResponse<SearchSong[]>>('/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<ApiResponse<SearchSong[]>>('/search', {
params: { q: search, type: searchType, authenticated: isAuthenticated, page },
});

return response.data;
Expand Down
39 changes: 37 additions & 2 deletions apps/web/src/queries/searchSongQuery.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/types/apiRoute.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export interface ApiSuccessResponse<T> {
success: true;
data?: T;
hasNext?: boolean;
// data: T; 타입 에러
}

Expand Down
2 changes: 1 addition & 1 deletion packages/crawling/src/progress.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"index":50515}
{ "index": 55203 }
18 changes: 18 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.