From f5987e9600e7961c8a850685fcef19194b1f6318 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Mon, 31 Aug 2026 16:09:47 +0900 Subject: [PATCH 1/9] =?UTF-8?q?chore=20:=20=EB=B9=84=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=ED=88=AC=ED=91=9C=20=ED=8C=A8=EB=84=90=20=EC=98=A4?= =?UTF-8?q?=EB=B2=84=EB=A0=88=EC=9D=B4=20=EA=B0=80=EB=A6=BC=20=EC=99=84?= =?UTF-8?q?=ED=99=94=20(#315)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로그인 안내 오버레이가 bg-background/70 + blur 2px라 뒤의 투표 패널이 거의 안 보였다. 무엇에 참여하는지 보여야 로그인 동기가 생기므로 /10 + blur 1px로 낮춘다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V3Qg1DFPBGjtDjhBN5CuAn --- apps/web/src/app/popular/ArtistVotePanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/popular/ArtistVotePanel.tsx b/apps/web/src/app/popular/ArtistVotePanel.tsx index 74ca962..c8fb99b 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 && ( -
+

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

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

From f0f0f6274c52cec25cf4cc21e8cf53d2e0ce99be Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Mon, 31 Aug 2026 16:09:56 +0900 Subject: [PATCH 2/9] =?UTF-8?q?chore=20:=20=EA=B2=80=EC=83=89=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=20=EB=AC=B8=EA=B5=AC=20=EA=B0=84=EA=B2=B0=ED=99=94=20?= =?UTF-8?q?=EB=B0=8F=20=EC=B1=97=EB=B4=87=20=EC=A3=BC=EC=84=9D=20=EC=9E=94?= =?UTF-8?q?=EC=9E=AC=20=EC=A0=9C=EA=B1=B0=20(#315)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "전체 문장보다는 단어 단위로 검색해보세요"를 "단어 단위로 검색해주세요"로 줄인다. 안내 문구가 길면 정작 읽지 않는다. 함께 주석 처리된 채 남아 있던 ChatBot 위젯 관련 코드(import, 토글 상태, localStorage 핸들러, 렌더 블록)를 삭제한다. 되살릴 계획이 없어 git 이력에만 남기면 된다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V3Qg1DFPBGjtDjhBN5CuAn --- apps/web/src/app/search/HomePage.tsx | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/apps/web/src/app/search/HomePage.tsx b/apps/web/src/app/search/HomePage.tsx index 7ed25ea..a6abae7 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 && } */} - Date: Mon, 31 Aug 2026 16:10:11 +0900 Subject: [PATCH 3/9] =?UTF-8?q?fix=20:=20=EA=B3=A1=20=EC=A0=9C=EB=AA=A9?= =?UTF-8?q?=C2=B7=EC=95=84=ED=8B=B0=EC=8A=A4=ED=8A=B8=20=ED=91=9C=EA=B8=B0?= =?UTF-8?q?=EB=A5=BC=20=ED=95=9C=EA=B5=AD=EC=96=B4=20=EC=9A=B0=EC=84=A0?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=ED=86=B5=EC=9D=BC=20(#315)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 검색 화면만 title_ko를 큰 줄에 올리고 부를곡·즐겨찾기·재생목록·홍보는 원어를 큰 줄에 두고 있었다. 검색에서 "요네즈 켄시"를 보고 담은 곡이 부를곡 목록에선 "米津玄師"로 떠, 한자를 못 읽는 사용자가 자기가 담은 곡을 눈으로 찾지 못했다. 일본곡 번역을 채워둔 의미가 검색에서만 살고 정작 노래방에서 보는 화면에서는 죽던 셈이다. 화면마다 복붙돼 있던 규칙을 utils/songDisplay.ts의 splitDisplay 하나로 모으고 한국어 우선으로 통일한다. 우선순위를 다시 바꿀 일이 생기면 이 함수만 고치면 된다. title_ko가 비어 있는 한국곡·영미곡은 예전과 똑같이 한 줄로 그려진다. 이미 한국어 우선이던 SearchResultCard·ReportFieldCard·SongPromotionModal은 각자 갖고 있던 같은 로직을 유틸로 교체만 했다(동작 변화 없음). 내 홍보 화면의 취소 다이얼로그는 `title_ko ?? title`이라 title_ko가 원어와 같은 값일 때를 거르지 못했는데, 이 과정에서 같은 규칙으로 맞춰졌다. 이달의 아티스트 배지 판정은 원어(getPrimaryArtistName(artist))를 그대로 쓴다. artists 마스터가 TJ 표기라 번역된 이름으로 비교하면 배지가 빠진다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V3Qg1DFPBGjtDjhBN5CuAn --- apps/web/src/app/info/like/SongItem.tsx | 20 +++++-- apps/web/src/app/info/promotions/page.tsx | 21 ++++--- apps/web/src/app/info/save/FolderCard.tsx | 58 +++++++++++-------- apps/web/src/app/search/AddFolderModal.tsx | 19 ++++-- apps/web/src/app/search/SearchResultCard.tsx | 19 +++--- apps/web/src/app/tosing/ModalSongItem.tsx | 20 +++++-- apps/web/src/components/PromotionBanner.tsx | 17 +++--- apps/web/src/components/ReportFieldCard.tsx | 17 ++---- .../web/src/components/SongPromotionModal.tsx | 5 +- apps/web/src/components/SongSummary.tsx | 21 ++++--- apps/web/src/utils/songDisplay.ts | 21 +++++++ 11 files changed, 147 insertions(+), 91 deletions(-) create mode 100644 apps/web/src/utils/songDisplay.ts diff --git a/apps/web/src/app/info/like/SongItem.tsx b/apps/web/src/app/info/like/SongItem.tsx index 6d24d49..f534ccb 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 293e300..5ed759d 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 993bb11..fc39b12 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/search/AddFolderModal.tsx b/apps/web/src/app/search/AddFolderModal.tsx index 38159b3..10cb06e 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/SearchResultCard.tsx b/apps/web/src/app/search/SearchResultCard.tsx index 11c7993..6911244 100644 --- a/apps/web/src/app/search/SearchResultCard.tsx +++ b/apps/web/src/app/search/SearchResultCard.tsx @@ -13,6 +13,7 @@ import { Card } from '@/components/ui/card'; import { Dialog, DialogContent } from '@/components/ui/dialog'; import useAuthStore from '@/stores/useAuthStore'; import { SearchSong } from '@/types/song'; +import { splitDisplay } from '@/utils/songDisplay'; import SongActionButtons from './SongActionButtons'; @@ -41,10 +42,8 @@ function SearchResultCard({ onClickSave, }: SearchResultCardProps) { const { id, 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 displayTitle = hasKoTitle ? title_ko : title; - const displayArtist = hasKoArtist ? artist_ko : artist; + const titleParts = splitDisplay(title_ko, title); + const artistParts = splitDisplay(artist_ko, artist); const { isAuthenticated } = useAuthStore(); @@ -89,11 +88,11 @@ function SearchResultCard({ 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 e6d8199..35017b1 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/components/PromotionBanner.tsx b/apps/web/src/components/PromotionBanner.tsx index b256809..0521156 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 8bf413c..83996e6 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 beeaff3..4279a57 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/utils/songDisplay.ts b/apps/web/src/utils/songDisplay.ts new file mode 100644 index 0000000..75c2ed6 --- /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 }; +} From 65a8824817d49c5dc047924f8548e788320c2c6d Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Mon, 31 Aug 2026 16:10:18 +0900 Subject: [PATCH 4/9] =?UTF-8?q?fix=20:=20=EB=B6=80=EB=A5=BC=20=EA=B3=A1=20?= =?UTF-8?q?=EC=88=9C=EC=84=9C=20=EC=9D=B4=EB=8F=99=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?aria-label=20=EC=88=98=EC=A0=95=20(#315)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 최상위 이동·삭제·최하위 이동 세 버튼의 aria-label이 전부 "삭제"라 스크린리더로는 어느 것이 삭제인지 구분할 수 없었다. 복붙 실수다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V3Qg1DFPBGjtDjhBN5CuAn --- apps/web/src/app/tosing/SongCard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/tosing/SongCard.tsx b/apps/web/src/app/tosing/SongCard.tsx index 71bb4b8..950e9ae 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} > From 3da3154ba1fa15f6b605c78270b426263696a59e Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Mon, 31 Aug 2026 16:10:18 +0900 Subject: [PATCH 5/9] =?UTF-8?q?chore=20:=20sitemap=20=EC=9E=AC=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20(#315)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 빌드 검증 과정에서 next-sitemap postbuild가 lastmod를 갱신했다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V3Qg1DFPBGjtDjhBN5CuAn --- apps/web/public/sitemap-0.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/public/sitemap-0.xml b/apps/web/public/sitemap-0.xml index 9a8cc02..ee74551 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 From 50907c1435c55468670fb30206bc3fe4451cb804 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Mon, 31 Aug 2026 16:12:06 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat=20:=20=EA=B2=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B6=80=EB=A5=BC=20=EA=B3=A1=EC=9D=84=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EA=B3=84=EC=A0=95=EC=9C=BC=EB=A1=9C=20=EB=B3=91?= =?UTF-8?q?=ED=95=A9=20(#317)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로그아웃 상태에서 담은 부를 곡이 로그인하는 순간 화면에서 사라졌다. useToSingSongQuery가 isAuthenticated로 queryKey와 queryFn을 통째로 갈아타 서버 응답만 반환하는데, localStorage의 게스트 목록을 서버로 옮기는 코드가 없었다(clearGuestToSingSongs는 어디에서도 호출되지 않았다). 곡을 열심히 담고 로그인한 순간 목록이 비어 보이는, 가장 아까운 이탈 지점이었다. 데이터 자체는 지워지지 않았으므로 이 변경은 앞으로의 유입뿐 아니라 과거에 목록을 잃었던 사용자도 같은 기기에서 자동 복구한다. 카카오 로그인은 /api/auth/callback에서 서버 리다이렉트로 끝나 전체 페이지가 다시 뜬다. 클라이언트에 "로그인 성공" 시점이 없어 전환을 감지하는 대신 "로그인 상태에서는 게스트 목록이 비어 있다"는 불변식을 지킨다. 성공했을 때만 로컬을 비우므로 실패해도 곡은 남고 다음 방문에서 다시 시도한다. 기존 /songs/tosing/array를 쓰지 않고 /songs/tosing/merge를 따로 둔 이유는 이 요청만 중복·유령 곡을 만나기 때문이다. 모달에서 담을 때는 isInToSingList가 클라이언트에서 걸러주지만, 병합은 이미 담아둔 곡과 겹치고 브라우저가 오래 들고 있던 삭제된 곡 id도 섞인다. 둘 중 하나만 있어도 배치 insert 전체가 깨지고, 그러면 로컬이 비워지지 않아 방문할 때마다 같은 실패를 반복한다. 서버에서 실재하는 곡만 남기고 이미 담긴 곡은 건너뛴 뒤, 게스트가 잡아둔 순서를 유지한 채 기존 목록 뒤에 붙인다. 로그아웃 시에는 아무것도 하지 않는다 — 서버 목록을 로컬로 복사하면 공용 기기에 남는다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V3Qg1DFPBGjtDjhBN5CuAn --- .../src/app/api/songs/tosing/merge/route.ts | 81 +++++++++++++++++++ apps/web/src/auth.tsx | 3 + apps/web/src/hooks/useMergeGuestToSing.ts | 57 +++++++++++++ apps/web/src/lib/api/tosing.ts | 8 ++ apps/web/src/queries/tosingSongQuery.ts | 19 +++++ 5 files changed, 168 insertions(+) create mode 100644 apps/web/src/app/api/songs/tosing/merge/route.ts create mode 100644 apps/web/src/hooks/useMergeGuestToSing.ts 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 0000000..b014804 --- /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/auth.tsx b/apps/web/src/auth.tsx index 4116545..463126f 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/hooks/useMergeGuestToSing.ts b/apps/web/src/hooks/useMergeGuestToSing.ts new file mode 100644 index 0000000..5d537fa --- /dev/null +++ b/apps/web/src/hooks/useMergeGuestToSing.ts @@ -0,0 +1,57 @@ +'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'; + +/** + * 게스트로 담아둔 부를 곡을 로그인 계정으로 옮긴다. + * + * 카카오 로그인은 `/api/auth/callback`에서 서버 리다이렉트로 끝나 전체 페이지가 다시 뜬다. + * 클라이언트에 "로그인 성공" 시점이 없으므로 전환을 감지하는 대신 "로그인 상태에서는 + * 게스트 목록이 비어 있다"는 불변식을 지킨다. + * + * 성공했을 때만 로컬을 비운다 — 실패하면 다음 방문에서 다시 시도하고, 그 사이에도 + * 사용자의 곡은 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 ed25688..8cf304d 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 dd6f0ba..df8b9d2 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(); From e7ad3ab84016882792d641d9ed60c9f6ddc6a22a Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Mon, 31 Aug 2026 17:08:00 +0900 Subject: [PATCH 7/9] =?UTF-8?q?doc=20:=20=EA=B2=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B3=91=ED=95=A9=20=ED=9B=85=20=EC=A3=BC=EC=84=9D=EC=9D=84=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=B0=A9=EC=8B=9D=20=EC=A0=84?= =?UTF-8?q?=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EC=9D=BC=EB=B0=98=ED=99=94=20(#3?= =?UTF-8?q?17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 카카오 로그인만 예로 들고 있어, 이메일/비밀번호 로그인은 따로 처리해야 하는 것처럼 읽힐 수 있었다. 실제로는 두 경우 모두 checkAuth()가 isAuthenticated를 뒤집는 지점을 지나므로 같은 상태 조건 하나로 덮인다. 방식마다 끝나는 모습이 다르다는 점(상태만 바뀜 vs 전체 리로드)과, 그래서 이벤트가 아니라 불변식을 쓴다는 점을 함께 적는다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V3Qg1DFPBGjtDjhBN5CuAn --- apps/web/src/hooks/useMergeGuestToSing.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/web/src/hooks/useMergeGuestToSing.ts b/apps/web/src/hooks/useMergeGuestToSing.ts index 5d537fa..9798d15 100644 --- a/apps/web/src/hooks/useMergeGuestToSing.ts +++ b/apps/web/src/hooks/useMergeGuestToSing.ts @@ -10,9 +10,11 @@ import useGuestToSingStore from '@/stores/useGuestToSingStore'; /** * 게스트로 담아둔 부를 곡을 로그인 계정으로 옮긴다. * - * 카카오 로그인은 `/api/auth/callback`에서 서버 리다이렉트로 끝나 전체 페이지가 다시 뜬다. - * 클라이언트에 "로그인 성공" 시점이 없으므로 전환을 감지하는 대신 "로그인 상태에서는 - * 게스트 목록이 비어 있다"는 불변식을 지킨다. + * "로그인 성공" 이벤트를 잡는 대신 "로그인 상태에서는 게스트 목록이 비어 있다"는 + * 불변식을 지킨다. 로그인 방식마다 끝나는 모습이 달라서다 — 이메일/비밀번호는 + * checkAuth()가 상태만 뒤집고 화면이 그대로 이어지는 반면, 카카오와 가입 확인 링크는 + * 서버 리다이렉트라 전체 페이지가 다시 뜬다. 상태 조건 하나면 두 경우가 함께 덮이고, + * 로그인 수단이 늘어도 checkAuth()만 거치면 따라온다. * * 성공했을 때만 로컬을 비운다 — 실패하면 다음 방문에서 다시 시도하고, 그 사이에도 * 사용자의 곡은 localStorage에 그대로 남는다. From 2a38d1be8f89e0bc4d0c7c976bb0eda9e6f9bff1 Mon Sep 17 00:00:00 2001 From: sham <72376700+GulSam00@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:34:26 +0900 Subject: [PATCH 8/9] Update README.md --- README.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/README.md b/README.md index 2a9cfc9..4ae7069 100644 --- a/README.md +++ b/README.md @@ -9,15 +9,6 @@ Singcode는 평소 노래방에서 부르고 싶던 노래 번호를 저장하고, 당신만의 노래 리스트를 만들고, 좋아하는 곡을 저장할 수 있습니다.
Supabase를 활용한 자체 DB를 통해 금영, TJ 노래방의 번호를 한 눈에 확인할 수 있습니다. -
- -feed1 1 - -search_autocomplete_over_results - - -
- --- ## 📦 배포 From a0b68d33bc730600adb7c73e18f126572ac164c2 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Tue, 1 Sep 2026 17:14:53 +0900 Subject: [PATCH 9/9] =?UTF-8?q?chore=20:=20=EB=B2=84=EC=A0=84=202.13.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/package.json | 2 +- apps/web/public/data/changelog.json | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/web/package.json b/apps/web/package.json index adcacf5..aa4687f 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 102bfdc..09fba96 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": [ + "일본곡의 한국어 번역이 우선해서 보이도록 수정했습니다.", + "게스트 상태일 때 추가한 부를 곡 목록이 로그인 시 호환됩니다." + ] } }