- 전체 문장보다는 단어 단위로 검색해보세요
+ 단어 단위로 검색해주세요
@@ -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 };
+}