diff --git a/.gitignore b/.gitignore index 10a301f..7318867 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 44a73ec..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "eslint.workingDirectories": [ - { - "mode": "auto" - } - ] -} diff --git a/GEMINI.md b/GEMINI.md index 77f90e1..72c1452 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/package.json b/apps/web/package.json index b7bcb60..ad2b832 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/public/sitemap-0.xml b/apps/web/public/sitemap-0.xml index c8586f6..233ef31 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 new file mode 100644 index 0000000..358ffff --- /dev/null +++ b/apps/web/src/app/api/songs/tosing/guest/route.ts @@ -0,0 +1,56 @@ +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[]'); + + if (!ids || ids.length === 0) { + return NextResponse.json({ success: true, data: [] }); + } + + 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/app/info/like/page.tsx b/apps/web/src/app/info/like/page.tsx index b932809..9ef99a1 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 6d5da78..fe7c610 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/popular/PopularRankingList.tsx b/apps/web/src/app/popular/PopularRankingList.tsx index 5e1a74d..27a1a35 100644 --- a/apps/web/src/app/popular/PopularRankingList.tsx +++ b/apps/web/src/app/popular/PopularRankingList.tsx @@ -1,24 +1,33 @@ +'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'; 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, refetch } = useSongThumbQuery(); + + if (isPending) { + return ; + } -interface RankingListProps { - title: string; - songStats: ThumbUpSong[]; -} -export default function PopularRankingList({ title, songStats }: RankingListProps) { return ( - // - - {title} + + 추천 곡 순위 + {/* refetch()} isLoading={isFetching} /> */} + + refetch()} className="cursor-pointer hover:animate-spin" />
- {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 4fc6ae1..b97552d 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/app/search/HomePage.tsx b/apps/web/src/app/search/HomePage.tsx index f6e40e1..76553a7 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/app/tosing/AddListModal.tsx b/apps/web/src/app/tosing/AddListModal.tsx index 98b826b..2f46caa 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/app/tosing/AddSongButton.tsx b/apps/web/src/app/tosing/AddSongButton.tsx new file mode 100644 index 0000000..7a0d53b --- /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/SongList.tsx b/apps/web/src/app/tosing/SongList.tsx index 08bb506..810c4c6 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/app/tosing/page.tsx b/apps/web/src/app/tosing/page.tsx index 5b71e51..3125f7a 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)} />
); } diff --git a/apps/web/src/components/LoadingOverlay.tsx b/apps/web/src/components/LoadingOverlay.tsx deleted file mode 100644 index 42a011d..0000000 --- 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 6c587cd..33c0544 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/components/ui/IntervalProgress.tsx b/apps/web/src/components/ui/IntervalProgress.tsx new file mode 100644 index 0000000..2b05def --- /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/hooks/useSearchSong.ts b/apps/web/src/hooks/useSearchSong.ts index 7cc9809..3682606 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'; @@ -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 제거 @@ -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') { - addSong(songId); + addGuestToSingSong(song); } else { - removeSong(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/useSong.ts b/apps/web/src/hooks/useToSingSong.ts similarity index 64% rename from apps/web/src/hooks/useSong.ts rename to apps/web/src/hooks/useToSingSong.ts index fcc3f32..3ee1d11 100644 --- a/apps/web/src/hooks/useSong.ts +++ b/apps/web/src/hooks/useToSingSong.ts @@ -7,18 +7,34 @@ import { usePatchToSingSongMutation, useToSingSongQuery, } from '@/queries/tosingSongQuery'; +import useAuthStore from '@/stores/useAuthStore'; +import useGuestToSingStore from '@/stores/useGuestToSingStore'; -export default function useSong() { - const { data, isLoading } = useToSingSongQuery(); +export default function useToSingSong() { + const { isAuthenticated } = useAuthStore(); + const { guestToSingSongs, swapGuestToSingSongs, removeGuestToSingSong } = useGuestToSingStore(); + + const { data, isLoading } = useToSingSongQuery(isAuthenticated, guestToSingSongs); const { mutate: patchToSingSong } = usePatchToSingSongMutation(); const { mutate: deleteToSingSong } = useDeleteToSingSongMutation(); const toSingSongs = data ?? []; const handleDragEnd = (event: DragEndEvent) => { + // 일단 guest일 때는 return 조치, 후에 local단에서 순서 조정 가능 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); @@ -49,12 +65,23 @@ export default function useSong() { }; const handleDelete = (songId: string) => { + if (!isAuthenticated) { + removeGuestToSingSong(songId); + return; + } deleteToSingSong(songId); }; const handleMoveToTop = (songId: string, oldIndex: number) => { + // 일단 guest일 때는 return 조치, 후에 local단에서 순서 조정 가능 + if (oldIndex === 0) return; + if (!isAuthenticated) { + swapGuestToSingSongs(songId, 0); + return; + } + const newItems = arrayMove(toSingSongs, oldIndex, 0); const newWeight = toSingSongs[0].order_weight - 1; @@ -66,9 +93,16 @@ export default function useSong() { }; const handleMoveToBottom = (songId: string, oldIndex: number) => { + // 일단 guest일 때는 return 조치, 후에 local단에서 순서 조정 가능 + 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/lib/api/tosing.ts b/apps/web/src/lib/api/tosing.ts index cb20957..ed25688 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 328169a..8f97238 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,8 +14,7 @@ export function useLikeSongQuery() { } return response.data || []; }, - staleTime: 1000 * 60 * 5, - gcTime: 1000 * 60 * 5, + enabled: isAuthenticated, }); } diff --git a/apps/web/src/queries/saveSongQuery.ts b/apps/web/src/queries/saveSongQuery.ts index a0b9870..ac3e4ef 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,8 +30,7 @@ export function useSaveSongQuery() { return songFolders; }, - staleTime: 1000 * 60 * 5, - gcTime: 1000 * 60 * 5, + enabled: isAuthenticated, }); } diff --git a/apps/web/src/queries/searchSongQuery.ts b/apps/web/src/queries/searchSongQuery.ts index ff47fa1..2eed3c1 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/songThumbQuery.ts b/apps/web/src/queries/songThumbQuery.ts index 34999af..68fb4c9 100644 --- a/apps/web/src/queries/songThumbQuery.ts +++ b/apps/web/src/queries/songThumbQuery.ts @@ -9,11 +9,12 @@ export const useSongThumbQuery = () => { const response = await getSongThumbList(); if (!response.success) { - return null; + return []; } return response.data; }, - staleTime: 1000 * 60, + staleTime: 0, // 데이터를 받자마자 "상한 것(Stale)"으로 취급 -> 다시 조회할 명분 생성 + gcTime: 0, // (구 cacheTime) 언마운트 되는 즉시 메모리에서 삭제 -> 캐시가 없으니 무조건 새로 요청 }); }); }; diff --git a/apps/web/src/queries/tosingSongQuery.ts b/apps/web/src/queries/tosingSongQuery.ts index fe6c70f..18b3601 100644 --- a/apps/web/src/queries/tosingSongQuery.ts +++ b/apps/web/src/queries/tosingSongQuery.ts @@ -8,26 +8,30 @@ 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() { +// 부를 노래 목록 가져오기 +export function useToSingSongQuery(isAuthenticated: boolean, guestToSingSongs: ToSingSong[]) { return useQuery({ - queryKey: ['toSingSong'], + queryKey: isAuthenticated + ? ['toSingSong'] + : ['toSingSong', 'guest', guestToSingSongs.map(song => song.songs.id)], queryFn: async () => { - const response = await getToSingSong(); - if (!response.success) { - return []; + if (isAuthenticated) { + const response = await getToSingSong(); + if (!response.success) { + return []; + } + return response.data || []; + } else { + // 게스트의 경우 로컬 스토리지 데이터 반환 (서버 요청 X) + return guestToSingSongs; } - return response.data || []; }, - // DB의 값은 고정된 값이므로 캐시를 유지한다 - staleTime: 1000 * 60 * 5, - gcTime: 1000 * 60 * 5, }); } -// 🎵 부를 노래 추가 +// 부를 노래 추가 export function usePostToSingSongMutation() { const queryClient = useQueryClient(); @@ -35,9 +39,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: ['recentSingLog'] }); queryClient.invalidateQueries({ queryKey: ['searchSong'] }); }, onError: error => { @@ -47,27 +48,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: ['recentSingLog'] }); - queryClient.invalidateQueries({ queryKey: ['searchSong'] }); - }, - onError: error => { - console.error('error', error); - alert(error.message ?? 'POST 실패'); - }, - }); -} - -// 🎵 부를 노래 삭제 +// 부를 노래 삭제 export function useDeleteToSingSongMutation() { const queryClient = useQueryClient(); @@ -76,9 +57,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) => { @@ -88,21 +69,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: ['recentSingLog'] }); - 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(); diff --git a/apps/web/src/query.tsx b/apps/web/src/query.tsx index c6d8b5b..e77c2ae 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} + {process.env.NODE_ENV === 'development' && } + + ); } diff --git a/apps/web/src/stores/useGuestToSingStore.ts b/apps/web/src/stores/useGuestToSingStore.ts index d7e90e2..ae6d615 100644 --- a/apps/web/src/stores/useGuestToSingStore.ts +++ b/apps/web/src/stores/useGuestToSingStore.ts @@ -1,53 +1,59 @@ import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; +import { Song, ToSingSong } from '@/types/song'; + interface GuestToSingState { - localToSingSongIds: string[]; - addSong: (songId: string) => void; - removeSong: (songId: string) => void; - swapSongs: (fromIndex: number, toIndex: number) => void; - clearSongs: () => void; + guestToSingSongs: ToSingSong[]; + addGuestToSingSong: (song: Song) => void; + removeGuestToSingSong: (songId: string) => void; + swapGuestToSingSongs: (targetId: string, moveIndex: number) => void; + clearGuestToSingSongs: () => void; } const GUEST_TO_SING_KEY = 'guest_to_sing'; const initialState = { - localToSingSongIds: [] as string[], + guestToSingSongs: [] as ToSingSong[], }; const useGuestToSingStore = create( persist( set => ({ ...initialState, - addSong: (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] }; }); }, - removeSong: (songId: string) => { + removeGuestToSingSong: (songId: string) => { set(state => ({ - localToSingSongIds: state.localToSingSongIds.filter(id => id !== songId), + guestToSingSongs: state.guestToSingSongs.filter(item => item.songs.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); - return { localToSingSongIds: newSongIds }; + if (moveIndex < 0 || moveIndex >= state.guestToSingSongs.length) return state; + 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); + + return { guestToSingSongs: newSongs }; }); }, - clearSongs: () => { + clearGuestToSingSongs: () => { set(initialState); }, }), diff --git a/apps/web/src/types/apiRoute.ts b/apps/web/src/types/apiRoute.ts index 239cc83..1de8512 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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7549fd..fe2df0c 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