Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,6 @@
Singcode는 평소 노래방에서 부르고 싶던 노래 번호를 저장하고, 당신만의 노래 리스트를 만들고, 좋아하는 곡을 저장할 수 있습니다. <br/>
Supabase를 활용한 자체 DB를 통해 금영, TJ 노래방의 번호를 한 눈에 확인할 수 있습니다.

<div style="display: flex; justify-content: center; gap: 10px; flex-wrap: wrap;">

<img width="1080" height="1350" alt="feed1 1" src="https://github.com/user-attachments/assets/2587f3cf-ad3b-43fe-bea8-6dfa3d1f0157" />

<img width="880" height="1800" alt="search_autocomplete_over_results" src="https://github.com/user-attachments/assets/aaabd920-cbf6-480b-9982-f3e86e00de1f" />


</div>

---

## 📦 배포
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "2.12.0",
"version": "2.13.0",
"type": "module",
"private": true,
"scripts": {
Expand Down
8 changes: 8 additions & 0 deletions apps/web/public/data/changelog.json
Original file line number Diff line number Diff line change
Expand Up @@ -193,5 +193,13 @@
"매월 투표 결과가 확정되며, 순위와 득표 현황을 확인할 수 있습니다.",
"이달의 아티스트로 선정된 가수의 곡에는 카드에 배지가 표시됩니다."
]
},
"2.13.0": {
"title": "버전 2.13.0",
"date": "2026-09-01",
"message": [
"일본곡의 한국어 번역이 우선해서 보이도록 수정했습니다.",
"게스트 상태일 때 추가한 부를 곡 목록이 로그인 시 호환됩니다."
]
}
}
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-08-28T16:24:56.791Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
<url><loc>https://www.singcode.kr/patch-notes</loc><lastmod>2026-08-28T16:24:56.793Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
<url><loc>https://www.singcode.kr</loc><lastmod>2026-08-28T16:24:56.794Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
<url><loc>https://www.singcode.kr/manifest.webmanifest</loc><lastmod>2026-08-31T07:03:47.228Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
<url><loc>https://www.singcode.kr/patch-notes</loc><lastmod>2026-08-31T07:03:47.229Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
<url><loc>https://www.singcode.kr</loc><lastmod>2026-08-31T07:03:47.229Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
</urlset>
81 changes: 81 additions & 0 deletions apps/web/src/app/api/songs/tosing/merge/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse<ApiResponse<{ merged: number }>>> {
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<string>(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 },
);
}
}
20 changes: 14 additions & 6 deletions apps/web/src/app/info/like/SongItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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 (
<div className={cn('border-border flex items-center space-x-3 border-b py-2 last:border-0')}>
<Checkbox
Expand All @@ -21,13 +25,17 @@ export default function SongItem({
onCheckedChange={() => onToggleSelect(song.song_id)}
/>
<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-sm font-medium">{titleParts.primary}</MarqueeText>
{titleParts.secondary && (
<MarqueeText className="text-muted-foreground text-xs">
{titleParts.secondary}
</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>
<MarqueeText className="text-muted-foreground text-xs">{artistParts.primary}</MarqueeText>
{artistParts.secondary && (
<MarqueeText className="text-muted-foreground/70 text-xs">
{artistParts.secondary}
</MarqueeText>
)}
</div>
</div>
Expand Down
21 changes: 12 additions & 9 deletions apps/web/src/app/info/promotions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -70,11 +71,13 @@ function PromotionItem({
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<div className="flex flex-col gap-0.5">
<p className="truncate text-base font-medium">{title}</p>
{hasKoTitle && <p className="text-muted-foreground truncate text-xs">{title_ko}</p>}
<p className="text-muted-foreground truncate text-sm">{artist}</p>
{hasKoArtist && (
<p className="text-muted-foreground/70 truncate text-xs">{artist_ko}</p>
<p className="truncate text-base font-medium">{titleParts.primary}</p>
{titleParts.secondary && (
<p className="text-muted-foreground truncate text-xs">{titleParts.secondary}</p>
)}
<p className="text-muted-foreground truncate text-sm">{artistParts.primary}</p>
{artistParts.secondary && (
<p className="text-muted-foreground/70 truncate text-xs">{artistParts.secondary}</p>
)}
</div>
<p className="bg-muted/50 text-foreground mt-2 rounded-md px-3 py-2 text-sm leading-relaxed whitespace-pre-line">
Expand Down Expand Up @@ -175,10 +178,10 @@ export default function MyPromotionsPage() {
{confirmTarget && (
<div className="space-y-2 py-2 text-sm">
<p className="font-medium">
{confirmTarget.title_ko ?? confirmTarget.title}
{splitDisplay(confirmTarget.title_ko, confirmTarget.title).primary}
<span className="text-muted-foreground font-normal">
{' · '}
{confirmTarget.artist_ko ?? confirmTarget.artist}
{splitDisplay(confirmTarget.artist_ko, confirmTarget.artist).primary}
</span>
</p>
<p className="text-muted-foreground">
Expand Down
58 changes: 34 additions & 24 deletions apps/web/src/app/info/save/FolderCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -96,33 +97,42 @@ export default function FolderCard({
<div className="px-4">
{folder.songList.length > 0 ? (
<div className="space-y-2">
{folder.songList.map(song => (
<div
key={song.song_id}
className="flex items-center gap-3 border-b py-2 last:border-0"
>
<Checkbox
id={`song-${song.song_id}`}
checked={!!selectedSongs[song.song_id]}
onCheckedChange={() => toggleSongSelection(song.song_id)}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<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>
)}
{folder.songList.map(song => {
const titleParts = splitDisplay(song.title_ko, song.title);
const artistParts = splitDisplay(song.artist_ko, song.artist);

return (
<div
key={song.song_id}
className="flex items-center gap-3 border-b py-2 last:border-0"
>
<Checkbox
id={`song-${song.song_id}`}
checked={!!selectedSongs[song.song_id]}
onCheckedChange={() => toggleSongSelection(song.song_id)}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Music className="text-muted-foreground h-4 w-4 shrink-0" />
<div>
<p className="text-sm font-medium">{titleParts.primary}</p>
{titleParts.secondary && (
<p className="text-muted-foreground text-xs">
{titleParts.secondary}
</p>
)}
<p className="text-muted-foreground text-xs">{artistParts.primary}</p>
{artistParts.secondary && (
<p className="text-muted-foreground/70 text-xs">
{artistParts.secondary}
</p>
)}
</div>
</div>
</div>
</div>
</div>
))}
);
})}
</div>
) : (
<div className="text-muted-foreground py-4 text-center">
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/popular/ArtistVotePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ export default function ArtistVotePanel() {
</div>

{!isAuthenticated && (
<div className="bg-background/70 absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 rounded-lg backdrop-blur-[2px]">
<div className="bg-background/10 absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 rounded-lg backdrop-blur-[1px]">
<p className="text-foreground text-xl font-medium">로그인하면 참여할 수 있어요</p>
<p className="text-muted-foreground text-sm">이 달의 아티스트를 직접 뽑아주세요.</p>
</div>
Expand Down
19 changes: 13 additions & 6 deletions apps/web/src/app/search/AddFolderModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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' ? '저장' : '수정';

Expand Down Expand Up @@ -113,13 +116,17 @@ 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-base font-medium">{titleParts.primary}</MarqueeText>
{titleParts.secondary && (
<MarqueeText className="text-muted-foreground text-xs">
{titleParts.secondary}
</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>
<MarqueeText className="text-muted-foreground text-sm">{artistParts.primary}</MarqueeText>
{artistParts.secondary && (
<MarqueeText className="text-muted-foreground/70 text-xs">
{artistParts.secondary}
</MarqueeText>
)}
</div>

Expand Down
Loading
Loading