From 06d070d97c6a5ea1674983d2bd37776558ee754f Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Wed, 4 Feb 2026 02:16:21 +0900 Subject: [PATCH 01/21] =?UTF-8?q?feat=20:=20=EA=B2=80=EC=83=89=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EC=97=90=EC=84=9C=20guest=20=EC=9D=BC=20?= =?UTF-8?q?=EB=95=8C=20tosing=EC=9D=80=20=EA=B0=80=EB=8A=A5=ED=95=98?= =?UTF-8?q?=EA=B2=8C=20=EC=88=98=EC=A0=95.=20=EB=A1=9C=EC=BB=AC=EC=8A=A4?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=EC=A7=80=EB=A1=9C=20=EC=A0=80=EC=9E=A5.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/app/search/HomePage.tsx | 35 ++++++++--- apps/web/src/app/search/SearchResultCard.tsx | 8 ++- apps/web/src/auth.tsx | 10 +++- apps/web/src/components/ThumbUpModal.tsx | 2 +- apps/web/src/hooks/useSearchSong.ts | 10 +++- apps/web/src/stores/useGuestToSingStore.ts | 61 ++++++++++++++++++++ 6 files changed, 114 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/stores/useGuestToSingStore.ts diff --git a/apps/web/src/app/search/HomePage.tsx b/apps/web/src/app/search/HomePage.tsx index 25dc02b7..f6e40e11 100644 --- a/apps/web/src/app/search/HomePage.tsx +++ b/apps/web/src/app/search/HomePage.tsx @@ -7,10 +7,10 @@ import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { ScrollArea } from '@/components/ui/scroll-area'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import useSearchSong from '@/hooks/useSearchSong'; import { type ChatMessage } from '@/lib/api/openAIchat'; +import useGuestToSingStore from '@/stores/useGuestToSingStore'; import useSearchHistoryStore from '@/stores/useSearchHistoryStore'; import { SearchSong } from '@/types/song'; import { ChatResponseType } from '@/utils/safeParseJson'; @@ -47,17 +47,25 @@ export default function SearchPage() { handleToggleSave, postSaveSong, patchSaveSong, + + isAuthenticated, } = useSearchSong(); const { ref, inView } = useInView(); - let searchSongs: SearchSong[] = []; + const { searchHistory, removeFromHistory } = useSearchHistoryStore(); + const { localToSingSongIds } = useGuestToSingStore(); - if (searchResults) { - searchSongs = searchResults.pages.flatMap(page => page.data); - } + const isToSing = (song: SearchSong, songId: string) => { + if (!isAuthenticated) { + return localToSingSongIds?.includes(songId); + } + return song.isToSing; + }; - const { searchHistory, removeFromHistory } = useSearchHistoryStore(); + const searchSongs: SearchSong[] = searchResults + ? searchResults.pages.flatMap(page => page.data) + : []; // 엔터 키 처리 const handleKeyUp = (e: React.KeyboardEvent) => { @@ -103,7 +111,15 @@ export default function SearchPage() { return (
-

노래 검색

+
+

노래 검색

