Skip to content
Merged
6 changes: 3 additions & 3 deletions apps/web/public/sitemap-0.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url><loc>https://www.singcode.kr/manifest.webmanifest</loc><lastmod>2026-05-19T13:17:55.218Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
<url><loc>https://www.singcode.kr</loc><lastmod>2026-05-19T13:17:55.220Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
<url><loc>https://www.singcode.kr/patch-notes</loc><lastmod>2026-05-19T13:17:55.220Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
<url><loc>https://www.singcode.kr/manifest.webmanifest</loc><lastmod>2026-05-25T11:26:49.726Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
<url><loc>https://www.singcode.kr/patch-notes</loc><lastmod>2026-05-25T11:26:49.727Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
<url><loc>https://www.singcode.kr</loc><lastmod>2026-05-25T11:26:49.727Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
</urlset>
9 changes: 8 additions & 1 deletion apps/web/src/app/api/search/log/route.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { subDays } from 'date-fns';
import { NextResponse } from 'next/server';

import createClient from '@/lib/supabase/server';
Expand All @@ -11,7 +12,13 @@ interface SearchLogCount {
export async function GET(): Promise<NextResponse<ApiResponse<SearchLogCount[]>>> {
try {
const supabase = await createClient();
const { data, error } = await supabase.from('search_logs').select('text');

// 최근 15일간의 검색 로그만 집계
const fifteenDaysAgo = subDays(new Date(), 15).toISOString();
const { data, error } = await supabase
.from('search_logs')
.select('text')
.gte('created_at', fifteenDaysAgo);

if (error) throw error;

Expand Down
55 changes: 51 additions & 4 deletions apps/web/src/app/api/search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,44 @@ interface DBSong extends Song {

function applyExactFilter(baseQuery: any, type: string, searchText: string) {
if (type === 'all') {
return baseQuery.or(`title.ilike.${searchText},artist.ilike.${searchText}`);
return baseQuery.or(
`title.ilike.${searchText},title_ko.ilike.${searchText},artist.ilike.${searchText},artist_ko.ilike.${searchText}`,
);
}
if (type === 'title') {
return baseQuery.or(`title.ilike.${searchText},title_ko.ilike.${searchText}`);
}
if (type === 'artist') {
return baseQuery.or(`artist.ilike.${searchText},artist_ko.ilike.${searchText}`);
}
if (type === 'number') {
return baseQuery.or(`num_tj.eq.${searchText},num_ky.eq.${searchText}`);
}
return baseQuery.ilike(type, searchText);
}

function applyPartialFilter(baseQuery: any, type: string, searchText: string) {
if (type === 'all') {
return baseQuery
.or(`title.ilike.%${searchText}%,artist.ilike.%${searchText}%`)
.or(
`title.ilike.%${searchText}%,title_ko.ilike.%${searchText}%,artist.ilike.%${searchText}%,artist_ko.ilike.%${searchText}%`,
)
.not('title', 'ilike', searchText)
.not('artist', 'ilike', searchText);
.not('title_ko', 'ilike', searchText)
.not('artist', 'ilike', searchText)
.not('artist_ko', 'ilike', searchText);
}
if (type === 'title') {
return baseQuery
.or(`title.ilike.%${searchText}%,title_ko.ilike.%${searchText}%`)
.not('title', 'ilike', searchText)
.not('title_ko', 'ilike', searchText);
}
if (type === 'artist') {
return baseQuery
.or(`artist.ilike.%${searchText}%,artist_ko.ilike.%${searchText}%`)
.not('artist', 'ilike', searchText)
.not('artist_ko', 'ilike', searchText);
}
return baseQuery.ilike(type, `%${searchText}%`).not(type, 'ilike', searchText);
}
Expand All @@ -50,6 +77,26 @@ async function executeSearchQueries(
): Promise<{ data: DBSong[]; hasNext: boolean } | { error: string }> {
const size = to - from + 1;

// 번호 검색은 정확 매칭만 지원 (부분 일치 단계 스킵)
if (type === 'number') {
const exactCountResult = await applyExactFilter(
supabase.from('songs').select(selectClause, { count: 'exact', head: true }),
type,
query,
);
if (exactCountResult.error) return { error: exactCountResult.error.message };
const exactTotal = exactCountResult.count ?? 0;

const exactQuery = applyExactFilter(supabase.from('songs').select(selectClause), type, query);
const { data, error } = await exactQuery.order(order).range(from, to);
if (error) return { error: error.message };

return {
data: (data as DBSong[]) ?? [],
hasNext: exactTotal > to + 1,
};
}

// 1. 정확 일치 / 부분 일치 각각의 총 개수를 병렬로 조회
const exactCountQuery = applyExactFilter(
supabase.from('songs').select(selectClause, { count: 'exact', head: true }),
Expand Down Expand Up @@ -134,7 +181,7 @@ export async function GET(request: Request): Promise<NextResponse<ApiResponse<Se
const { searchParams } = new URL(request.url);
const query = searchParams.get('q');
const type = searchParams.get('type') || 'title';
const order = type === 'all' ? 'title' : type;
const order = type === 'all' ? 'title' : type === 'number' ? 'num_tj' : type;
const authenticated = searchParams.get('authenticated') === 'true';

const page = parseInt(searchParams.get('page') || '0', 10);
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/app/api/songs/like/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export async function GET(): Promise<NextResponse<ApiResponse<PersonalSong[]>>>
created_at: item.created_at,
title: item.songs.title,
artist: item.songs.artist,
title_ko: item.songs.title_ko,
artist_ko: item.songs.artist_ko,
num_tj: item.songs.num_tj,
num_ky: item.songs.num_ky,
}));
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/app/api/songs/save/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export async function GET(): Promise<NextResponse<ApiResponse<SaveSong[]>>> {
updated_at: item.updated_at,
title: item.songs.title,
artist: item.songs.artist,
title_ko: item.songs.title_ko,
artist_ko: item.songs.artist_ko,
num_tj: item.songs.num_tj,
num_ky: item.songs.num_ky,
}));
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/app/info/like/SongItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,13 @@ export default function SongItem({
/>
<div className="min-w-0 flex-1">
<MarqueeText className="text-sm font-medium">{song.title}</MarqueeText>
{song.title_ko && song.title_ko !== song.title && (
<MarqueeText className="text-muted-foreground text-xs">{song.title_ko}</MarqueeText>
)}
<MarqueeText className="text-muted-foreground text-xs">{song.artist}</MarqueeText>
{song.artist_ko && song.artist_ko !== song.artist && (
<MarqueeText className="text-muted-foreground/70 text-xs">{song.artist_ko}</MarqueeText>
)}
</div>
</div>
);
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/app/info/save/FolderCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,13 @@ export default function FolderCard({
<Music className="text-muted-foreground h-4 w-4 shrink-0" />
<div>
<p className="text-sm font-medium">{song.title}</p>
{song.title_ko && song.title_ko !== song.title && (
<p className="text-muted-foreground text-xs">{song.title_ko}</p>
)}
<p className="text-muted-foreground text-xs">{song.artist}</p>
{song.artist_ko && song.artist_ko !== song.artist && (
<p className="text-muted-foreground/70 text-xs">{song.artist_ko}</p>
)}
</div>
</div>
</div>
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/app/search/AddFolderModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export default function AddFolderModal({
const [folderName, setFolderName] = useState<string>('');
const [isExistingPlaylist, setIsExistingPlaylist] = useState(false);

const { id: songId, title, artist } = song;
const { id: songId, title, artist, title_ko, artist_ko } = song;

const LOGIC_TEXT = modalType === 'POST' ? '저장' : '수정';

Expand Down Expand Up @@ -114,7 +114,13 @@ export default function AddFolderModal({
{/* 곡 정보 */}
<div className="bg-muted mb-4 rounded-md p-3">
<MarqueeText className="text-base font-medium">{title}</MarqueeText>
{title_ko && title_ko !== title && (
<MarqueeText className="text-muted-foreground text-xs">{title_ko}</MarqueeText>
)}
<MarqueeText className="text-muted-foreground text-sm">{artist}</MarqueeText>
{artist_ko && artist_ko !== artist && (
<MarqueeText className="text-muted-foreground/70 text-xs">{artist_ko}</MarqueeText>
)}
</div>

<div className="w-full space-y-4 py-2">
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/app/search/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ export default function SearchPage() {
return '노래 제목 검색';
case 'artist':
return '가수 이름 검색';
case 'number':
return '노래방 번호 검색 (TJ/KY)';
default:
return '전체 키워드 검색';
}
Expand Down Expand Up @@ -169,12 +171,13 @@ export default function SearchPage() {
</div>

<Tabs defaultValue="all" value={searchType} onValueChange={handleSearchTypeChange}>
<TabsList className="dark:bg-muted/50 grid w-full grid-cols-3 dark:border">
<TabsList className="dark:bg-muted/50 grid w-full grid-cols-4 dark:border">
{(
[
['all', '전체'],
['title', '제목'],
['artist', '가수'],
['number', '번호'],
] as const
).map(([value, label]) => (
<TabsTrigger
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/app/search/SearchResultCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ export default function SearchResultCard({
songId={id}
title={title}
artist={artist}
title_ko={title_ko}
artist_ko={artist_ko}
thumb={thumb || 0}
handleClose={() => setOpen(false)}
/>
Expand Down Expand Up @@ -259,7 +261,7 @@ export default function SearchResultCard({
</AnimatePresence>

<Dialog open={promotionOpen} onOpenChange={setPromotionOpen}>
<DialogContent>
<DialogContent className="h-[600px] max-h-[calc(100dvh-2rem)] overflow-y-auto">
<SongPromotionModal
songId={id}
title={title}
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/app/tosing/ModalSongItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,13 @@ export default function ModalSongItem({
/>
<div className="min-w-0 flex-1">
<MarqueeText className="text-sm font-medium">{song.title}</MarqueeText>
{song.title_ko && song.title_ko !== song.title && (
<MarqueeText className="text-muted-foreground text-xs">{song.title_ko}</MarqueeText>
)}
<MarqueeText className="text-muted-foreground text-xs">{song.artist}</MarqueeText>
{song.artist_ko && song.artist_ko !== song.artist && (
<MarqueeText className="text-muted-foreground/70 text-xs">{song.artist_ko}</MarqueeText>
)}
</div>
</div>
);
Expand Down
10 changes: 5 additions & 5 deletions apps/web/src/components/PromotionBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export default function PromotionBanner() {
transition={{ duration: 0.25, ease: 'easeInOut' }}
className="overflow-hidden"
>
<div className="h-24 overflow-hidden">
<div className="h-32 overflow-hidden">
<AnimatePresence mode="wait">
<motion.div
key={current.id}
Expand All @@ -97,20 +97,20 @@ export default function PromotionBanner() {
className="flex flex-col gap-2"
>
<div className="flex flex-col gap-0.5">
<div className="flex items-baseline gap-1.5 truncate">
<div className="flex flex-col">
<span className="truncate text-sm font-semibold">{current.title}</span>
{hasKoTitle && (
<span className="text-muted-foreground shrink-0 truncate text-xs">
<span className="text-muted-foreground truncate text-xs">
{current.title_ko}
</span>
)}
</div>
<div className="flex items-baseline gap-1.5 truncate">
<div className="flex flex-col">
<span className="text-muted-foreground truncate text-sm">
{current.artist}
</span>
{hasKoArtist && (
<span className="text-muted-foreground/70 shrink-0 truncate text-xs">
<span className="text-muted-foreground/70 truncate text-xs">
{current.artist_ko}
</span>
)}
Expand Down
14 changes: 13 additions & 1 deletion apps/web/src/components/RankingItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ interface RankingItemProps {
rank: number;
title: string;
artist: string;
title_ko?: string;
artist_ko?: string;
num_tj: string;
num_ky: string;
value: number;
Expand All @@ -25,6 +27,8 @@ export default function RankingItem({
rank,
title,
artist,
title_ko,
artist_ko,
num_tj,
num_ky,
value,
Expand Down Expand Up @@ -68,7 +72,13 @@ export default function RankingItem({
<div className="flex w-full justify-between gap-2">
<div className="w-[100px] shrink-0">
<MarqueeText className="text-sm font-medium">{title}</MarqueeText>
{title_ko && title_ko !== title && (
<MarqueeText className="text-muted-foreground text-xs">{title_ko}</MarqueeText>
)}
<MarqueeText className="text-muted-foreground text-xs">{artist}</MarqueeText>
{artist_ko && artist_ko !== artist && (
<MarqueeText className="text-muted-foreground/70 text-xs">{artist_ko}</MarqueeText>
)}
</div>

<div>
Expand All @@ -82,7 +92,7 @@ export default function RankingItem({
</div>
</div>

<div className="flex items-center gap-2">
<div className="flex gap-2">
<Dialog open={open} onOpenChange={setOpen}>
<Button
variant="ghost"
Expand All @@ -102,6 +112,8 @@ export default function RankingItem({
songId={id}
title={title}
artist={artist}
title_ko={title_ko}
artist_ko={artist_ko}
thumb={value}
handleClose={() => setOpen(false)}
/>
Expand Down
4 changes: 1 addition & 3 deletions apps/web/src/components/SongPromotionModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,7 @@ export default function SongPromotionModal({
<div className="flex flex-col gap-4 sm:max-w-md">
<DialogHeader>
<DialogTitle>곡 홍보하기</DialogTitle>
<DialogDescription>
하루 50P를 소모해 검색 페이지 전광판에 곡을 홍보하세요.
</DialogDescription>
<DialogDescription>하루 단위로 전광판에 곡을 홍보할 수 있어요.</DialogDescription>
</DialogHeader>

<div className="text-muted-foreground flex flex-col gap-0.5 rounded-md border p-3 text-sm">
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/components/ThumbUpModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ interface ThumbUpModalProps {
songId: string;
title: string;
artist: string;
title_ko?: string;
artist_ko?: string;
thumb: number;
handleClose: () => void;
}
Expand All @@ -28,6 +30,8 @@ export default function ThumbUpModal({
songId,
title,
artist,
title_ko,
artist_ko,
thumb,
handleClose,
}: ThumbUpModalProps) {
Expand Down Expand Up @@ -118,7 +122,13 @@ export default function ThumbUpModal({
<FallingIcons count={value[0]} />
<div className="absolute top-0 left-0 flex h-full w-full flex-col items-center justify-center px-4 text-center opacity-50">
<div className="text-lg font-bold">{title}</div>
{title_ko && title_ko !== title && (
<div className="text-muted-foreground text-sm">{title_ko}</div>
)}
<div className="text-muted-foreground text-sm">{artist}</div>
{artist_ko && artist_ko !== artist && (
<div className="text-muted-foreground/70 text-xs">{artist_ko}</div>
)}
</div>
</div>

Expand Down
3 changes: 1 addition & 2 deletions apps/web/src/hooks/useSaveSongModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@ import { useSaveMutation } from '@/queries/searchSongQuery';
import useAuthStore from '@/stores/useAuthStore';
import useFooterAnimateStore from '@/stores/useFooterAnimateStore';
import { Method } from '@/types/common';
import { SearchSong } from '@/types/song';
import { SearchSong, SearchType } from '@/types/song';

type SaveModalType = '' | 'POST' | 'PATCH';
type SearchType = 'all' | 'title' | 'artist';

export default function useSaveSongModal(query: string, queryType: SearchType) {
const { isAuthenticated } = useAuthStore();
Expand Down
4 changes: 1 addition & 3 deletions apps/web/src/hooks/useSearchSong.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,9 @@ import useFooterAnimateStore from '@/stores/useFooterAnimateStore';
import useGuestToSingStore from '@/stores/useGuestToSingStore';
import useSearchHistoryStore from '@/stores/useSearchHistoryStore';
import { Method } from '@/types/common';
import { Song } from '@/types/song';
import { SearchType, Song } from '@/types/song';
import { getAutoCompleteSuggestions } from '@/utils/getArtistAlias';

type SearchType = 'all' | 'title' | 'artist';

export default function useSearchSong() {
const { isAuthenticated } = useAuthStore();

Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/types/song.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export type SearchType = 'all' | 'title' | 'artist' | 'number';

export interface Song {
id: string;
title: string;
Expand Down
Loading