diff --git a/README.md b/README.md index 2a9cfc9b..4ae70696 100644 --- a/README.md +++ b/README.md @@ -9,15 +9,6 @@ Singcode는 평소 노래방에서 부르고 싶던 노래 번호를 저장하고, 당신만의 노래 리스트를 만들고, 좋아하는 곡을 저장할 수 있습니다.
Supabase를 활용한 자체 DB를 통해 금영, TJ 노래방의 번호를 한 눈에 확인할 수 있습니다. -
- -feed1 1 - -search_autocomplete_over_results - - -
- --- ## 📦 배포 diff --git a/apps/web/package.json b/apps/web/package.json index adcacf59..aa4687f5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "web", - "version": "2.12.0", + "version": "2.13.0", "type": "module", "private": true, "scripts": { diff --git a/apps/web/public/data/changelog.json b/apps/web/public/data/changelog.json index 102bfdce..09fba967 100644 --- a/apps/web/public/data/changelog.json +++ b/apps/web/public/data/changelog.json @@ -193,5 +193,13 @@ "매월 투표 결과가 확정되며, 순위와 득표 현황을 확인할 수 있습니다.", "이달의 아티스트로 선정된 가수의 곡에는 카드에 배지가 표시됩니다." ] + }, + "2.13.0": { + "title": "버전 2.13.0", + "date": "2026-09-01", + "message": [ + "일본곡의 한국어 번역이 우선해서 보이도록 수정했습니다.", + "게스트 상태일 때 추가한 부를 곡 목록이 로그인 시 호환됩니다." + ] } } diff --git a/apps/web/public/sitemap-0.xml b/apps/web/public/sitemap-0.xml index 9a8cc028..ee745516 100644 --- a/apps/web/public/sitemap-0.xml +++ b/apps/web/public/sitemap-0.xml @@ -1,6 +1,6 @@ -https://www.singcode.kr/manifest.webmanifest2026-08-28T16:24:56.791Zweekly0.7 -https://www.singcode.kr/patch-notes2026-08-28T16:24:56.793Zweekly0.7 -https://www.singcode.kr2026-08-28T16:24:56.794Zweekly0.7 +https://www.singcode.kr/manifest.webmanifest2026-08-31T07:03:47.228Zweekly0.7 +https://www.singcode.kr/patch-notes2026-08-31T07:03:47.229Zweekly0.7 +https://www.singcode.kr2026-08-31T07:03:47.229Zweekly0.7 \ No newline at end of file diff --git a/apps/web/src/app/api/songs/tosing/merge/route.ts b/apps/web/src/app/api/songs/tosing/merge/route.ts new file mode 100644 index 00000000..b0148045 --- /dev/null +++ b/apps/web/src/app/api/songs/tosing/merge/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from 'next/server'; + +import createClient from '@/lib/supabase/server'; +import { ApiResponse } from '@/types/apiRoute'; +import { getAuthenticatedUser } from '@/utils/getAuthenticatedUser'; + +// 게스트 목록은 localStorage에 무한정 쌓일 수 있어 한 번에 옮길 양을 막아둔다. +const MAX_MERGE_COUNT = 100; + +/** + * 게스트로 담아둔 부를 곡을 로그인 계정으로 옮긴다. + * + * `/songs/tosing/array`를 쓰지 않는 이유는 이 요청만 중복·유령 곡을 만나기 때문이다. + * 모달에서 담을 때는 `isInToSingList`가 클라이언트에서 걸러주지만, 병합은 이미 담아둔 + * 곡과 겹치고 브라우저가 오래 들고 있던 삭제된 곡 id도 섞인다. 둘 중 하나만 있어도 + * 배치 insert 전체가 깨지고, 그러면 로컬이 비워지지 않아 방문할 때마다 같은 실패를 + * 반복한다. 그래서 넣기 전에 서버에서 거른다. + */ +export async function POST( + request: Request, +): Promise>> { + try { + const supabase = await createClient(); + const userId = await getAuthenticatedUser(supabase); + + const { songIds } = await request.json(); + if (!Array.isArray(songIds) || songIds.length === 0) { + return NextResponse.json({ success: true, data: { merged: 0 } }); + } + + const ids = [...new Set(songIds)].slice(0, MAX_MERGE_COUNT); + + const { data: realSongs, error: songError } = await supabase + .from('songs') + .select('id') + .in('id', ids); + if (songError) throw songError; + + const { data: mine, error: mineError } = await supabase + .from('tosings') + .select('song_id, order_weight') + .eq('user_id', userId); + if (mineError) throw mineError; + + const realIds = new Set((realSongs ?? []).map(row => row.id)); + const mineIds = new Set((mine ?? []).map(row => row.song_id)); + + // 게스트가 잡아둔 순서를 유지한 채, 이미 담긴 곡과 사라진 곡만 걸러낸다 + const targets = ids.filter(id => realIds.has(id) && !mineIds.has(id)); + if (targets.length === 0) { + return NextResponse.json({ success: true, data: { merged: 0 } }); + } + + // 기존 목록 뒤에 붙인다 — 계정에 있던 순서가 밀리지 않게 + const lastWeight = (mine ?? []).reduce((max, row) => Math.max(max, row.order_weight), 0); + + const { error } = await supabase.from('tosings').insert( + targets.map((songId, index) => ({ + user_id: userId, + song_id: songId, + order_weight: lastWeight + index + 1, + })), + ); + if (error) throw error; + + return NextResponse.json({ success: true, data: { merged: targets.length } }); + } 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 merge API:', error); + return NextResponse.json( + { success: false, error: 'Failed to merge tosing songs' }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/app/info/like/SongItem.tsx b/apps/web/src/app/info/like/SongItem.tsx index 6d24d491..f534ccb0 100644 --- a/apps/web/src/app/info/like/SongItem.tsx +++ b/apps/web/src/app/info/like/SongItem.tsx @@ -2,6 +2,7 @@ import MarqueeText from '@/components/MarqueeText'; import { Checkbox } from '@/components/ui/checkbox'; import { AddListModalSong } from '@/types/song'; import { cn } from '@/utils/cn'; +import { splitDisplay } from '@/utils/songDisplay'; // 노래 항목 컴포넌트 export default function SongItem({ @@ -13,6 +14,9 @@ export default function SongItem({ isSelected: boolean; onToggleSelect: (id: string) => void; }) { + const titleParts = splitDisplay(song.title_ko, song.title); + const artistParts = splitDisplay(song.artist_ko, song.artist); + return (
onToggleSelect(song.song_id)} />
- {song.title} - {song.title_ko && song.title_ko !== song.title && ( - {song.title_ko} + {titleParts.primary} + {titleParts.secondary && ( + + {titleParts.secondary} + )} - {song.artist} - {song.artist_ko && song.artist_ko !== song.artist && ( - {song.artist_ko} + {artistParts.primary} + {artistParts.secondary && ( + + {artistParts.secondary} + )}
diff --git a/apps/web/src/app/info/promotions/page.tsx b/apps/web/src/app/info/promotions/page.tsx index 293e3003..5ed759d3 100644 --- a/apps/web/src/app/info/promotions/page.tsx +++ b/apps/web/src/app/info/promotions/page.tsx @@ -21,6 +21,7 @@ import { useDeleteUserPromotionMutation, useUserPromotionsQuery } from '@/querie import useAuthStore from '@/stores/useAuthStore'; import { SongPromotion } from '@/types/promotion'; import { getTodayKST } from '@/utils/kst'; +import { splitDisplay } from '@/utils/songDisplay'; function PromotionItem({ promotion, @@ -34,8 +35,8 @@ function PromotionItem({ const todayKST = getTodayKST(); const canCancel = promotion.start_date > todayKST; const { title, artist, title_ko, artist_ko } = promotion; - const hasKoTitle = title_ko && title_ko !== title; - const hasKoArtist = artist_ko && artist_ko !== artist; + const titleParts = splitDisplay(title_ko, title); + const artistParts = splitDisplay(artist_ko, artist); const statusLabel = (() => { if (promotion.end_date < todayKST) @@ -70,11 +71,13 @@ function PromotionItem({
-

{title}

- {hasKoTitle &&

{title_ko}

} -

{artist}

- {hasKoArtist && ( -

{artist_ko}

+

{titleParts.primary}

+ {titleParts.secondary && ( +

{titleParts.secondary}

+ )} +

{artistParts.primary}

+ {artistParts.secondary && ( +

{artistParts.secondary}

)}

@@ -175,10 +178,10 @@ export default function MyPromotionsPage() { {confirmTarget && (

- {confirmTarget.title_ko ?? confirmTarget.title} + {splitDisplay(confirmTarget.title_ko, confirmTarget.title).primary} {' · '} - {confirmTarget.artist_ko ?? confirmTarget.artist} + {splitDisplay(confirmTarget.artist_ko, confirmTarget.artist).primary}

diff --git a/apps/web/src/app/info/save/FolderCard.tsx b/apps/web/src/app/info/save/FolderCard.tsx index 993bb113..fc39b120 100644 --- a/apps/web/src/app/info/save/FolderCard.tsx +++ b/apps/web/src/app/info/save/FolderCard.tsx @@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Checkbox } from '@/components/ui/checkbox'; import { Separator } from '@/components/ui/separator'; import { SaveSongFolder } from '@/types/song'; +import { splitDisplay } from '@/utils/songDisplay'; interface IProps { folder: SaveSongFolder; @@ -96,33 +97,42 @@ export default function FolderCard({

{folder.songList.length > 0 ? (
- {folder.songList.map(song => ( -
- toggleSongSelection(song.song_id)} - /> -
-
- -
-

{song.title}

- {song.title_ko && song.title_ko !== song.title && ( -

{song.title_ko}

- )} -

{song.artist}

- {song.artist_ko && song.artist_ko !== song.artist && ( -

{song.artist_ko}

- )} + {folder.songList.map(song => { + const titleParts = splitDisplay(song.title_ko, song.title); + const artistParts = splitDisplay(song.artist_ko, song.artist); + + return ( +
+ toggleSongSelection(song.song_id)} + /> +
+
+ +
+

{titleParts.primary}

+ {titleParts.secondary && ( +

+ {titleParts.secondary} +

+ )} +

{artistParts.primary}

+ {artistParts.secondary && ( +

+ {artistParts.secondary} +

+ )} +
-
- ))} + ); + })}
) : (
diff --git a/apps/web/src/app/popular/ArtistVotePanel.tsx b/apps/web/src/app/popular/ArtistVotePanel.tsx index 74ca962e..c8fb99b4 100644 --- a/apps/web/src/app/popular/ArtistVotePanel.tsx +++ b/apps/web/src/app/popular/ArtistVotePanel.tsx @@ -244,7 +244,7 @@ export default function ArtistVotePanel() {
{!isAuthenticated && ( -
+

로그인하면 참여할 수 있어요

이 달의 아티스트를 직접 뽑아주세요.

diff --git a/apps/web/src/app/search/AddFolderModal.tsx b/apps/web/src/app/search/AddFolderModal.tsx index 38159b35..10cb06e4 100644 --- a/apps/web/src/app/search/AddFolderModal.tsx +++ b/apps/web/src/app/search/AddFolderModal.tsx @@ -23,6 +23,7 @@ import { } from '@/components/ui/select'; import { useSaveSongFolderQuery } from '@/queries/saveSongFolderQuery'; import { SaveSongFolderList, SearchSong } from '@/types/song'; +import { splitDisplay } from '@/utils/songDisplay'; interface IProps { modalType: '' | 'POST' | 'PATCH'; @@ -47,6 +48,8 @@ export default function AddFolderModal({ const [isExistingPlaylist, setIsExistingPlaylist] = useState(false); const { id: songId, title, artist, title_ko, artist_ko } = song; + const titleParts = splitDisplay(title_ko, title); + const artistParts = splitDisplay(artist_ko, artist); const LOGIC_TEXT = modalType === 'POST' ? '저장' : '수정'; @@ -113,13 +116,17 @@ export default function AddFolderModal({ {/* 곡 정보 */}
- {title} - {title_ko && title_ko !== title && ( - {title_ko} + {titleParts.primary} + {titleParts.secondary && ( + + {titleParts.secondary} + )} - {artist} - {artist_ko && artist_ko !== artist && ( - {artist_ko} + {artistParts.primary} + {artistParts.secondary && ( + + {artistParts.secondary} + )}
diff --git a/apps/web/src/app/search/HomePage.tsx b/apps/web/src/app/search/HomePage.tsx index 7ed25ea6..a6abae73 100644 --- a/apps/web/src/app/search/HomePage.tsx +++ b/apps/web/src/app/search/HomePage.tsx @@ -6,9 +6,7 @@ import { useInView } from 'react-intersection-observer'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; -// import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; -// import { Label } from '@/components/ui/label'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { TOUR_DEMO_SEARCH_TERM, TOUR_DEMO_SONG } from '@/constants/tourDemoSong'; import useSaveSongModal from '@/hooks/useSaveSongModal'; @@ -19,7 +17,6 @@ import { SearchSong, SearchType } from '@/types/song'; import { cn } from '@/utils/cn'; import AddFolderModal from './AddFolderModal'; -// import ChatBot from './ChatBot'; import JpnArtistList from './JpnArtistList'; import NumberKeypad from './NumberKeypad'; import PopularSearchHistory from './PopularSearchHistory'; @@ -65,11 +62,6 @@ export default function SearchPage() { const [isJpnArtistModalOpen, setIsJpnArtistModalOpen] = useState(false); const [isFocusAuto, setIsFocusAuto] = useState(false); const [isNumberKeypadOpen, setIsNumberKeypadOpen] = useState(false); - // const [isChatBotEnabled, setIsChatBotEnabled] = useState(() => { - // if (typeof window === 'undefined') return true; - // const stored = localStorage.getItem('chatbot-enabled'); - // return stored === null ? true : stored === 'true'; - // }); const [scrollRef, setScrollRef] = useState(null); const { ref, inView } = useInView({ @@ -79,11 +71,6 @@ export default function SearchPage() { const { guestToSingSongs } = useGuestToSingStore(); - // const handleToggleChatBot = (checked: boolean) => { - // setIsChatBotEnabled(checked); - // localStorage.setItem('chatbot-enabled', String(checked)); - // }; - const guestToSingIds = useMemo( () => new Set(guestToSingSongs?.map(item => item.songs.id)), [guestToSingSongs], @@ -398,7 +385,7 @@ export default function SearchPage() {
- 전체 문장보다는 단어 단위로 검색해보세요 + 단어 단위로 검색해주세요
@@ -419,9 +406,6 @@ export default function SearchPage() { /> )} - {/* 챗봇 위젯 */} - {/* {isChatBotEnabled && } */} - handleCopy(displayTitle)} + onClick={() => handleCopy(titleParts.primary)} > - {displayTitle} + {titleParts.primary} - {hasKoTitle && ( + {titleParts.secondary && ( handleCopy(title)} @@ -103,11 +102,11 @@ function SearchResultCard({ )} handleCopy(displayArtist)} + onClick={() => handleCopy(artistParts.primary)} > - {displayArtist} + {artistParts.primary} - {hasKoArtist && ( + {artistParts.secondary && ( handleCopy(artist)} diff --git a/apps/web/src/app/tosing/ModalSongItem.tsx b/apps/web/src/app/tosing/ModalSongItem.tsx index e6d81997..35017b18 100644 --- a/apps/web/src/app/tosing/ModalSongItem.tsx +++ b/apps/web/src/app/tosing/ModalSongItem.tsx @@ -2,6 +2,7 @@ import MarqueeText from '@/components/MarqueeText'; import { Checkbox } from '@/components/ui/checkbox'; import { AddListModalSong } from '@/types/song'; import { cn } from '@/utils/cn'; +import { splitDisplay } from '@/utils/songDisplay'; // 노래 항목 컴포넌트 export default function ModalSongItem({ @@ -13,6 +14,9 @@ export default function ModalSongItem({ isSelected: boolean; onToggleSelect: (id: string) => void; }) { + const titleParts = splitDisplay(song.title_ko, song.title); + const artistParts = splitDisplay(song.artist_ko, song.artist); + return (
- {song.title} - {song.title_ko && song.title_ko !== song.title && ( - {song.title_ko} + {titleParts.primary} + {titleParts.secondary && ( + + {titleParts.secondary} + )} - {song.artist} - {song.artist_ko && song.artist_ko !== song.artist && ( - {song.artist_ko} + {artistParts.primary} + {artistParts.secondary && ( + + {artistParts.secondary} + )}
diff --git a/apps/web/src/app/tosing/SongCard.tsx b/apps/web/src/app/tosing/SongCard.tsx index 71bb4b80..950e9aeb 100644 --- a/apps/web/src/app/tosing/SongCard.tsx +++ b/apps/web/src/app/tosing/SongCard.tsx @@ -38,7 +38,7 @@ export default function SongCard({ song, onDelete, onMoveToTop, onMoveToBottom } variant="ghost" size="icon" className={`h-13 flex-1 flex-col items-center justify-center`} - aria-label="삭제" + aria-label="최상위로 이동" onClick={onMoveToTop} > @@ -60,7 +60,7 @@ export default function SongCard({ song, onDelete, onMoveToTop, onMoveToBottom } variant="ghost" size="icon" className={`h-13 flex-1 flex-col items-center justify-center`} - aria-label="삭제" + aria-label="최하위로 이동" onClick={onMoveToBottom} > diff --git a/apps/web/src/auth.tsx b/apps/web/src/auth.tsx index 41165455..463126f0 100644 --- a/apps/web/src/auth.tsx +++ b/apps/web/src/auth.tsx @@ -3,6 +3,7 @@ import { usePathname, useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; +import useMergeGuestToSing from '@/hooks/useMergeGuestToSing'; import useAuthStore from '@/stores/useAuthStore'; const ALLOW_PATHS = [ @@ -24,6 +25,8 @@ export default function AuthProvider({ children }: { children: React.ReactNode } const { checkAuth } = useAuthStore(); const [isAuthChecked, setIsAuthChecked] = useState(false); + useMergeGuestToSing(); + useEffect(() => { const isPublicPath = ALLOW_PATHS.includes(pathname); diff --git a/apps/web/src/components/PromotionBanner.tsx b/apps/web/src/components/PromotionBanner.tsx index b2568095..0521156a 100644 --- a/apps/web/src/components/PromotionBanner.tsx +++ b/apps/web/src/components/PromotionBanner.tsx @@ -6,6 +6,7 @@ import { usePathname } from 'next/navigation'; import { useEffect, useState } from 'react'; import { useSongPromotionsQuery } from '@/queries/songPromotionQuery'; +import { splitDisplay } from '@/utils/songDisplay'; const ALLOWED_PATHS = ['/', '/popular', '/recent', '/tosing']; const COLLAPSED_STORAGE_KEY = 'promotion-banner-collapsed'; @@ -49,8 +50,8 @@ export default function PromotionBanner() { if (promotions.length === 0) return null; const current = promotions[currentIndex]; - const hasKoTitle = current.title_ko && current.title_ko !== current.title; - const hasKoArtist = current.artist_ko && current.artist_ko !== current.artist; + const titleParts = splitDisplay(current.title_ko, current.title); + const artistParts = splitDisplay(current.artist_ko, current.artist); return (
@@ -98,20 +99,20 @@ export default function PromotionBanner() { >
- {current.title} - {hasKoTitle && ( + {titleParts.primary} + {titleParts.secondary && ( - {current.title_ko} + {titleParts.secondary} )}
- {current.artist} + {artistParts.primary} - {hasKoArtist && ( + {artistParts.secondary && ( - {current.artist_ko} + {artistParts.secondary} )}
diff --git a/apps/web/src/components/ReportFieldCard.tsx b/apps/web/src/components/ReportFieldCard.tsx index 8bf413cd..83996e60 100644 --- a/apps/web/src/components/ReportFieldCard.tsx +++ b/apps/web/src/components/ReportFieldCard.tsx @@ -1,5 +1,6 @@ import { ReportCardField } from '@/types/report'; import { cn } from '@/utils/cn'; +import { splitDisplay } from '@/utils/songDisplay'; interface ReportFieldCardProps { title: string; @@ -12,16 +13,6 @@ interface ReportFieldCardProps { newValue: string | null; } -function splitDisplay( - translated: string | undefined, - original: string, -): { primary: string; secondary: string | null } { - if (translated && translated !== original) { - return { primary: translated, secondary: original }; - } - return { primary: original || '-', secondary: null }; -} - function NewValueIndicator({ isVisible, value, @@ -73,7 +64,9 @@ export default function ReportFieldCard({ >
- {titleParts.primary} + + {titleParts.primary || '-'} +
- {artistParts.primary} + {artistParts.primary || '-'} = cost; const canSubmit = days > 0 && content.trim().length > 0 && canAfford; - const displayTitle = title_ko && title_ko !== title ? title_ko : title; - const displayArtist = artist_ko && artist_ko !== artist ? artist_ko : artist; + const displayTitle = splitDisplay(title_ko, title).primary; + const displayArtist = splitDisplay(artist_ko, artist).primary; const handleProceed = () => { if (!canSubmit || !range?.from) return; diff --git a/apps/web/src/components/SongSummary.tsx b/apps/web/src/components/SongSummary.tsx index beeaff30..4279a574 100644 --- a/apps/web/src/components/SongSummary.tsx +++ b/apps/web/src/components/SongSummary.tsx @@ -7,6 +7,7 @@ import SongBadges from '@/components/SongBadges'; import { useCurrentArtistOfMonthQuery } from '@/queries/artistVoteQuery'; import { Song } from '@/types/song'; import { cn } from '@/utils/cn'; +import { splitDisplay } from '@/utils/songDisplay'; type SummarySong = Pick< Song, @@ -28,8 +29,8 @@ interface SongSummaryProps { export default function SongSummary({ song, className }: SongSummaryProps) { const { title, artist, title_ko, artist_ko, num_tj, num_ky, badges } = song; - const hasKoTitle = !!title_ko && title_ko !== title; - const hasKoArtist = !!artist_ko && artist_ko !== artist; + const titleParts = splitDisplay(title_ko, title); + const artistParts = splitDisplay(artist_ko, artist); // songs.artist는 "IU(Feat.최백호)"처럼 원문 그대로라, artists 마스터에 등록된 정규화된 // 이름과 곧이곧대로 비교하면 우승 아티스트의 피처링·듀엣 곡에 배지가 빠진다. @@ -43,14 +44,18 @@ export default function SongSummary({ song, className }: SongSummaryProps) {
- {title} - {hasKoTitle && ( - {title_ko} + {titleParts.primary} + {titleParts.secondary && ( + + {titleParts.secondary} + )} - {artist} - {hasKoArtist && ( - {artist_ko} + {artistParts.primary} + {artistParts.secondary && ( + + {artistParts.secondary} + )} {isArtistOfMonth && ( diff --git a/apps/web/src/hooks/useMergeGuestToSing.ts b/apps/web/src/hooks/useMergeGuestToSing.ts new file mode 100644 index 00000000..9798d151 --- /dev/null +++ b/apps/web/src/hooks/useMergeGuestToSing.ts @@ -0,0 +1,59 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { toast } from 'sonner'; + +import { useMergeGuestToSingMutation } from '@/queries/tosingSongQuery'; +import useAuthStore from '@/stores/useAuthStore'; +import useGuestToSingStore from '@/stores/useGuestToSingStore'; + +/** + * 게스트로 담아둔 부를 곡을 로그인 계정으로 옮긴다. + * + * "로그인 성공" 이벤트를 잡는 대신 "로그인 상태에서는 게스트 목록이 비어 있다"는 + * 불변식을 지킨다. 로그인 방식마다 끝나는 모습이 달라서다 — 이메일/비밀번호는 + * checkAuth()가 상태만 뒤집고 화면이 그대로 이어지는 반면, 카카오와 가입 확인 링크는 + * 서버 리다이렉트라 전체 페이지가 다시 뜬다. 상태 조건 하나면 두 경우가 함께 덮이고, + * 로그인 수단이 늘어도 checkAuth()만 거치면 따라온다. + * + * 성공했을 때만 로컬을 비운다 — 실패하면 다음 방문에서 다시 시도하고, 그 사이에도 + * 사용자의 곡은 localStorage에 그대로 남는다. + */ +export default function useMergeGuestToSing() { + const { isAuthenticated } = useAuthStore(); + const { guestToSingSongs, clearGuestToSingSongs } = useGuestToSingStore(); + const { mutate } = useMergeGuestToSingMutation(); + + // StrictMode의 이중 실행과 리렌더로 인한 중복 요청을 막는다 + const isMergingRef = useRef(false); + + useEffect(() => { + if (!isAuthenticated || guestToSingSongs.length === 0) return; + if (isMergingRef.current) return; + + isMergingRef.current = true; + mutate( + guestToSingSongs.map(item => item.songs.id), + { + onSuccess: response => { + if (!response.success) { + isMergingRef.current = false; + return; + } + + clearGuestToSingSongs(); + + const merged = response.data?.merged ?? 0; + if (merged > 0) { + toast.success('담아둔 곡을 옮겼어요', { + description: `부를 곡 목록에 ${merged}곡을 추가했어요.`, + }); + } + }, + onError: () => { + isMergingRef.current = false; + }, + }, + ); + }, [isAuthenticated, guestToSingSongs, mutate, clearGuestToSingSongs]); +} diff --git a/apps/web/src/lib/api/tosing.ts b/apps/web/src/lib/api/tosing.ts index ed25688a..8cf304dd 100644 --- a/apps/web/src/lib/api/tosing.ts +++ b/apps/web/src/lib/api/tosing.ts @@ -30,6 +30,14 @@ export async function postToSingSongArray(body: { songIds: string[] }) { return response.data; } +export async function postToSingSongMerge(body: { songIds: string[] }) { + const response = await instance.post>( + '/songs/tosing/merge', + body, + ); + return response.data; +} + export async function deleteToSingSong(body: { songId: string }) { const response = await instance.delete>('/songs/tosing', { data: body }); return response.data; diff --git a/apps/web/src/queries/tosingSongQuery.ts b/apps/web/src/queries/tosingSongQuery.ts index dd6f0baa..df8b9d23 100644 --- a/apps/web/src/queries/tosingSongQuery.ts +++ b/apps/web/src/queries/tosingSongQuery.ts @@ -5,6 +5,7 @@ import { getToSingSong, patchToSingSong, postToSingSongArray, + postToSingSongMerge, } from '@/lib/api/tosing'; import { ToSingSong } from '@/types/song'; @@ -46,6 +47,24 @@ export function usePostToSingSongMutation() { }); } +// 게스트로 담아둔 곡을 로그인 계정으로 병합 +// 실패해도 로컬을 비우지 않아야 재시도가 가능하므로, 성공 판정은 호출부에서 한다. +export function useMergeGuestToSingMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (songIds: string[]) => postToSingSongMerge({ songIds }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['toSingSong'] }); + queryClient.invalidateQueries({ queryKey: ['searchSong'] }); + }, + onError: error => { + // 사용자가 시킨 동작이 아니라 배경에서 도는 병합이라 alert로 막지 않는다 + console.error('게스트 부를 곡 병합 실패:', error); + }, + }); +} + // 부를 노래 삭제 export function useDeleteToSingSongMutation() { const queryClient = useQueryClient(); diff --git a/apps/web/src/utils/songDisplay.ts b/apps/web/src/utils/songDisplay.ts new file mode 100644 index 00000000..75c2ed66 --- /dev/null +++ b/apps/web/src/utils/songDisplay.ts @@ -0,0 +1,21 @@ +/** + * 곡 제목·아티스트를 화면에 그릴 때의 표시 우선순위. + * + * 번역(`title_ko`/`artist_ko`)이 있으면 그쪽을 큰 줄로 올리고 원어를 작은 줄로 내린다. + * 검색 화면만 한국어 우선이고 부를곡·즐겨찾기·홍보는 원어 우선이라, 검색에서 + * "요네즈 켄시"로 보고 담은 곡이 부를곡 목록에선 "米津玄師"로 떠 같은 곡을 눈으로 + * 못 찾는 문제가 있었다. 화면마다 복붙돼 있던 규칙을 여기 한 곳으로 모았으니 + * 우선순위를 바꿀 일이 생기면 이 함수만 고치면 된다. + * + * 번역이 없거나 원어와 같으면 `secondary`는 null이다 — 한국곡은 `title_ko`가 + * 비어 있어 지금과 똑같이 한 줄로만 그려진다. + */ +export function splitDisplay( + translated: string | null | undefined, + original: string, +): { primary: string; secondary: string | null } { + if (translated && translated !== original) { + return { primary: translated, secondary: original }; + } + return { primary: original, secondary: null }; +}