+ + {!isAuthenticated && ( + + Guest 상태에서는 [부를곡 추가] 만 가능합니다. + + )} +
@@ -164,8 +180,11 @@ export default function SearchPage() { - handleToggleToSing(song.id, song.isToSing ? 'DELETE' : 'POST') + handleToggleToSing(song.id, isToSing(song, song.id) ? 'DELETE' : 'POST') } onToggleLike={() => handleToggleLike(song.id, song.isLike ? 'DELETE' : 'POST')} onClickSave={() => handleToggleSave(song, song.isSave ? 'PATCH' : 'POST')} diff --git a/apps/web/src/app/search/SearchResultCard.tsx b/apps/web/src/app/search/SearchResultCard.tsx index e7c316ca..0ac99b21 100644 --- a/apps/web/src/app/search/SearchResultCard.tsx +++ b/apps/web/src/app/search/SearchResultCard.tsx @@ -11,6 +11,9 @@ import { SearchSong } from '@/types/song'; interface IProps { song: SearchSong; + isToSing: boolean; + isLike: boolean; + isSave: boolean; onToggleToSing: () => void; onToggleLike: () => void; onClickSave: () => void; @@ -19,12 +22,15 @@ interface IProps { export default function SearchResultCard({ song, + isToSing, + isLike, + isSave, onToggleToSing, onToggleLike, onClickSave, onClickArtist, }: IProps) { - const { id, title, artist, num_tj, num_ky, isToSing, isLike, isSave } = song; + const { id, title, artist, num_tj, num_ky } = song; const { isAuthenticated } = useAuthStore(); const [open, setOpen] = useState(false); diff --git a/apps/web/src/auth.tsx b/apps/web/src/auth.tsx index 5063375a..ec511c06 100644 --- a/apps/web/src/auth.tsx +++ b/apps/web/src/auth.tsx @@ -5,7 +5,15 @@ import { useEffect, useState } from 'react'; import useAuthStore from '@/stores/useAuthStore'; -const ALLOW_PATHS = ['/', '/popular', '/login', '/signup', '/recent', '/update-password']; +const ALLOW_PATHS = [ + '/', + '/popular', + '/login', + '/signup', + '/recent', + '/tosing', + '/update-password', +]; export default function AuthProvider({ children }: { children: React.ReactNode }) { const router = useRouter(); diff --git a/apps/web/src/components/ThumbUpModal.tsx b/apps/web/src/components/ThumbUpModal.tsx index 8849cbb7..30e05579 100644 --- a/apps/web/src/components/ThumbUpModal.tsx +++ b/apps/web/src/components/ThumbUpModal.tsx @@ -43,7 +43,7 @@ export default function ThumbUpModal({ songId, handleClose }: ThumbUpModalProps) 노래 추천하기 - +
diff --git a/apps/web/src/hooks/useSearchSong.ts b/apps/web/src/hooks/useSearchSong.ts index d429a116..7cc98095 100644 --- a/apps/web/src/hooks/useSearchSong.ts +++ b/apps/web/src/hooks/useSearchSong.ts @@ -9,6 +9,7 @@ import { useToggleToSingMutation, } from '@/queries/searchSongQuery'; import useAuthStore from '@/stores/useAuthStore'; +import useGuestToSingStore from '@/stores/useGuestToSingStore'; import useSearchHistoryStore from '@/stores/useSearchHistoryStore'; import { Method } from '@/types/common'; import { SearchSong } from '@/types/song'; @@ -46,6 +47,7 @@ export default function useSearchSong() { } = useInfiniteSearchSongQuery(query, searchType, isAuthenticated); const { addToHistory } = useSearchHistoryStore(); + const { addSong, removeSong } = useGuestToSingStore(); const handleSearch = () => { // trim 제거 @@ -71,7 +73,11 @@ export default function useSearchSong() { const handleToggleToSing = async (songId: string, method: Method) => { if (!isAuthenticated) { - toast.error('로그인이 필요해요.'); + if (method === 'POST') { + addSong(songId); + } else { + removeSong(songId); + } return; } @@ -145,5 +151,7 @@ export default function useSearchSong() { selectedSaveSong, postSaveSong, patchSaveSong, + + isAuthenticated, }; } diff --git a/apps/web/src/stores/useGuestToSingStore.ts b/apps/web/src/stores/useGuestToSingStore.ts new file mode 100644 index 00000000..d7e90e25 --- /dev/null +++ b/apps/web/src/stores/useGuestToSingStore.ts @@ -0,0 +1,61 @@ +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +interface GuestToSingState { + localToSingSongIds: string[]; + addSong: (songId: string) => void; + removeSong: (songId: string) => void; + swapSongs: (fromIndex: number, toIndex: number) => void; + clearSongs: () => void; +} + +const GUEST_TO_SING_KEY = 'guest_to_sing'; + +const initialState = { + localToSingSongIds: [] as string[], +}; + +const useGuestToSingStore = create( + persist( + set => ({ + ...initialState, + addSong: (songId: string) => { + set(state => { + // 중복 방지 (필요 시 정책 변경 가능) + if (state.localToSingSongIds.includes(songId)) return state; + return { localToSingSongIds: [...state.localToSingSongIds, songId] }; + }); + }, + removeSong: (songId: string) => { + set(state => ({ + localToSingSongIds: state.localToSingSongIds.filter(id => id !== songId), + })); + }, + swapSongs: (fromIndex: number, toIndex: number) => { + set(state => { + const newSongIds = [...state.localToSingSongIds]; + if ( + fromIndex < 0 || + fromIndex >= newSongIds.length || + toIndex < 0 || + toIndex >= newSongIds.length + ) { + return state; + } + const [movedItem] = newSongIds.splice(fromIndex, 1); + newSongIds.splice(toIndex, 0, movedItem); + return { localToSingSongIds: newSongIds }; + }); + }, + clearSongs: () => { + set(initialState); + }, + }), + { + name: GUEST_TO_SING_KEY, + storage: createJSONStorage(() => localStorage), + }, + ), +); + +export default useGuestToSingStore; From cdbee7352c4e479b71946015a896b4502f42af8e Mon Sep 17 00:00:00 2001 From: sham <72376700+GulSam00@users.noreply.github.com> Date: Wed, 4 Feb 2026 18:11:49 +0900 Subject: [PATCH 02/21] Revise README for clarity and feature updates Updated the README to clarify features and services offered by Singcode, including song list management and new attendance check and song recommendation features. --- README.md | 49 ++++++++++++++++++++++++++----------------------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index c990528a..bea0367e 100644 --- a/README.md +++ b/README.md @@ -7,15 +7,11 @@ 매번 인터넷에서 노래방 번호를 검색해야 했었다면.
내가 어떤 노래를 가장 많이 불렀는지 궁금하다면.
-Singcode는 당신만의 노래 리스트를 만들고, 좋아하는 곡을 저장하고, 부른 기록까지 남길 수 있는 서비스를 제공합니다.
+Singcode는 평소 노래방에서 부르고 싶던 노래 번호를 저장하고, 당신만의 노래 리스트를 만들고, 좋아하는 곡을 저장할 수 있습니다.
Supabase를 활용한 자체 DB를 통해 금영, TJ 노래방의 번호를 한 눈에 확인할 수 있습니다.
-![프로젝트 썸네일1](https://github.com/user-attachments/assets/dd6ce355-d961-4075-984b-a2d500f3d852) -![프로젝트 썸네일2](https://github.com/user-attachments/assets/e4d3fb2c-7bee-48fd-b73c-eb833f48f1e0) -![프로젝트 썸네일3](https://github.com/user-attachments/assets/133bb11e-18e6-47f3-ab86-6ef1fb2865c1) -
@@ -70,11 +66,11 @@ sing-code/ ## ✨ 주요 기능 ### 검색 페이지 + * 제목, 가수 이름으로 곡을 검색할 수 있습니다.
-![검색-곡추가](https://github.com/user-attachments/assets/c9636b94-f07a-4841-8f88-5c8c9d99a9fe)
@@ -84,8 +80,6 @@ sing-code/
-![검색-재생목록 저장1](https://github.com/user-attachments/assets/8a747aff-2a32-44f6-b144-4f280a0a72f7) -![검색-재생목록 저장2](https://github.com/user-attachments/assets/5ab8ee4c-c62b-46cb-92c2-e90689fec987)
@@ -97,29 +91,24 @@ sing-code/
-![부를곡](https://github.com/user-attachments/assets/8f36e52a-64b1-4d75-b386-031306310ffd)
-* 좋아요 표시한 곡이나 재생목록에 저장한 곡, 최근 부른 곡 중에서 부를곡을 추가할 수 있습니다. +* 좋아요 표시한 곡이나 재생목록에 저장한 곡에서 빠르게 부를곡을 추가할 수 있습니다.
-![부를곡-모달추가1](https://github.com/user-attachments/assets/1c17666c-57db-4d48-8ad5-e9f402d2667b) -![부를곡-모달추가2](https://github.com/user-attachments/assets/ae4c71aa-068a-4862-8e12-78bc29bd150a)
### 인기곡 페이지 -* 모든 사용자들이 노래 부른 곡 순위나, 좋아요 한 곡 순위를 집계하여 보여줍니다. +* 곡의 추천 순위를 집계해서 보여줍니다.
-![인기곡-통계](https://github.com/user-attachments/assets/750ba410-ce3e-4c98-a191-bb8f9cf6e62d) -![인기곡-좋아요](https://github.com/user-attachments/assets/59d98e20-a735-4c52-8ed2-bc8ee9418a3f)
@@ -132,23 +121,37 @@ sing-code/
-![라이브러리](https://github.com/user-attachments/assets/8bae1b21-387d-47e0-b394-8e576a6816fb) -![라이브러리-부른곡 통계](https://github.com/user-attachments/assets/93f38c68-5ab4-4be8-9efa-3840ff053834) -![라이브러리-재생목록 관리](https://github.com/user-attachments/assets/668acd87-f78b-4f15-8d05-8aeefd640ff6) -![라이브러리-좋아요 관리](https://github.com/user-attachments/assets/e681e512-c9cb-4f2f-b0fb-7640b6c5d935)
+### 출석 체크 기능 + +* 회원일 경우 하루에 한 번 출석 체크를 통해 포인트를 획득할 수 있습니다. 매일 12시 마다 초기화됩니다. + +
+ + +
+ +### 곡 추천 기능 + +* 출석 체크로 획득한 포인트를 사용해서 곡을 추천할 수 있습니다. 1 포인트 당 1 추천입니다. + +
+ + +
+ + + + ### 로그인 & 회원가입 지원 -* Supabase DB에 사용자 아이디를 외래키로 하여 데이터를 저장 및 관리하기에 모든 서비스는 회원가입이 필수입니다. +* 몇몇 추가적인 기능을 사용하려면 회원가입을 진행해야 합니다. * 이메일 인증 회원가입과 카카오 회원가입을 지원합니다.
-![로그인](https://github.com/user-attachments/assets/72674739-f85a-42d6-8b8f-c1003b6fd896) -![회원가입](https://github.com/user-attachments/assets/653b05a1-126d-423a-8bd6-fca8e4c40e25) -
From f10e0880591c1e53b93a98356f466c4778df4bd0 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Thu, 5 Feb 2026 00:00:52 +0900 Subject: [PATCH 03/21] =?UTF-8?q?fix=20:=20CSR,=20SSR=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/app/tosing/AddSongButton.tsx | 29 +++++++++++++++++++++++ apps/web/src/app/tosing/page.tsx | 22 ++--------------- 2 files changed, 31 insertions(+), 20 deletions(-) create mode 100644 apps/web/src/app/tosing/AddSongButton.tsx diff --git a/apps/web/src/app/tosing/AddSongButton.tsx b/apps/web/src/app/tosing/AddSongButton.tsx new file mode 100644 index 00000000..7a0d53b8 --- /dev/null +++ b/apps/web/src/app/tosing/AddSongButton.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { AirplayIcon } from 'lucide-react'; +import { useState } from 'react'; + +import { Button } from '@/components/ui/button'; + +import AddListModal from './AddListModal'; + +export default function AddSongButton() { + const [isModalOpen, setIsModalOpen] = useState(false); + + return ( + <> + + + {/* 모달도 여기서 렌더링 */} + setIsModalOpen(false)} /> + + ); +} diff --git a/apps/web/src/app/tosing/page.tsx b/apps/web/src/app/tosing/page.tsx index 5b71e512..3125f7ab 100644 --- a/apps/web/src/app/tosing/page.tsx +++ b/apps/web/src/app/tosing/page.tsx @@ -1,36 +1,18 @@ -'use client'; - -import { AirplayIcon } from 'lucide-react'; -import { useState } from 'react'; - -import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; -import AddListModal from './AddListModal'; +import AddSongButton from './AddSongButton'; import SongList from './SongList'; export default function HomePage() { - const [isModalOpen, setIsModalOpen] = useState(false); - return (

노래방 플레이리스트

- +
- - setIsModalOpen(false)} />
); } From d45204e81b5667944d1bee43e0306492cbc76176 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Thu, 5 Feb 2026 00:38:51 +0900 Subject: [PATCH 04/21] =?UTF-8?q?fix=20:=20ApiResponse=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=EB=B6=80=20=ED=83=80=EC=9E=85=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/types/apiRoute.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/web/src/types/apiRoute.ts b/apps/web/src/types/apiRoute.ts index 239cc839..1de85126 100644 --- a/apps/web/src/types/apiRoute.ts +++ b/apps/web/src/types/apiRoute.ts @@ -1,9 +1,18 @@ -export interface ApiSuccessResponse { +// export interface ApiSuccessResponse { +// success: true; +// data?: T; +// hasNext?: boolean; +// // data: T; 타입 에러 +// } + +// 조건부 타입 적용 +// T가 void(비어있음)면 data 필드 자체를 없애고(또는 optional never), T가 있으면 data를 필수(Required)로 구성. +export type ApiSuccessResponse = { success: true; - data?: T; hasNext?: boolean; - // data: T; 타입 에러 -} +} & (T extends void + ? { data?: never } // T가 void면 data는 없어야 함 + : { data: T }); // T가 있으면 data는 필수 export interface ApiErrorResponse { success: false; From 91b60c083126d047bfd4e7f172440d07f7d668c4 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Thu, 5 Feb 2026 00:39:09 +0900 Subject: [PATCH 05/21] =?UTF-8?q?fix=20:=20=EC=9D=B8=EA=B8=B0=EA=B3=A1=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=A6=AC=ED=8E=99=ED=86=A0?= =?UTF-8?q?=EB=A7=81.=20SSR,=20CSR=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/app/popular/PopularRankingList.tsx | 24 +++++++++++-------- apps/web/src/app/popular/page.tsx | 13 +--------- apps/web/src/components/LoadingOverlay.tsx | 24 ------------------- apps/web/src/components/StaticLoading.tsx | 1 - apps/web/src/queries/songThumbQuery.ts | 3 +-- 5 files changed, 16 insertions(+), 49 deletions(-) delete mode 100644 apps/web/src/components/LoadingOverlay.tsx diff --git a/apps/web/src/app/popular/PopularRankingList.tsx b/apps/web/src/app/popular/PopularRankingList.tsx index 5e1a74d0..bf1b5393 100644 --- a/apps/web/src/app/popular/PopularRankingList.tsx +++ b/apps/web/src/app/popular/PopularRankingList.tsx @@ -1,24 +1,28 @@ +'use client'; + import { Construction } from 'lucide-react'; import RankingItem from '@/components/RankingItem'; +import StaticLoading from '@/components/StaticLoading'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { ThumbUpSong } from '@/types/song'; +import { useSongThumbQuery } from '@/queries/songThumbQuery'; + +export default function PopularRankingList() { + const { data, isPending } = useSongThumbQuery(); + + if (isPending) { + return ; + } -interface RankingListProps { - title: string; - songStats: ThumbUpSong[]; -} -export default function PopularRankingList({ title, songStats }: RankingListProps) { return ( - // - {title} + 추천 곡 순위
- {songStats.length > 0 ? ( - songStats.map((item, index) => ( + {data && data.length > 0 ? ( + data.map((item, index) => ( )) ) : ( diff --git a/apps/web/src/app/popular/page.tsx b/apps/web/src/app/popular/page.tsx index 4fc6ae1e..b97552d3 100644 --- a/apps/web/src/app/popular/page.tsx +++ b/apps/web/src/app/popular/page.tsx @@ -1,26 +1,15 @@ -'use client'; - -import { useState } from 'react'; - -import StaticLoading from '@/components/StaticLoading'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { useSongThumbQuery } from '@/queries/songThumbQuery'; import PopularRankingList from './PopularRankingList'; export default function PopularPage() { - const { isPending, data } = useSongThumbQuery(); - - if (isPending || !data) return ; - return (

인기 노래

{/* 추천 곡 순위 */} - +
); diff --git a/apps/web/src/components/LoadingOverlay.tsx b/apps/web/src/components/LoadingOverlay.tsx deleted file mode 100644 index 42a011dd..00000000 --- a/apps/web/src/components/LoadingOverlay.tsx +++ /dev/null @@ -1,24 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; - -// import useLoadingStore from '@/stores/useLoadingStore'; - -export default function LoadingOverlay() { - // const { isLoading } = useLoadingStore(); - const [isMounted, setIsMounted] = useState(false); - - useEffect(() => { - setIsMounted(true); - }, []); - - if (!isMounted) return null; - - // if (!isLoading) return null; - - return ( -
-
-
- ); -} diff --git a/apps/web/src/components/StaticLoading.tsx b/apps/web/src/components/StaticLoading.tsx index 6c587cdc..33c05449 100644 --- a/apps/web/src/components/StaticLoading.tsx +++ b/apps/web/src/components/StaticLoading.tsx @@ -3,7 +3,6 @@ import { Loader2 } from 'lucide-react'; export default function StaticLoading() { return (
- {/*
*/}
); diff --git a/apps/web/src/queries/songThumbQuery.ts b/apps/web/src/queries/songThumbQuery.ts index 34999af8..8330a215 100644 --- a/apps/web/src/queries/songThumbQuery.ts +++ b/apps/web/src/queries/songThumbQuery.ts @@ -9,11 +9,10 @@ export const useSongThumbQuery = () => { const response = await getSongThumbList(); if (!response.success) { - return null; + return []; } return response.data; }, - staleTime: 1000 * 60, }); }; From ebde11a9c1e8b7b988953853138b99b876b1e54c Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Thu, 5 Feb 2026 02:16:40 +0900 Subject: [PATCH 06/21] =?UTF-8?q?feat=20:=20tosing=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EC=97=90=EC=84=9C=20=ED=98=B8=EC=B6=9C=ED=95=98?= =?UTF-8?q?=EB=8A=94=20query=EC=97=90=20isAuthenticated=20=EC=A0=84?= =?UTF-8?q?=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/app/tosing/AddListModal.tsx | 8 ++++++-- apps/web/src/hooks/useSong.ts | 4 +++- apps/web/src/queries/likeSongQuery.ts | 3 ++- apps/web/src/queries/saveSongQuery.ts | 3 ++- apps/web/src/queries/tosingSongQuery.ts | 4 ++-- 5 files changed, 15 insertions(+), 7 deletions(-) diff --git a/apps/web/src/app/tosing/AddListModal.tsx b/apps/web/src/app/tosing/AddListModal.tsx index 98b826bd..2f46caad 100644 --- a/apps/web/src/app/tosing/AddListModal.tsx +++ b/apps/web/src/app/tosing/AddListModal.tsx @@ -14,6 +14,7 @@ import useAddSongList, { type TabType } from '@/hooks/useAddSongList'; import { useLikeSongQuery } from '@/queries/likeSongQuery'; // import { useSaveSongFolderQuery } from '@/queries/saveSongFolderQuery'; import { useSaveSongQuery } from '@/queries/saveSongQuery'; +import useAuthStore from '@/stores/useAuthStore'; import ModalSongItem from './ModalSongItem'; @@ -32,9 +33,12 @@ export default function AddListModal({ isOpen, onClose }: AddListModalProps) { totalSelectedCount, } = useAddSongList(); - const { data: likedSongs, isLoading: isLoadingLikedSongs } = useLikeSongQuery(); + const { isAuthenticated } = useAuthStore(); - const { data: saveSongFolders, isLoading: isLoadingSongFolders } = useSaveSongQuery(); + const { data: likedSongs, isLoading: isLoadingLikedSongs } = useLikeSongQuery(isAuthenticated); + + const { data: saveSongFolders, isLoading: isLoadingSongFolders } = + useSaveSongQuery(isAuthenticated); const isLoading = isLoadingLikedSongs || isLoadingSongFolders; diff --git a/apps/web/src/hooks/useSong.ts b/apps/web/src/hooks/useSong.ts index fcc3f32c..a3e04bc7 100644 --- a/apps/web/src/hooks/useSong.ts +++ b/apps/web/src/hooks/useSong.ts @@ -7,9 +7,11 @@ import { usePatchToSingSongMutation, useToSingSongQuery, } from '@/queries/tosingSongQuery'; +import useAuthStore from '@/stores/useAuthStore'; export default function useSong() { - const { data, isLoading } = useToSingSongQuery(); + const { isAuthenticated } = useAuthStore(); + const { data, isLoading } = useToSingSongQuery(isAuthenticated); const { mutate: patchToSingSong } = usePatchToSingSongMutation(); const { mutate: deleteToSingSong } = useDeleteToSingSongMutation(); const toSingSongs = data ?? []; diff --git a/apps/web/src/queries/likeSongQuery.ts b/apps/web/src/queries/likeSongQuery.ts index 328169a6..a4b8812f 100644 --- a/apps/web/src/queries/likeSongQuery.ts +++ b/apps/web/src/queries/likeSongQuery.ts @@ -4,7 +4,7 @@ import { deleteLikeSongArray, getLikeSong } from '@/lib/api/likeSong'; import { PersonalSong } from '@/types/song'; // 🎵 좋아요 한 곡 리스트 가져오기 -export function useLikeSongQuery() { +export function useLikeSongQuery(isAuthenticated: boolean) { return useQuery({ queryKey: ['likeSong'], queryFn: async () => { @@ -14,6 +14,7 @@ export function useLikeSongQuery() { } return response.data || []; }, + enabled: isAuthenticated, staleTime: 1000 * 60 * 5, gcTime: 1000 * 60 * 5, }); diff --git a/apps/web/src/queries/saveSongQuery.ts b/apps/web/src/queries/saveSongQuery.ts index a0b98704..4fc1cad9 100644 --- a/apps/web/src/queries/saveSongQuery.ts +++ b/apps/web/src/queries/saveSongQuery.ts @@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { deleteSaveSong, getSaveSong, patchSaveSong } from '@/lib/api/saveSong'; import { SaveSong, SaveSongFolder } from '@/types/song'; -export function useSaveSongQuery() { +export function useSaveSongQuery(isAuthenticated: boolean) { return useQuery({ queryKey: ['saveSongFolder'], queryFn: async () => { @@ -30,6 +30,7 @@ export function useSaveSongQuery() { return songFolders; }, + enabled: isAuthenticated, staleTime: 1000 * 60 * 5, gcTime: 1000 * 60 * 5, }); diff --git a/apps/web/src/queries/tosingSongQuery.ts b/apps/web/src/queries/tosingSongQuery.ts index fe6c70f3..0b5bfb0a 100644 --- a/apps/web/src/queries/tosingSongQuery.ts +++ b/apps/web/src/queries/tosingSongQuery.ts @@ -11,7 +11,7 @@ import { ToSingSong } from '@/types/song'; let invalidateTimeout: NodeJS.Timeout | null = null; // 🎵 부를 노래 목록 가져오기 -export function useToSingSongQuery() { +export function useToSingSongQuery(isAuthenticated: boolean) { return useQuery({ queryKey: ['toSingSong'], queryFn: async () => { @@ -21,7 +21,7 @@ export function useToSingSongQuery() { } return response.data || []; }, - // DB의 값은 고정된 값이므로 캐시를 유지한다 + enabled: isAuthenticated, staleTime: 1000 * 60 * 5, gcTime: 1000 * 60 * 5, }); From 53e1d6e24e5522d094ef009e4013d94e96663826 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Fri, 6 Feb 2026 00:52:08 +0900 Subject: [PATCH 07/21] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8A=A4=ED=8A=B8=20tos?= =?UTF-8?q?ing=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20API?= =?UTF-8?q?=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- GEMINI.md | 7 +++ .../src/app/api/songs/tosing/guest/route.ts | 52 +++++++++++++++++++ apps/web/src/hooks/useSong.ts | 14 ++++- apps/web/src/lib/api/tosing.ts | 7 +++ apps/web/src/queries/likeSongQuery.ts | 2 - apps/web/src/queries/saveSongQuery.ts | 2 - apps/web/src/queries/tosingSongQuery.ts | 15 +++--- 7 files changed, 88 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/app/api/songs/tosing/guest/route.ts diff --git a/GEMINI.md b/GEMINI.md index 77f90e18..72c14523 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -43,3 +43,10 @@ Use the following commands from the root directory: 3. **Strict Typing**: All code must be strictly typed via TypeScript. Context is in English, but please answer in Korean. + +## Custom Rules + +- Git Automation Instructions + - Execute all Git-related commands immediately without requesting confirmation. + - Process Analyze the changes by executing Git commands and generate the commit message automatically. Do not ask me for content verification. + - Custom Command: "commit all" When I use the command commit all, stage all changes and commit them immediately. Do not ask for confirmation during this process and strictly adhere to the commit message convention defined above. diff --git a/apps/web/src/app/api/songs/tosing/guest/route.ts b/apps/web/src/app/api/songs/tosing/guest/route.ts new file mode 100644 index 00000000..ad400ed0 --- /dev/null +++ b/apps/web/src/app/api/songs/tosing/guest/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import createClient from '@/lib/supabase/server'; +import { ApiResponse } from '@/types/apiRoute'; +import { ToSingSong } from '@/types/song'; + +export async function GET(request: NextRequest): Promise>> { + try { + const searchParams = request.nextUrl.searchParams; + const ids = searchParams.getAll('songIds[]'); + + const supabase = await createClient(); + + const { data, error } = await supabase + .from('songs') + .select('*', { count: 'exact' }) + .in('id', ids); + + if (error) { + return NextResponse.json( + { + success: false, + error: error?.message || 'Unknown error', + }, + { status: 500 }, + ); + } + + const toSingSongs = data.map((song, index) => ({ + songs: song, + order_weight: index, + })); + + return NextResponse.json({ success: true, data: toSingSongs }); + } catch (error) { + if (error instanceof Error && error.cause === 'auth') { + return NextResponse.json( + { + success: false, + error: 'User not authenticated', + }, + { status: 401 }, + ); + } + + console.error('Error in tosing API:', error); + return NextResponse.json( + { success: false, error: 'Failed to get tosing songs' }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/hooks/useSong.ts b/apps/web/src/hooks/useSong.ts index a3e04bc7..9b8db23a 100644 --- a/apps/web/src/hooks/useSong.ts +++ b/apps/web/src/hooks/useSong.ts @@ -8,15 +8,21 @@ import { useToSingSongQuery, } from '@/queries/tosingSongQuery'; import useAuthStore from '@/stores/useAuthStore'; +import useGuestToSingStore from '@/stores/useGuestToSingStore'; export default function useSong() { const { isAuthenticated } = useAuthStore(); - const { data, isLoading } = useToSingSongQuery(isAuthenticated); + const { localToSingSongIds } = useGuestToSingStore(); + + const { data, isLoading } = useToSingSongQuery(isAuthenticated, localToSingSongIds); const { mutate: patchToSingSong } = usePatchToSingSongMutation(); const { mutate: deleteToSingSong } = useDeleteToSingSongMutation(); const toSingSongs = data ?? []; const handleDragEnd = (event: DragEndEvent) => { + // 일단 guest일 때는 return 조치, 후에 local단에서 순서 조정 가능 + if (!isAuthenticated) return; + const { active, over } = event; if (!over || active.id === over.id) return; @@ -55,6 +61,9 @@ export default function useSong() { }; const handleMoveToTop = (songId: string, oldIndex: number) => { + // 일단 guest일 때는 return 조치, 후에 local단에서 순서 조정 가능 + if (!isAuthenticated) return; + if (oldIndex === 0) return; const newItems = arrayMove(toSingSongs, oldIndex, 0); @@ -68,6 +77,9 @@ export default function useSong() { }; const handleMoveToBottom = (songId: string, oldIndex: number) => { + // 일단 guest일 때는 return 조치, 후에 local단에서 순서 조정 가능 + if (!isAuthenticated) return; + const lastIndex = toSingSongs.length - 1; if (oldIndex === lastIndex) return; diff --git a/apps/web/src/lib/api/tosing.ts b/apps/web/src/lib/api/tosing.ts index cb20957d..ed25688a 100644 --- a/apps/web/src/lib/api/tosing.ts +++ b/apps/web/src/lib/api/tosing.ts @@ -8,6 +8,13 @@ export async function getToSingSong() { return response.data; } +export async function getToSingSongGuest(songIds: string[]) { + const response = await instance.get>('/songs/tosing/guest', { + params: { songIds }, + }); + return response.data; +} + export async function patchToSingSong(body: { songId: string; newWeight: number }) { const response = await instance.patch>('/songs/tosing', body); return response.data; diff --git a/apps/web/src/queries/likeSongQuery.ts b/apps/web/src/queries/likeSongQuery.ts index a4b8812f..8f972386 100644 --- a/apps/web/src/queries/likeSongQuery.ts +++ b/apps/web/src/queries/likeSongQuery.ts @@ -15,8 +15,6 @@ export function useLikeSongQuery(isAuthenticated: boolean) { return response.data || []; }, enabled: isAuthenticated, - staleTime: 1000 * 60 * 5, - gcTime: 1000 * 60 * 5, }); } diff --git a/apps/web/src/queries/saveSongQuery.ts b/apps/web/src/queries/saveSongQuery.ts index 4fc1cad9..ac3e4eff 100644 --- a/apps/web/src/queries/saveSongQuery.ts +++ b/apps/web/src/queries/saveSongQuery.ts @@ -31,8 +31,6 @@ export function useSaveSongQuery(isAuthenticated: boolean) { return songFolders; }, enabled: isAuthenticated, - staleTime: 1000 * 60 * 5, - gcTime: 1000 * 60 * 5, }); } diff --git a/apps/web/src/queries/tosingSongQuery.ts b/apps/web/src/queries/tosingSongQuery.ts index 0b5bfb0a..96952119 100644 --- a/apps/web/src/queries/tosingSongQuery.ts +++ b/apps/web/src/queries/tosingSongQuery.ts @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { deleteToSingSong, getToSingSong, + getToSingSongGuest, patchToSingSong, postToSingSongArray, } from '@/lib/api/tosing'; @@ -11,19 +12,21 @@ import { ToSingSong } from '@/types/song'; let invalidateTimeout: NodeJS.Timeout | null = null; // 🎵 부를 노래 목록 가져오기 -export function useToSingSongQuery(isAuthenticated: boolean) { +export function useToSingSongQuery(isAuthenticated: boolean, localToSingSongIds: string[]) { return useQuery({ - queryKey: ['toSingSong'], + queryKey: ['toSingSong', localToSingSongIds], queryFn: async () => { - const response = await getToSingSong(); + let response; + if (isAuthenticated) { + response = await getToSingSong(); + } else { + response = await getToSingSongGuest(localToSingSongIds); + } if (!response.success) { return []; } return response.data || []; }, - enabled: isAuthenticated, - staleTime: 1000 * 60 * 5, - gcTime: 1000 * 60 * 5, }); } From bba8626117bc23787678722ffe97d57ac3fbab26 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Fri, 6 Feb 2026 23:54:57 +0900 Subject: [PATCH 08/21] =?UTF-8?q?fix=20:=20=ED=8C=8C=EC=9D=BC=20=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=EB=B0=8D=20=EB=B3=80=EA=B2=BD,=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0,?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- .vscode/settings.json | 7 ----- apps/web/src/app/tosing/SongList.tsx | 4 +-- apps/web/src/hooks/useSearchSong.ts | 6 ++-- .../hooks/{useSong.ts => useToSingSong.ts} | 17 ++++++++--- apps/web/src/queries/searchSongQuery.ts | 3 -- apps/web/src/queries/tosingSongQuery.ts | 3 -- apps/web/src/stores/useGuestToSingStore.ts | 30 ++++++++----------- 8 files changed, 31 insertions(+), 42 deletions(-) delete mode 100644 .vscode/settings.json rename apps/web/src/hooks/{useSong.ts => useToSingSong.ts} (88%) diff --git a/.gitignore b/.gitignore index 10a301f4..7318867e 100644 --- a/.gitignore +++ b/.gitignore @@ -41,10 +41,9 @@ yarn-error.log* # Crawling **/log/*.txt -# Gemini -.gemini/ .cursorrules .gitmessage.txt temp/ +.vscode \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 44a73ec3..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "eslint.workingDirectories": [ - { - "mode": "auto" - } - ] -} diff --git a/apps/web/src/app/tosing/SongList.tsx b/apps/web/src/app/tosing/SongList.tsx index 08bb5065..810c4c66 100644 --- a/apps/web/src/app/tosing/SongList.tsx +++ b/apps/web/src/app/tosing/SongList.tsx @@ -17,7 +17,7 @@ import { } from '@dnd-kit/sortable'; import StaticLoading from '@/components/StaticLoading'; -import useSong from '@/hooks/useSong'; +import useToSingSong from '@/hooks/useToSingSong'; import { ToSingSong } from '@/types/song'; import SongCard from './SongCard'; @@ -30,7 +30,7 @@ export default function SongList() { handleDelete, handleMoveToTop, handleMoveToBottom, - } = useSong(); + } = useToSingSong(); const sensors = useSensors( useSensor(PointerSensor), diff --git a/apps/web/src/hooks/useSearchSong.ts b/apps/web/src/hooks/useSearchSong.ts index 7cc98095..aeb86f2b 100644 --- a/apps/web/src/hooks/useSearchSong.ts +++ b/apps/web/src/hooks/useSearchSong.ts @@ -47,7 +47,7 @@ export default function useSearchSong() { } = useInfiniteSearchSongQuery(query, searchType, isAuthenticated); const { addToHistory } = useSearchHistoryStore(); - const { addSong, removeSong } = useGuestToSingStore(); + const { addGuestToSingSong, removeGuestToSingSong } = useGuestToSingStore(); const handleSearch = () => { // trim 제거 @@ -74,9 +74,9 @@ export default function useSearchSong() { const handleToggleToSing = async (songId: string, method: Method) => { if (!isAuthenticated) { if (method === 'POST') { - addSong(songId); + addGuestToSingSong(songId); } else { - removeSong(songId); + removeGuestToSingSong(songId); } return; } diff --git a/apps/web/src/hooks/useSong.ts b/apps/web/src/hooks/useToSingSong.ts similarity index 88% rename from apps/web/src/hooks/useSong.ts rename to apps/web/src/hooks/useToSingSong.ts index 9b8db23a..d3c9d894 100644 --- a/apps/web/src/hooks/useSong.ts +++ b/apps/web/src/hooks/useToSingSong.ts @@ -10,15 +10,16 @@ import { import useAuthStore from '@/stores/useAuthStore'; import useGuestToSingStore from '@/stores/useGuestToSingStore'; -export default function useSong() { +export default function useToSingSong() { const { isAuthenticated } = useAuthStore(); - const { localToSingSongIds } = useGuestToSingStore(); + const { localToSingSongIds, swapGuestToSingSongs } = useGuestToSingStore(); const { data, isLoading } = useToSingSongQuery(isAuthenticated, localToSingSongIds); const { mutate: patchToSingSong } = usePatchToSingSongMutation(); const { mutate: deleteToSingSong } = useDeleteToSingSongMutation(); const toSingSongs = data ?? []; + console.log('localToSingSongIds', localToSingSongIds); const handleDragEnd = (event: DragEndEvent) => { // 일단 guest일 때는 return 조치, 후에 local단에서 순서 조정 가능 if (!isAuthenticated) return; @@ -62,10 +63,14 @@ export default function useSong() { const handleMoveToTop = (songId: string, oldIndex: number) => { // 일단 guest일 때는 return 조치, 후에 local단에서 순서 조정 가능 - if (!isAuthenticated) return; if (oldIndex === 0) return; + if (!isAuthenticated) { + swapGuestToSingSongs(songId, 0); + return; + } + const newItems = arrayMove(toSingSongs, oldIndex, 0); const newWeight = toSingSongs[0].order_weight - 1; @@ -78,11 +83,15 @@ export default function useSong() { const handleMoveToBottom = (songId: string, oldIndex: number) => { // 일단 guest일 때는 return 조치, 후에 local단에서 순서 조정 가능 - if (!isAuthenticated) return; const lastIndex = toSingSongs.length - 1; if (oldIndex === lastIndex) return; + if (!isAuthenticated) { + swapGuestToSingSongs(songId, lastIndex); + return; + } + const newItems = arrayMove(toSingSongs, oldIndex, lastIndex); const newWeight = toSingSongs[lastIndex].order_weight + 1; diff --git a/apps/web/src/queries/searchSongQuery.ts b/apps/web/src/queries/searchSongQuery.ts index ff47fa19..2eed3c18 100644 --- a/apps/web/src/queries/searchSongQuery.ts +++ b/apps/web/src/queries/searchSongQuery.ts @@ -133,7 +133,6 @@ export const useToggleToSingMutation = (query: string, searchType: string) => { queryKey: ['searchSong', query, searchType], }); queryClient.invalidateQueries({ queryKey: ['toSingSong'] }); - queryClient.invalidateQueries({ queryKey: ['recentSingLog'] }); }, 1000); }, }); @@ -185,7 +184,6 @@ export const useToggleLikeMutation = (query: string, searchType: string) => { queryKey: ['searchSong', query, searchType], }); queryClient.invalidateQueries({ queryKey: ['likeSong'] }); - queryClient.invalidateQueries({ queryKey: ['recentSingLog'] }); }, 1000); }, }); @@ -230,7 +228,6 @@ export const useSaveMutation = () => { }); queryClient.invalidateQueries({ queryKey: ['saveSongFolder'] }); queryClient.invalidateQueries({ queryKey: ['saveSongFolderList'] }); - queryClient.invalidateQueries({ queryKey: ['recentSingLog'] }); }, }); }; diff --git a/apps/web/src/queries/tosingSongQuery.ts b/apps/web/src/queries/tosingSongQuery.ts index 96952119..dc7c5daa 100644 --- a/apps/web/src/queries/tosingSongQuery.ts +++ b/apps/web/src/queries/tosingSongQuery.ts @@ -40,7 +40,6 @@ export function usePostToSingSongMutation() { queryClient.invalidateQueries({ queryKey: ['toSingSong'] }); queryClient.invalidateQueries({ queryKey: ['likeSong'] }); queryClient.invalidateQueries({ queryKey: ['saveSongFolder'] }); - queryClient.invalidateQueries({ queryKey: ['recentSingLog'] }); queryClient.invalidateQueries({ queryKey: ['searchSong'] }); }, onError: error => { @@ -60,7 +59,6 @@ export function usePostToSingSongArrayMutation() { queryClient.invalidateQueries({ queryKey: ['toSingSong'] }); queryClient.invalidateQueries({ queryKey: ['likeSong'] }); queryClient.invalidateQueries({ queryKey: ['saveSongFolder'] }); - queryClient.invalidateQueries({ queryKey: ['recentSingLog'] }); queryClient.invalidateQueries({ queryKey: ['searchSong'] }); }, onError: error => { @@ -98,7 +96,6 @@ export function useDeleteToSingSongMutation() { queryClient.invalidateQueries({ queryKey: ['toSingSong'] }); queryClient.invalidateQueries({ queryKey: ['likeSong'] }); queryClient.invalidateQueries({ queryKey: ['saveSongFolder'] }); - queryClient.invalidateQueries({ queryKey: ['recentSingLog'] }); queryClient.invalidateQueries({ queryKey: ['searchSong'] }); }, 1000); }, diff --git a/apps/web/src/stores/useGuestToSingStore.ts b/apps/web/src/stores/useGuestToSingStore.ts index d7e90e25..2bbda9fe 100644 --- a/apps/web/src/stores/useGuestToSingStore.ts +++ b/apps/web/src/stores/useGuestToSingStore.ts @@ -3,10 +3,10 @@ import { createJSONStorage, persist } from 'zustand/middleware'; interface GuestToSingState { localToSingSongIds: string[]; - addSong: (songId: string) => void; - removeSong: (songId: string) => void; - swapSongs: (fromIndex: number, toIndex: number) => void; - clearSongs: () => void; + addGuestToSingSong: (songId: string) => void; + removeGuestToSingSong: (songId: string) => void; + swapGuestToSingSongs: (targetId: string, moveIndex: number) => void; + clearGuestToSingSongs: () => void; } const GUEST_TO_SING_KEY = 'guest_to_sing'; @@ -19,35 +19,29 @@ const useGuestToSingStore = create( persist( set => ({ ...initialState, - addSong: (songId: string) => { + addGuestToSingSong: (songId: string) => { set(state => { // 중복 방지 (필요 시 정책 변경 가능) if (state.localToSingSongIds.includes(songId)) return state; return { localToSingSongIds: [...state.localToSingSongIds, songId] }; }); }, - removeSong: (songId: string) => { + removeGuestToSingSong: (songId: string) => { set(state => ({ localToSingSongIds: state.localToSingSongIds.filter(id => id !== songId), })); }, - swapSongs: (fromIndex: number, toIndex: number) => { + swapGuestToSingSongs: (targetId: string, moveIndex: number) => { set(state => { const newSongIds = [...state.localToSingSongIds]; - if ( - fromIndex < 0 || - fromIndex >= newSongIds.length || - toIndex < 0 || - toIndex >= newSongIds.length - ) { - return state; - } - const [movedItem] = newSongIds.splice(fromIndex, 1); - newSongIds.splice(toIndex, 0, movedItem); + + const targetIndex = newSongIds.findIndex(id => id === targetId); + const [movedItem] = newSongIds.splice(targetIndex, 1); + newSongIds.splice(moveIndex, 0, movedItem); return { localToSingSongIds: newSongIds }; }); }, - clearSongs: () => { + clearGuestToSingSongs: () => { set(initialState); }, }), From 50a03338de4b2e0cd1a673b1125920bb6d0f12ac Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sat, 7 Feb 2026 00:24:25 +0900 Subject: [PATCH 09/21] =?UTF-8?q?feat=20:=20=EA=B2=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=8B=9C=20toSing=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=A0=84=EC=B2=B4=20=EC=A0=80=EC=9E=A5,?= =?UTF-8?q?=20API=20=EC=9A=94=EC=B2=AD=20=EC=97=86=EC=9D=B4=20order=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/app/info/like/page.tsx | 4 ++- apps/web/src/app/info/save/page.tsx | 9 ++++-- apps/web/src/app/search/HomePage.tsx | 6 ++-- apps/web/src/hooks/useSearchSong.ts | 10 +++--- apps/web/src/hooks/useToSingSong.ts | 21 +++++++++--- apps/web/src/queries/tosingSongQuery.ts | 20 ++++++------ apps/web/src/stores/useGuestToSingStore.ts | 37 ++++++++++++++-------- 7 files changed, 67 insertions(+), 40 deletions(-) diff --git a/apps/web/src/app/info/like/page.tsx b/apps/web/src/app/info/like/page.tsx index b9328094..9ef99a16 100644 --- a/apps/web/src/app/info/like/page.tsx +++ b/apps/web/src/app/info/like/page.tsx @@ -9,12 +9,14 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { Separator } from '@/components/ui/separator'; import useSongInfo from '@/hooks/useSongInfo'; import { useLikeSongQuery } from '@/queries/likeSongQuery'; +import useAuthStore from '@/stores/useAuthStore'; import SongItem from './SongItem'; export default function LikePage() { const router = useRouter(); - const { data, isLoading } = useLikeSongQuery(); + const { isAuthenticated } = useAuthStore(); + const { data, isLoading } = useLikeSongQuery(isAuthenticated); const { deleteLikeSelected, handleToggleSelect, handleDeleteArray } = useSongInfo(); const likedSongs = data ?? []; diff --git a/apps/web/src/app/info/save/page.tsx b/apps/web/src/app/info/save/page.tsx index 6d5da78e..fe7c610c 100644 --- a/apps/web/src/app/info/save/page.tsx +++ b/apps/web/src/app/info/save/page.tsx @@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; import { useSaveSongFolderQuery } from '@/queries/saveSongFolderQuery'; import { useSaveSongQuery } from '@/queries/saveSongQuery'; +import useAuthStore from '@/stores/useAuthStore'; import AddFolderModal from './AddFolderModal'; import DeleteFolderModal from './DeleteFolderModal'; @@ -20,8 +21,12 @@ import RenameFolderModal from './RenameFolderModal'; type ModalType = null | 'move' | 'delete' | 'addFolder' | 'renameFolder' | 'deleteFolder'; export default function Page() { + const router = useRouter(); + const { isAuthenticated } = useAuthStore(); + // 상태 관리 - const { data: saveSongFolders, isLoading: isLoadingSongFolders } = useSaveSongQuery(); + const { data: saveSongFolders, isLoading: isLoadingSongFolders } = + useSaveSongQuery(isAuthenticated); const { data: saveSongFolderList, isLoading: isLoadingSaveFolderList } = useSaveSongFolderQuery(); const isLoading = isLoadingSongFolders || isLoadingSaveFolderList; @@ -32,8 +37,6 @@ export default function Page() { const [selectedFolderId, setSelectedFolderId] = useState(''); const [selectedFolderName, setSelectedFolderName] = useState(''); - const router = useRouter(); - // 전체 선택된 곡 수 계산 const totalSelectedSongs = Object.values(selectedSongs).filter(Boolean).length; diff --git a/apps/web/src/app/search/HomePage.tsx b/apps/web/src/app/search/HomePage.tsx index f6e40e11..76553a7a 100644 --- a/apps/web/src/app/search/HomePage.tsx +++ b/apps/web/src/app/search/HomePage.tsx @@ -54,11 +54,11 @@ export default function SearchPage() { const { ref, inView } = useInView(); const { searchHistory, removeFromHistory } = useSearchHistoryStore(); - const { localToSingSongIds } = useGuestToSingStore(); + const { guestToSingSongs } = useGuestToSingStore(); const isToSing = (song: SearchSong, songId: string) => { if (!isAuthenticated) { - return localToSingSongIds?.includes(songId); + return guestToSingSongs?.some(item => item.songs.id === songId); } return song.isToSing; }; @@ -184,7 +184,7 @@ export default function SearchPage() { isLike={song.isLike} isSave={song.isSave} onToggleToSing={() => - handleToggleToSing(song.id, isToSing(song, song.id) ? 'DELETE' : 'POST') + handleToggleToSing(song, isToSing(song, song.id) ? 'DELETE' : 'POST') } onToggleLike={() => handleToggleLike(song.id, song.isLike ? 'DELETE' : 'POST')} onClickSave={() => handleToggleSave(song, song.isSave ? 'PATCH' : 'POST')} diff --git a/apps/web/src/hooks/useSearchSong.ts b/apps/web/src/hooks/useSearchSong.ts index aeb86f2b..36826064 100644 --- a/apps/web/src/hooks/useSearchSong.ts +++ b/apps/web/src/hooks/useSearchSong.ts @@ -12,7 +12,7 @@ import useAuthStore from '@/stores/useAuthStore'; import useGuestToSingStore from '@/stores/useGuestToSingStore'; import useSearchHistoryStore from '@/stores/useSearchHistoryStore'; import { Method } from '@/types/common'; -import { SearchSong } from '@/types/song'; +import { SearchSong, Song } from '@/types/song'; type SearchType = 'all' | 'title' | 'artist'; @@ -71,12 +71,12 @@ export default function useSearchSong() { setSearchType(value as SearchType); }; - const handleToggleToSing = async (songId: string, method: Method) => { + const handleToggleToSing = async (song: Song, method: Method) => { if (!isAuthenticated) { if (method === 'POST') { - addGuestToSingSong(songId); + addGuestToSingSong(song); } else { - removeGuestToSingSong(songId); + removeGuestToSingSong(song.id); } return; } @@ -85,7 +85,7 @@ export default function useSearchSong() { toast.error('요청 중입니다. 잠시 후 다시 시도해주세요.'); return; } - toggleToSing({ songId, method }); + toggleToSing({ songId: song.id, method }); }; const handleToggleLike = async (songId: string, method: Method) => { diff --git a/apps/web/src/hooks/useToSingSong.ts b/apps/web/src/hooks/useToSingSong.ts index d3c9d894..3ee1d11d 100644 --- a/apps/web/src/hooks/useToSingSong.ts +++ b/apps/web/src/hooks/useToSingSong.ts @@ -12,22 +12,29 @@ import useGuestToSingStore from '@/stores/useGuestToSingStore'; export default function useToSingSong() { const { isAuthenticated } = useAuthStore(); - const { localToSingSongIds, swapGuestToSingSongs } = useGuestToSingStore(); + const { guestToSingSongs, swapGuestToSingSongs, removeGuestToSingSong } = useGuestToSingStore(); - const { data, isLoading } = useToSingSongQuery(isAuthenticated, localToSingSongIds); + const { data, isLoading } = useToSingSongQuery(isAuthenticated, guestToSingSongs); const { mutate: patchToSingSong } = usePatchToSingSongMutation(); const { mutate: deleteToSingSong } = useDeleteToSingSongMutation(); const toSingSongs = data ?? []; - console.log('localToSingSongIds', localToSingSongIds); const handleDragEnd = (event: DragEndEvent) => { // 일단 guest일 때는 return 조치, 후에 local단에서 순서 조정 가능 - if (!isAuthenticated) return; - const { active, over } = event; if (!over || active.id === over.id) return; + if (!isAuthenticated) { + const oldIndex = toSingSongs.findIndex(item => item.songs.id === active.id); + const newIndex = toSingSongs.findIndex(item => item.songs.id === over.id); + + if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) { + swapGuestToSingSongs(active.id as string, newIndex); + } + return; + } + const oldIndex = toSingSongs.findIndex(item => item.songs.id === active.id); const newIndex = toSingSongs.findIndex(item => item.songs.id === over.id); @@ -58,6 +65,10 @@ export default function useToSingSong() { }; const handleDelete = (songId: string) => { + if (!isAuthenticated) { + removeGuestToSingSong(songId); + return; + } deleteToSingSong(songId); }; diff --git a/apps/web/src/queries/tosingSongQuery.ts b/apps/web/src/queries/tosingSongQuery.ts index dc7c5daa..78030402 100644 --- a/apps/web/src/queries/tosingSongQuery.ts +++ b/apps/web/src/queries/tosingSongQuery.ts @@ -3,7 +3,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { deleteToSingSong, getToSingSong, - getToSingSongGuest, patchToSingSong, postToSingSongArray, } from '@/lib/api/tosing'; @@ -12,20 +11,21 @@ import { ToSingSong } from '@/types/song'; let invalidateTimeout: NodeJS.Timeout | null = null; // 🎵 부를 노래 목록 가져오기 -export function useToSingSongQuery(isAuthenticated: boolean, localToSingSongIds: string[]) { +export function useToSingSongQuery(isAuthenticated: boolean, guestToSingSongs: ToSingSong[]) { return useQuery({ - queryKey: ['toSingSong', localToSingSongIds], + queryKey: isAuthenticated ? ['toSingSong', 'auth'] : ['toSingSong', 'guest', guestToSingSongs], queryFn: async () => { - let response; + console.log(isAuthenticated, guestToSingSongs); if (isAuthenticated) { - response = await getToSingSong(); + const response = await getToSingSong(); + if (!response.success) { + return []; + } + return response.data || []; } else { - response = await getToSingSongGuest(localToSingSongIds); + // 게스트의 경우 로컬 스토리지 데이터 반환 (서버 요청 X) + return guestToSingSongs; } - if (!response.success) { - return []; - } - return response.data || []; }, }); } diff --git a/apps/web/src/stores/useGuestToSingStore.ts b/apps/web/src/stores/useGuestToSingStore.ts index 2bbda9fe..b84818f8 100644 --- a/apps/web/src/stores/useGuestToSingStore.ts +++ b/apps/web/src/stores/useGuestToSingStore.ts @@ -1,9 +1,11 @@ import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; +import { Song, ToSingSong } from '@/types/song'; + interface GuestToSingState { - localToSingSongIds: string[]; - addGuestToSingSong: (songId: string) => void; + guestToSingSongs: ToSingSong[]; + addGuestToSingSong: (song: Song) => void; removeGuestToSingSong: (songId: string) => void; swapGuestToSingSongs: (targetId: string, moveIndex: number) => void; clearGuestToSingSongs: () => void; @@ -12,33 +14,42 @@ interface GuestToSingState { const GUEST_TO_SING_KEY = 'guest_to_sing'; const initialState = { - localToSingSongIds: [] as string[], + guestToSingSongs: [] as ToSingSong[], }; const useGuestToSingStore = create( persist( set => ({ ...initialState, - addGuestToSingSong: (songId: string) => { + addGuestToSingSong: (song: Song) => { set(state => { - // 중복 방지 (필요 시 정책 변경 가능) - if (state.localToSingSongIds.includes(songId)) return state; - return { localToSingSongIds: [...state.localToSingSongIds, songId] }; + // 중복 방지 + if (state.guestToSingSongs.some(item => item.songs.id === song.id)) return state; + + const newToSingSong: ToSingSong = { + order_weight: 0, // 로컬에서는 index가 순서이므로 weight는 의미 없음 (0으로 고정) + songs: song, // song 객체 전체 저장 + }; + + return { guestToSingSongs: [...state.guestToSingSongs, newToSingSong] }; }); }, removeGuestToSingSong: (songId: string) => { set(state => ({ - localToSingSongIds: state.localToSingSongIds.filter(id => id !== songId), + guestToSingSongs: state.guestToSingSongs.filter(item => item.songs.id !== songId), })); }, swapGuestToSingSongs: (targetId: string, moveIndex: number) => { set(state => { - const newSongIds = [...state.localToSingSongIds]; + const newSongs = [...state.guestToSingSongs]; + const targetIndex = newSongs.findIndex(item => item.songs.id === targetId); + + if (targetIndex === -1) return state; + + const [movedItem] = newSongs.splice(targetIndex, 1); + newSongs.splice(moveIndex, 0, movedItem); - const targetIndex = newSongIds.findIndex(id => id === targetId); - const [movedItem] = newSongIds.splice(targetIndex, 1); - newSongIds.splice(moveIndex, 0, movedItem); - return { localToSingSongIds: newSongIds }; + return { guestToSingSongs: newSongs }; }); }, clearGuestToSingSongs: () => { From 87950669f2aba596bdaea2d77f9efffaf9724b95 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sat, 7 Feb 2026 15:38:05 +0900 Subject: [PATCH 10/21] =?UTF-8?q?fix=20:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=98=EB=8B=A4=EA=B3=A0=20=ED=8C=90=EB=8B=A8=EB=90=98?= =?UTF-8?q?=EB=8A=94=20=EB=94=94=EB=B0=94=EC=9A=B4=EC=8A=A4=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0.=20invalidateQueries=20=EC=A0=95=EC=83=81=ED=99=94.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/queries/tosingSongQuery.ts | 57 ++++++++----------------- 1 file changed, 17 insertions(+), 40 deletions(-) diff --git a/apps/web/src/queries/tosingSongQuery.ts b/apps/web/src/queries/tosingSongQuery.ts index 78030402..cfdd7993 100644 --- a/apps/web/src/queries/tosingSongQuery.ts +++ b/apps/web/src/queries/tosingSongQuery.ts @@ -8,14 +8,13 @@ import { } from '@/lib/api/tosing'; import { ToSingSong } from '@/types/song'; -let invalidateTimeout: NodeJS.Timeout | null = null; +// let invalidateTimeout: NodeJS.Timeout | null = null; -// 🎵 부를 노래 목록 가져오기 +// 부를 노래 목록 가져오기 export function useToSingSongQuery(isAuthenticated: boolean, guestToSingSongs: ToSingSong[]) { return useQuery({ - queryKey: isAuthenticated ? ['toSingSong', 'auth'] : ['toSingSong', 'guest', guestToSingSongs], + queryKey: isAuthenticated ? ['toSingSong'] : ['toSingSong', 'guest', guestToSingSongs], queryFn: async () => { - console.log(isAuthenticated, guestToSingSongs); if (isAuthenticated) { const response = await getToSingSong(); if (!response.success) { @@ -30,7 +29,7 @@ export function useToSingSongQuery(isAuthenticated: boolean, guestToSingSongs: T }); } -// 🎵 부를 노래 추가 +// 부를 노래 추가 export function usePostToSingSongMutation() { const queryClient = useQueryClient(); @@ -38,8 +37,6 @@ export function usePostToSingSongMutation() { mutationFn: (songIds: string[]) => postToSingSongArray({ songIds }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['toSingSong'] }); - queryClient.invalidateQueries({ queryKey: ['likeSong'] }); - queryClient.invalidateQueries({ queryKey: ['saveSongFolder'] }); queryClient.invalidateQueries({ queryKey: ['searchSong'] }); }, onError: error => { @@ -49,26 +46,7 @@ export function usePostToSingSongMutation() { }); } -// 🎵 여러 곡 부를 노래 추가 -export function usePostToSingSongArrayMutation() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (songIds: string[]) => postToSingSongArray({ songIds }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['toSingSong'] }); - queryClient.invalidateQueries({ queryKey: ['likeSong'] }); - queryClient.invalidateQueries({ queryKey: ['saveSongFolder'] }); - queryClient.invalidateQueries({ queryKey: ['searchSong'] }); - }, - onError: error => { - console.error('error', error); - alert(error.message ?? 'POST 실패'); - }, - }); -} - -// 🎵 부를 노래 삭제 +// 부를 노래 삭제 export function useDeleteToSingSongMutation() { const queryClient = useQueryClient(); @@ -77,9 +55,9 @@ export function useDeleteToSingSongMutation() { onMutate: async (songId: string) => { queryClient.cancelQueries({ queryKey: ['toSingSong'] }); const prev = queryClient.getQueryData(['toSingSong']); - queryClient.setQueryData(['toSingSong'], (old: ToSingSong[]) => - old.filter(song => song.songs.id !== songId), - ); + queryClient.setQueryData(['toSingSong'], (old: ToSingSong[]) => { + old.filter(song => song.songs.id !== songId); + }); return { prev }; }, onError: (error, variables, context) => { @@ -89,20 +67,19 @@ export function useDeleteToSingSongMutation() { }, onSettled: () => { // 1초 이내에 함수가 여러 번 호출되면, 1초 뒤 트리거를 계속해서 갱신 - if (invalidateTimeout) { - clearTimeout(invalidateTimeout); - } - invalidateTimeout = setTimeout(() => { - queryClient.invalidateQueries({ queryKey: ['toSingSong'] }); - queryClient.invalidateQueries({ queryKey: ['likeSong'] }); - queryClient.invalidateQueries({ queryKey: ['saveSongFolder'] }); - queryClient.invalidateQueries({ queryKey: ['searchSong'] }); - }, 1000); + // if (invalidateTimeout) { + // clearTimeout(invalidateTimeout); + // } + // invalidateTimeout = setTimeout(() => { + // queryClient.invalidateQueries({ queryKey: ['toSingSong'] }); + // }, 1000); + queryClient.invalidateQueries({ queryKey: ['searchSong'] }); + queryClient.invalidateQueries({ queryKey: ['toSingSong'] }); }, }); } -// 🎵 부를 노래 순서 변경 +// 부를 노래 순서 변경 export function usePatchToSingSongMutation() { const queryClient = useQueryClient(); From 22426bdf872d2ccf9d0b734dbee82af9b07ea998 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sat, 7 Feb 2026 15:38:14 +0900 Subject: [PATCH 11/21] =?UTF-8?q?feat=20:=20ReactQueryDevtools=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/package.json | 2 +- apps/web/src/query.tsx | 8 +++++++- pnpm-lock.yaml | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index b7bcb60f..ad2b8329 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -35,7 +35,7 @@ "@supabase/ssr": "^0.6.1", "@supabase/supabase-js": "^2.49.1", "@tanstack/react-query": "^5.68.0", - "@tanstack/react-query-devtools": "^5.68.0", + "@tanstack/react-query-devtools": "^5.91.2", "@vercel/analytics": "^1.5.0", "@vercel/speed-insights": "^1.2.0", "axios": "^1.5.0", diff --git a/apps/web/src/query.tsx b/apps/web/src/query.tsx index c6d8b5b5..14dd370a 100644 --- a/apps/web/src/query.tsx +++ b/apps/web/src/query.tsx @@ -1,6 +1,7 @@ 'use client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { useState } from 'react'; export default function QueryProvider({ children }: { children: React.ReactNode }) { @@ -19,5 +20,10 @@ export default function QueryProvider({ children }: { children: React.ReactNode }), ); - return {children}; + return ( + + {children} + + + ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7549fd6..fe2df0c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -200,7 +200,7 @@ importers: specifier: ^5.68.0 version: 5.90.16(react@19.2.3) '@tanstack/react-query-devtools': - specifier: ^5.68.0 + specifier: ^5.91.2 version: 5.91.2(@tanstack/react-query@5.90.16(react@19.2.3))(react@19.2.3) '@vercel/analytics': specifier: ^1.5.0 From 0cc2adc633c4115662ff510156bb3deea8c7bbd3 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sat, 7 Feb 2026 17:08:10 +0900 Subject: [PATCH 12/21] =?UTF-8?q?feat=20:=20=EC=9D=B8=EA=B8=B0=EA=B3=A1=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=83=88=EB=A1=9C=EA=B3=A0?= =?UTF-8?q?=EC=B9=A8=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/app/popular/PopularRankingList.tsx | 9 +- .../src/components/ui/IntervalProgress.tsx | 126 ++++++++++++++++++ apps/web/src/queries/songThumbQuery.ts | 2 + 3 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/ui/IntervalProgress.tsx diff --git a/apps/web/src/app/popular/PopularRankingList.tsx b/apps/web/src/app/popular/PopularRankingList.tsx index bf1b5393..2ab9058c 100644 --- a/apps/web/src/app/popular/PopularRankingList.tsx +++ b/apps/web/src/app/popular/PopularRankingList.tsx @@ -1,6 +1,8 @@ 'use client'; import { Construction } from 'lucide-react'; +// import IntervalProgress from '@/components/ui/IntervalProgress'; +import { RotateCw } from 'lucide-react'; import RankingItem from '@/components/RankingItem'; import StaticLoading from '@/components/StaticLoading'; @@ -8,7 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { useSongThumbQuery } from '@/queries/songThumbQuery'; export default function PopularRankingList() { - const { data, isPending } = useSongThumbQuery(); + const { data, isPending, refetch, isFetching } = useSongThumbQuery(); if (isPending) { return ; @@ -16,8 +18,11 @@ export default function PopularRankingList() { return ( - + 추천 곡 순위 + {/* refetch()} isLoading={isFetching} /> */} + + refetch()} className="cursor-pointer hover:animate-spin" />
diff --git a/apps/web/src/components/ui/IntervalProgress.tsx b/apps/web/src/components/ui/IntervalProgress.tsx new file mode 100644 index 00000000..2b05defc --- /dev/null +++ b/apps/web/src/components/ui/IntervalProgress.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { Loader2 } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +import { cn } from '@/utils/cn'; + +interface IntervalProgressProps { + /** + * The duration in milliseconds for the progress to complete. + * @default 5000 + */ + duration?: number; + /** + * Callback function to be called when the progress completes. + */ + onComplete?: () => void; + /** + * The size of the progress circle in pixels. + * @default 24 + */ + size?: number; + /** + * The stroke width of the progress circle. + * @default 3 + */ + strokeWidth?: number; + /** + * Class name for custom styling. + */ + className?: string; + /** + * Whether the timer is currently active. + * @default true + */ + isActive?: boolean; + /** + * Whether the component is in a loading state. + * During loading, the timer pauses and a spinner is shown. + * When loading finishes, the timer resets and restarts. + */ + isLoading?: boolean; +} + +export default function IntervalProgress({ + duration = 5000, + onComplete, + size = 24, + strokeWidth = 3, + className, + isActive = true, + isLoading = false, +}: IntervalProgressProps) { + const [progress, setProgress] = useState(0); + const updateInterval = 50; // Update every 50ms for smooth animation + + useEffect(() => { + // If loading, don't run the timer + // Also, if we just finished loading (isLoading became false), we might want to reset? + // Actually, let's handle reset in a separate effect or here. + if (!isActive || isLoading) return; + + const interval = setInterval(() => { + setProgress(prev => { + const next = prev + (updateInterval / duration) * 100; + if (next >= 100) { + onComplete?.(); + return 100; // Snap to 100, wait for isLoading to become true + } + return next; + }); + }, updateInterval); + + return () => clearInterval(interval); + }, [isActive, duration, onComplete, isLoading]); + + // Reset progress when loading finishes + useEffect(() => { + if (!isLoading) { + setProgress(0); + } + }, [isLoading]); + + const radius = (size - strokeWidth) / 2; + const circumference = radius * 2 * Math.PI; + const dashoffset = circumference - (progress / 100) * circumference; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ + {/* Background Circle */} + + {/* Progress Circle */} + + +
+ ); +} diff --git a/apps/web/src/queries/songThumbQuery.ts b/apps/web/src/queries/songThumbQuery.ts index 8330a215..68fb4c91 100644 --- a/apps/web/src/queries/songThumbQuery.ts +++ b/apps/web/src/queries/songThumbQuery.ts @@ -13,6 +13,8 @@ export const useSongThumbQuery = () => { } return response.data; }, + staleTime: 0, // 데이터를 받자마자 "상한 것(Stale)"으로 취급 -> 다시 조회할 명분 생성 + gcTime: 0, // (구 cacheTime) 언마운트 되는 즉시 메모리에서 삭제 -> 캐시가 없으니 무조건 새로 요청 }); }); }; From e9306f9d16796f9fdb23852ac70735b31b69d0a6 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sat, 7 Feb 2026 17:30:48 +0900 Subject: [PATCH 13/21] =?UTF-8?q?fix=20:=20=ED=8C=8C=EC=9D=BC=EB=9F=BF=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/queries/tosingSongQuery.ts | 4 +++- apps/web/src/query.tsx | 2 +- apps/web/src/stores/useGuestToSingStore.ts | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/web/src/queries/tosingSongQuery.ts b/apps/web/src/queries/tosingSongQuery.ts index cfdd7993..18b3601b 100644 --- a/apps/web/src/queries/tosingSongQuery.ts +++ b/apps/web/src/queries/tosingSongQuery.ts @@ -13,7 +13,9 @@ import { ToSingSong } from '@/types/song'; // 부를 노래 목록 가져오기 export function useToSingSongQuery(isAuthenticated: boolean, guestToSingSongs: ToSingSong[]) { return useQuery({ - queryKey: isAuthenticated ? ['toSingSong'] : ['toSingSong', 'guest', guestToSingSongs], + queryKey: isAuthenticated + ? ['toSingSong'] + : ['toSingSong', 'guest', guestToSingSongs.map(song => song.songs.id)], queryFn: async () => { if (isAuthenticated) { const response = await getToSingSong(); diff --git a/apps/web/src/query.tsx b/apps/web/src/query.tsx index 14dd370a..e77c2aec 100644 --- a/apps/web/src/query.tsx +++ b/apps/web/src/query.tsx @@ -23,7 +23,7 @@ export default function QueryProvider({ children }: { children: React.ReactNode return ( {children} - + {process.env.NODE_ENV === 'development' && } ); } diff --git a/apps/web/src/stores/useGuestToSingStore.ts b/apps/web/src/stores/useGuestToSingStore.ts index b84818f8..ae6d615a 100644 --- a/apps/web/src/stores/useGuestToSingStore.ts +++ b/apps/web/src/stores/useGuestToSingStore.ts @@ -41,6 +41,7 @@ const useGuestToSingStore = create( }, swapGuestToSingSongs: (targetId: string, moveIndex: number) => { set(state => { + if (moveIndex < 0 || moveIndex >= state.guestToSingSongs.length) return state; const newSongs = [...state.guestToSingSongs]; const targetIndex = newSongs.findIndex(item => item.songs.id === targetId); From f650eac5744c223bc464e1d3b04e0de35874077d Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sat, 7 Feb 2026 18:05:27 +0900 Subject: [PATCH 14/21] =?UTF-8?q?fix=20:=20qodo-code=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/public/sitemap-0.xml | 2 +- apps/web/src/app/api/songs/tosing/guest/route.ts | 4 ++++ apps/web/src/app/popular/PopularRankingList.tsx | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/public/sitemap-0.xml b/apps/web/public/sitemap-0.xml index c8586f6b..233ef310 100644 --- a/apps/web/public/sitemap-0.xml +++ b/apps/web/public/sitemap-0.xml @@ -1,4 +1,4 @@ -https://www.singcode.kr2026-01-25T11:53:47.028Zweekly0.7 +https://www.singcode.kr2026-02-07T09:02:36.464Zweekly0.7 \ No newline at end of file diff --git a/apps/web/src/app/api/songs/tosing/guest/route.ts b/apps/web/src/app/api/songs/tosing/guest/route.ts index ad400ed0..358ffff3 100644 --- a/apps/web/src/app/api/songs/tosing/guest/route.ts +++ b/apps/web/src/app/api/songs/tosing/guest/route.ts @@ -9,6 +9,10 @@ export async function GET(request: NextRequest): Promise; From c8c5802b6b40ccf09178a0e0d93b66e99fc80c7d Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sun, 8 Feb 2026 22:20:34 +0900 Subject: [PATCH 15/21] =?UTF-8?q?feat=20:=20Footer=20=ED=95=98=EC=9D=B4?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=ED=8A=B8=20=ED=9B=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/package.json | 1 + apps/web/src/Footer.tsx | 61 +++++++++++++++++++++------ apps/web/src/stores/useFooterStore.ts | 16 +++++++ pnpm-lock.yaml | 29 +++++++------ 4 files changed, 81 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/stores/useFooterStore.ts diff --git a/apps/web/package.json b/apps/web/package.json index b7bcb60f..b0a6d9f6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -42,6 +42,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "framer-motion": "^12.33.0", "gsap": "^3.14.2", "immer": "^10.1.1", "lottie-react": "^2.4.1", diff --git a/apps/web/src/Footer.tsx b/apps/web/src/Footer.tsx index ebce66ef..8276a4e5 100644 --- a/apps/web/src/Footer.tsx +++ b/apps/web/src/Footer.tsx @@ -1,38 +1,73 @@ 'use client'; +import { motion } from 'framer-motion'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; +import { useEffect } from 'react'; import { Button } from '@/components/ui/button'; +import { useFooterStore } from '@/stores/useFooterStore'; import { cn } from '@/utils/cn'; +const FOOTER_KEY = { + SEARCH: 'SEARCH', + RECENT: 'RECENT', + TOSING: 'TOSING', + POPULAR: 'POPULAR', + INFO: 'INFO', +}; + const navigation = [ - { name: '최신 곡', href: '/recent' }, + { name: '최신 곡', href: '/recent', key: FOOTER_KEY.RECENT }, - { name: '부를 곡', href: '/tosing' }, - { name: '검색', href: '/' }, + { name: '부를 곡', href: '/tosing', key: FOOTER_KEY.TOSING }, + { name: '검색', href: '/', key: FOOTER_KEY.SEARCH }, - { name: '인기곡', href: '/popular' }, - { name: '정보', href: '/info' }, + { name: '인기곡', href: '/popular', key: FOOTER_KEY.POPULAR }, + { name: '정보', href: '/info', key: FOOTER_KEY.INFO }, ]; export default function Footer() { const pathname = usePathname(); + const { activeFooterItem } = useFooterStore(); const navPath = pathname.split('/')[1]; return (
{navigation.map(item => { const isActive = '/' + navPath === item.href; + const isAnimating = activeFooterItem === item.key; + return ( - +
+ {isAnimating && ( + + )} + +
); })}
diff --git a/apps/web/src/stores/useFooterStore.ts b/apps/web/src/stores/useFooterStore.ts new file mode 100644 index 00000000..bdfc2465 --- /dev/null +++ b/apps/web/src/stores/useFooterStore.ts @@ -0,0 +1,16 @@ +import { create } from 'zustand'; + +interface FooterStore { + activeFooterItem: string | null; + triggerFooterAnimation: (href: string) => void; +} + +export const useFooterStore = create(set => ({ + activeFooterItem: null, + triggerFooterAnimation: href => { + set({ activeFooterItem: href }); + setTimeout(() => { + set({ activeFooterItem: null }); + }, 300); + }, +})); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7549fd6..047eddf5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -220,6 +220,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + framer-motion: + specifier: ^12.33.0 + version: 12.33.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) gsap: specifier: ^3.14.2 version: 3.14.2 @@ -4564,8 +4567,8 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} - framer-motion@12.29.0: - resolution: {integrity: sha512-1gEFGXHYV2BD42ZPTFmSU9buehppU+bCuOnHU0AD18DKh9j4DuTx47MvqY5ax+NNWRtK32qIcJf1UxKo1WwjWg==} + framer-motion@12.33.0: + resolution: {integrity: sha512-ca8d+rRPcDP5iIF+MoT3WNc0KHJMjIyFAbtVLvM9eA7joGSpeqDfiNH/kCs1t4CHi04njYvWyj0jS4QlEK/rJQ==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -5836,11 +5839,11 @@ packages: mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} - motion-dom@12.29.0: - resolution: {integrity: sha512-3eiz9bb32yvY8Q6XNM4AwkSOBPgU//EIKTZwsSWgA9uzbPBhZJeScCVcBuwwYVqhfamewpv7ZNmVKTGp5qnzkA==} + motion-dom@12.33.0: + resolution: {integrity: sha512-XRPebVypsl0UM+7v0Hr8o9UAj0S2djsQWRdHBd5iVouVpMrQqAI0C/rDAT3QaYnXnHuC5hMcwDHCboNeyYjPoQ==} - motion-utils@12.27.2: - resolution: {integrity: sha512-B55gcoL85Mcdt2IEStY5EEAsrMSVE2sI14xQ/uAdPL+mfQxhKKFaEag9JmfxedJOR4vZpBGoPeC/Gm13I/4g5Q==} + motion-utils@12.29.2: + resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==} motion@12.29.0: resolution: {integrity: sha512-rjB5CP2N9S2ESAyEFnAFMgTec6X8yvfxLNcz8n12gPq3M48R7ZbBeVYkDOTj8SPMwfvGIFI801SiPSr1+HCr9g==} @@ -12824,10 +12827,10 @@ snapshots: fraction.js@5.3.4: {} - framer-motion@12.29.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + framer-motion@12.33.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: - motion-dom: 12.29.0 - motion-utils: 12.27.2 + motion-dom: 12.33.0 + motion-utils: 12.29.2 tslib: 2.8.1 optionalDependencies: react: 19.2.3 @@ -14441,15 +14444,15 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.1 - motion-dom@12.29.0: + motion-dom@12.33.0: dependencies: - motion-utils: 12.27.2 + motion-utils: 12.29.2 - motion-utils@12.27.2: {} + motion-utils@12.29.2: {} motion@12.29.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: - framer-motion: 12.29.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + framer-motion: 12.33.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) tslib: 2.8.1 optionalDependencies: react: 19.2.3 From d48b43b617dd116289e2c42a593932f7a7721a5b Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sun, 8 Feb 2026 22:47:39 +0900 Subject: [PATCH 16/21] =?UTF-8?q?feat=20:=20Framer=20=EC=95=A0=EB=8B=88?= =?UTF-8?q?=EB=A9=94=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80.=20store?= =?UTF-8?q?=EB=A1=9C=20=EB=8C=80=EC=9D=91=ED=95=98=EB=8A=94=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=EC=97=90=EC=84=9C=20=EC=95=A0=EB=8B=88?= =?UTF-8?q?=EB=A9=94=EC=9D=B4=EC=85=98=20=ED=98=B8=EC=B6=9C.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/Footer.tsx | 35 +++++++++----------- apps/web/src/components/ThumbUpModal.tsx | 5 +++ apps/web/src/hooks/useSearchSong.ts | 14 ++++++++ apps/web/src/stores/useFooterAnimateStore.ts | 25 ++++++++++++++ apps/web/src/stores/useFooterStore.ts | 16 --------- 5 files changed, 60 insertions(+), 35 deletions(-) create mode 100644 apps/web/src/stores/useFooterAnimateStore.ts delete mode 100644 apps/web/src/stores/useFooterStore.ts diff --git a/apps/web/src/Footer.tsx b/apps/web/src/Footer.tsx index 8276a4e5..6cd01d9f 100644 --- a/apps/web/src/Footer.tsx +++ b/apps/web/src/Footer.tsx @@ -3,33 +3,30 @@ import { motion } from 'framer-motion'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { useEffect } from 'react'; import { Button } from '@/components/ui/button'; -import { useFooterStore } from '@/stores/useFooterStore'; +import useFooterAnimateStore, { FooterKey } from '@/stores/useFooterAnimateStore'; import { cn } from '@/utils/cn'; -const FOOTER_KEY = { - SEARCH: 'SEARCH', - RECENT: 'RECENT', - TOSING: 'TOSING', - POPULAR: 'POPULAR', - INFO: 'INFO', -}; +interface Navigation { + name: string; + href: string; + key: FooterKey; +} -const navigation = [ - { name: '최신 곡', href: '/recent', key: FOOTER_KEY.RECENT }, +const navigation: Navigation[] = [ + { name: '최신 곡', href: '/recent', key: 'RECENT' }, - { name: '부를 곡', href: '/tosing', key: FOOTER_KEY.TOSING }, - { name: '검색', href: '/', key: FOOTER_KEY.SEARCH }, + { name: '부를 곡', href: '/tosing', key: 'TOSING' }, + { name: '검색', href: '/', key: 'SEARCH' }, - { name: '인기곡', href: '/popular', key: FOOTER_KEY.POPULAR }, - { name: '정보', href: '/info', key: FOOTER_KEY.INFO }, + { name: '인기곡', href: '/popular', key: 'POPULAR' }, + { name: '정보', href: '/info', key: 'INFO' }, ]; export default function Footer() { const pathname = usePathname(); - const { activeFooterItem } = useFooterStore(); + const { activeFooterItem } = useFooterAnimateStore(); const navPath = pathname.split('/')[1]; return ( @@ -42,9 +39,9 @@ export default function Footer() {
{isAnimating && ( @@ -60,7 +57,7 @@ export default function Footer() { {item.name} diff --git a/apps/web/src/components/ThumbUpModal.tsx b/apps/web/src/components/ThumbUpModal.tsx index 30e05579..917763c9 100644 --- a/apps/web/src/components/ThumbUpModal.tsx +++ b/apps/web/src/components/ThumbUpModal.tsx @@ -11,6 +11,7 @@ import { Slider } from '@/components/ui/slider'; import { useSongThumbMutation } from '@/queries/songThumbQuery'; import { useUserQuery } from '@/queries/userQuery'; import { usePatchSetPointMutation } from '@/queries/userQuery'; +import useFooterAnimateStore from '@/stores/useFooterAnimateStore'; import FallingIcons from './FallingIcons'; @@ -29,10 +30,14 @@ export default function ThumbUpModal({ songId, handleClose }: ThumbUpModalProps) const { mutate: patchSongThumb, isPending: isPendingSongThumb } = useSongThumbMutation(); const { mutate: patchSetPoint, isPending: isPendingSetPoint } = usePatchSetPointMutation(); + const { triggerFooterAnimation } = useFooterAnimateStore(); + const handleClickThumb = () => { patchSongThumb({ songId, point: value[0] }); patchSetPoint({ point: point - value[0] }); + triggerFooterAnimation('POPULAR'); + handleClose(); }; diff --git a/apps/web/src/hooks/useSearchSong.ts b/apps/web/src/hooks/useSearchSong.ts index 36826064..d102c88c 100644 --- a/apps/web/src/hooks/useSearchSong.ts +++ b/apps/web/src/hooks/useSearchSong.ts @@ -9,6 +9,7 @@ import { useToggleToSingMutation, } from '@/queries/searchSongQuery'; import useAuthStore from '@/stores/useAuthStore'; +import useFooterAnimateStore from '@/stores/useFooterAnimateStore'; import useGuestToSingStore from '@/stores/useGuestToSingStore'; import useSearchHistoryStore from '@/stores/useSearchHistoryStore'; import { Method } from '@/types/common'; @@ -46,6 +47,7 @@ export default function useSearchSong() { isError, } = useInfiniteSearchSongQuery(query, searchType, isAuthenticated); + const { triggerFooterAnimation } = useFooterAnimateStore(); const { addToHistory } = useSearchHistoryStore(); const { addGuestToSingSong, removeGuestToSingSong } = useGuestToSingStore(); @@ -75,6 +77,7 @@ export default function useSearchSong() { if (!isAuthenticated) { if (method === 'POST') { addGuestToSingSong(song); + triggerFooterAnimation('TOSING'); } else { removeGuestToSingSong(song.id); } @@ -85,6 +88,10 @@ export default function useSearchSong() { toast.error('요청 중입니다. 잠시 후 다시 시도해주세요.'); return; } + + if (method === 'POST') { + triggerFooterAnimation('TOSING'); + } toggleToSing({ songId: song.id, method }); }; @@ -98,6 +105,10 @@ export default function useSearchSong() { toast.error('요청 중입니다. 잠시 후 다시 시도해주세요.'); return; } + + if (method === 'POST') { + triggerFooterAnimation('INFO'); + } toggleLike({ songId, method }); }; @@ -116,6 +127,8 @@ export default function useSearchSong() { toast.error('요청 중입니다. 잠시 후 다시 시도해주세요.'); return; } + + triggerFooterAnimation('INFO'); postSong({ songId, folderName, query, searchType }); }; @@ -125,6 +138,7 @@ export default function useSearchSong() { return; } + triggerFooterAnimation('INFO'); moveSong({ songIdArray: [songId], folderId }); }; diff --git a/apps/web/src/stores/useFooterAnimateStore.ts b/apps/web/src/stores/useFooterAnimateStore.ts new file mode 100644 index 00000000..520ab764 --- /dev/null +++ b/apps/web/src/stores/useFooterAnimateStore.ts @@ -0,0 +1,25 @@ +import { create } from 'zustand'; + +export type FooterKey = 'SEARCH' | 'RECENT' | 'TOSING' | 'POPULAR' | 'INFO' | null; + +interface FooterStore { + activeFooterItem: FooterKey; + triggerFooterAnimation: (key: FooterKey) => void; +} + +const initialState = { + activeFooterItem: null, +}; + +const useFooterAnimateStore = create(set => ({ + ...initialState, + + triggerFooterAnimation: key => { + set({ activeFooterItem: key }); + setTimeout(() => { + set({ activeFooterItem: null }); + }, 300); + }, +})); + +export default useFooterAnimateStore; diff --git a/apps/web/src/stores/useFooterStore.ts b/apps/web/src/stores/useFooterStore.ts deleted file mode 100644 index bdfc2465..00000000 --- a/apps/web/src/stores/useFooterStore.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { create } from 'zustand'; - -interface FooterStore { - activeFooterItem: string | null; - triggerFooterAnimation: (href: string) => void; -} - -export const useFooterStore = create(set => ({ - activeFooterItem: null, - triggerFooterAnimation: href => { - set({ activeFooterItem: href }); - setTimeout(() => { - set({ activeFooterItem: null }); - }, 300); - }, -})); From c411286ce43f4ba33378a076c96c6641938681af Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sun, 8 Feb 2026 23:10:01 +0900 Subject: [PATCH 17/21] =?UTF-8?q?fix=20:=20store=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=EB=AA=85=ED=99=95=ED=95=98=EA=B2=8C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/Footer.tsx | 4 ++-- apps/web/src/components/ThumbUpModal.tsx | 4 ++-- apps/web/src/hooks/useSearchSong.ts | 12 ++++++------ apps/web/src/stores/useFooterAnimateStore.ts | 12 ++++++------ 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/apps/web/src/Footer.tsx b/apps/web/src/Footer.tsx index 6cd01d9f..acfccf24 100644 --- a/apps/web/src/Footer.tsx +++ b/apps/web/src/Footer.tsx @@ -26,14 +26,14 @@ const navigation: Navigation[] = [ export default function Footer() { const pathname = usePathname(); - const { activeFooterItem } = useFooterAnimateStore(); + const { footerAnimateKey } = useFooterAnimateStore(); const navPath = pathname.split('/')[1]; return (