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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,8 @@ sing-code/
- 2026.1.4 : 버전 1.9.0 배포. OPENAI 활용 챗봇 기능 추가.
- 2026.1.27 : 버전 2.0.0 배포. DB 재설계 및 로직 리펙토링. 출석 체크, 유저 별 포인트, 곡 추천 기능 추가.
- 2026.2.8 : 버전 2.1.0 배포. 비회원 상태로 곡 부를곡 추가가 가능, Footer 애니메이션 추가.

- 2026.2.20 : 버전 2.2.0 배포. 검색어 자동 완성 기능. es-hangul로 초성 검색 지원.
- 2026.3.2 : 버전 2.3.0 배포. 곡 추천 페이지에서 UI 조정, 곡 추천 기능 추가. 글자 자동 스크롤 기능 추가.

## 📝 회고

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.2.0",
"version": "2.3.0",
"type": "module",
"private": true,
"scripts": {
Expand Down
7 changes: 7 additions & 0 deletions apps/web/public/changelog.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,5 +101,12 @@
"일본 가수를 한글로 검색할 수 있습니다. 초성으로도 가능합니다.",
"유명 일본 가수를 한눈에 조회할 수 있습니다."
]
},
"2.3.0": {
"title": "버전 2.3.0",
"message": [
"인기곡 페이지의 UI를 개선했습니다. 이제 인기곡 페이지에서 곡을 추천할 수 있습니다.",
"로그인 시 제공되는 코인을 30개로 변경했습니다."
]
}
}
2 changes: 1 addition & 1 deletion apps/web/public/sitemap-0.xml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<?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</loc><lastmod>2026-02-19T15:16:29.251Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
<url><loc>https://www.singcode.kr</loc><lastmod>2026-03-02T07:59:31.054Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
</urlset>
23 changes: 17 additions & 6 deletions apps/web/src/app/api/search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { SearchSong, Song } from '@/types/song';
import { getAuthenticatedUser } from '@/utils/getAuthenticatedUser';

interface DBSong extends Song {
total_stats: {
total_thumb: number;
};
tosings: {
user_id: string;
}[];
Expand Down Expand Up @@ -44,7 +47,14 @@ export async function GET(request: Request): Promise<NextResponse<ApiResponse<Se
const supabase = await createClient();

if (!authenticated) {
const baseQuery = supabase.from('songs').select('*', { count: 'exact' });
const baseQuery = supabase.from('songs').select(
`*,
total_stats (
*
)
`,
{ count: 'exact' },
);

if (type === 'all') {
baseQuery.or(`title.ilike.%${query}%,artist.ilike.%${query}%`);
Expand All @@ -64,17 +74,16 @@ export async function GET(request: Request): Promise<NextResponse<ApiResponse<Se
);
}

const songs: SearchSong[] = data.map((song: Song) => ({
const songs: SearchSong[] = data.map((song: DBSong) => ({
id: song.id,
title: song.title,
artist: song.artist,
num_tj: song.num_tj,
num_ky: song.num_ky,
// like_activities에서 현재 사용자의 데이터가 있는지 확인
isLike: false,
// tosings에서 현재 사용자의 데이터가 있는지 확인
isToSing: false,
isSave: false,
thumb: song.total_stats?.total_thumb ?? 0,
}));

return NextResponse.json({
Expand All @@ -90,6 +99,9 @@ export async function GET(request: Request): Promise<NextResponse<ApiResponse<Se
const baseQuery = supabase.from('songs').select(
`
*,
total_stats (
*
),
tosings (
user_id
),
Expand Down Expand Up @@ -129,11 +141,10 @@ export async function GET(request: Request): Promise<NextResponse<ApiResponse<Se
num_tj: song.num_tj,
num_ky: song.num_ky,

// tosings에서 현재 사용자의 데이터가 있는지 확인
isToSing: song.tosings?.some(tosing => tosing.user_id === userId) ?? false,
// like_activities에서 현재 사용자의 데이터가 있는지 확인
isLike: song.like_activities?.some(like => like.user_id === userId) ?? false,
isSave: song.save_activities?.some(save => save.user_id === userId) ?? false,
thumb: song.total_stats?.total_thumb ?? 0,
}));

return NextResponse.json({
Expand Down
35 changes: 17 additions & 18 deletions apps/web/src/app/api/songs/recent-add/route.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,37 @@
import { endOfMonth, format, startOfMonth } from 'date-fns';
import { NextResponse } from 'next/server';

import createClient from '@/lib/supabase/server';
import { ApiResponse } from '@/types/apiRoute';
import { Song } from '@/types/song';

interface ResponseRecentAddSong {
songs: Song;
}
export async function GET(
request: Request,
): Promise<NextResponse<ApiResponse<ResponseRecentAddSong[]>>> {
export async function GET(request: Request): Promise<NextResponse<ApiResponse<Song[]>>> {
try {
const { searchParams } = new URL(request.url);

const year = Number(searchParams.get('year')) || 0;
const month = Number(searchParams.get('month')) || 0;
const now = new Date();
const year = Number(searchParams.get('year'));
const month = Number(searchParams.get('month'));

const startDate = new Date(year, month, 1);
const endDate = new Date(year, month + 1, 1);
// date-fns의 month는 0-indexed이므로 1을 빼줌 (사용자 입력은 1-12)
const baseDate = new Date(year, month, 1);
const startDate = format(startOfMonth(baseDate), 'yyyy-MM-01');
const endDate = format(endOfMonth(baseDate), 'yyyy-MM-dd');

Comment on lines +12 to 20

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Validate recent-add params 🐞 Bug ⛯ Reliability

The recent-add API converts year/month via Number() without validation; missing/invalid params
become NaN, producing an Invalid Date and causing the handler to throw/return 500 instead of a 400
(bad request). This makes the endpoint fragile to malformed requests and can create noisy server
errors.
Agent Prompt
### Issue description
`/api/songs/recent-add` builds date ranges from `year`/`month` query params without validation. If params are missing/invalid, `Number()` yields `NaN`, which can lead to invalid date computations and a 500 response instead of a clean 400.

### Issue Context
This endpoint is callable via HTTP and should be resilient to malformed input.

### Fix Focus Areas
- apps/web/src/app/api/songs/recent-add/route.ts[12-20]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

const supabase = await createClient();

// songs 테이블의 startDate, endDate 사이의 데이터를 가져옴
// songs 테이블의 release 날짜가 해당 월의 시작일부터 마지막일 사이인 데이터를 가져옴
const { data, error: recentError } = await supabase
.from('songs') // songs 테이블에서 검색
.select(`*`)
.gte('created_at', startDate.toISOString())
.lte('created_at', endDate.toISOString())
.order('created_at', { ascending: false })
.limit(100); // 단순히 songs의 created_at으로 정렬
.from('songs')
.select('*')
.gte('release', startDate)
.lte('release', endDate)
.order('release', { ascending: false })
.limit(100);

if (recentError) throw recentError;

return NextResponse.json({ success: true, data });
return NextResponse.json({ success: true, data: data as Song[] });
} catch (error) {
if (error instanceof Error && error.cause === 'auth') {
return NextResponse.json(
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/api/user/check-in/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export async function PATCH(): Promise<NextResponse<ApiResponse<void>>> {
.from('users')
.update({
last_check_in: new Date(),
point: user.point + 10,
point: user.point + 30,
})
.eq('id', userId);

Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/app/info/like/SongItem.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import MarqueeText from '@/components/MarqueeText';
import { Checkbox } from '@/components/ui/checkbox';
import { AddListModalSong } from '@/types/song';
import { cn } from '@/utils/cn';
Expand All @@ -20,8 +21,8 @@ export default function SongItem({
onCheckedChange={() => onToggleSelect(song.song_id)}
/>
<div className="min-w-0 flex-1">
<h4 className="truncate text-sm font-medium">{song.title}</h4>
<p className="text-muted-foreground truncate text-xs">{song.artist}</p>
<MarqueeText className="text-sm font-medium">{song.title}</MarqueeText>
<MarqueeText className="text-muted-foreground text-xs">{song.artist}</MarqueeText>
</div>
</div>
);
Expand Down
41 changes: 23 additions & 18 deletions apps/web/src/app/popular/PopularRankingList.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
'use client';

import { Construction } from 'lucide-react';
// import IntervalProgress from '@/components/ui/IntervalProgress';
import { RotateCw } from 'lucide-react';

import RankingItem from '@/components/RankingItem';
import StaticLoading from '@/components/StaticLoading';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useSongThumbQuery } from '@/queries/songThumbQuery';

export default function PopularRankingList() {
Expand All @@ -17,27 +17,32 @@ export default function PopularRankingList() {
}

return (
<Card>
<Card className="relative">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-xl">추천 곡 순위</CardTitle>
{/* <IntervalProgress duration={5000} onComplete={() => refetch()} isLoading={isFetching} /> */}

<RotateCw onClick={() => refetch()} className="cursor-pointer hover:animate-spin" />
<RotateCw
onClick={() => refetch()}
className="absolute top-6 right-6 cursor-pointer hover:animate-spin"
/>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-0">
{data && data.length > 0 ? (
data.map((item, index) => (
<RankingItem key={index} {...item} rank={index + 1} value={item.total_thumb} />
))
) : (
<div className="flex h-64 flex-col items-center justify-center gap-4">
<Construction className="text-muted-foreground h-16 w-16" />
<p className="text-muted-foreground text-xl">데이터를 준비중이에요</p>
</div>
)}
</div>
</CardContent>

<ScrollArea className="h-[calc(100vh-20rem)]">
<CardContent className="pt-0">
<div className="space-y-0">
{data && data.length > 0 ? (
data.map((item, index) => (
<RankingItem key={index} {...item} rank={index + 1} value={item.total_thumb} />
))
Comment on lines +33 to +36

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Index key state bug 🐞 Bug ✓ Correctness

Popular ranking items use key={index} while each RankingItem maintains local dialog open state;
when the list refetches and order changes, React can re-use components by index and attach the open
state to the wrong song. This can manifest as the wrong song opening in the recommendation modal
after refresh/reorder.
Agent Prompt
### Issue description
`RankingItem` is rendered with `key={index}` even though it has local dialog state. If the list ordering changes, dialog state may attach to the wrong item.

### Issue Context
This is especially likely because the list supports manual `refetch()`.

### Fix Focus Areas
- apps/web/src/app/popular/PopularRankingList.tsx[33-36]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

) : (
<div className="flex h-64 flex-col items-center justify-center gap-4">
<Construction className="text-muted-foreground h-16 w-16" />
<p className="text-muted-foreground text-xl">데이터를 준비중이에요</p>
</div>
)}
</div>
</CardContent>
</ScrollArea>
</Card>
);
}
7 changes: 2 additions & 5 deletions apps/web/src/app/popular/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { ScrollArea } from '@/components/ui/scroll-area';

import PopularRankingList from './PopularRankingList';

export default function PopularPage() {
Expand All @@ -8,9 +6,8 @@ export default function PopularPage() {
<h1 className="text-2xl font-bold">인기 노래</h1>

{/* 추천 곡 순위 */}
<ScrollArea className="h-[calc(100vh-20rem)]">
<PopularRankingList />
</ScrollArea>

<PopularRankingList />
</div>
);
}
7 changes: 4 additions & 3 deletions apps/web/src/app/recent/RecentSongCard.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import MarqueeText from '@/components/MarqueeText';
import { Separator } from '@/components/ui/separator';
import { Song } from '@/types/song';

Expand All @@ -10,9 +11,9 @@ export default function RecentSongCard({ song }: { song: Song }) {
<div className="flex flex-col">
{/* 제목 및 가수 */}
<div className="flex justify-between">
<div className="flex w-[70%] flex-col">
<h3 className="truncate text-base font-medium">{title}</h3>
<p className="text-muted-foreground truncate text-sm">{artist}</p>
<div className="max-w-[250px] min-w-[100px]">
<MarqueeText className="text-base font-medium">{title}</MarqueeText>
<MarqueeText className="text-muted-foreground text-sm">{artist}</MarqueeText>
</div>

<div className="flex flex-col space-x-4">
Expand Down
66 changes: 46 additions & 20 deletions apps/web/src/app/recent/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,19 @@ import { useState } from 'react';

import StaticLoading from '@/components/StaticLoading';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useRecentAddSongQuery } from '@/queries/recentAddSongQuery';

import RecentSongCard from './RecentSongCard';

export default function LibraryPage() {
export default function RecentSongPage() {
const [today, setToday] = useState(new Date());
const [prevAction, setPrevAction] = useState<'prev' | 'next' | null>(null);

const { data: recentAddSongs, isLoading: isLoadingRecentAddSongs } = useRecentAddSongQuery(
today.getFullYear(),
Expand All @@ -20,40 +26,60 @@ export default function LibraryPage() {

const handlePrevMonth = () => {
setToday(new Date(today.getFullYear(), today.getMonth() - 1, 1));
setPrevAction('prev');
};

const handleNextMonth = () => {
setToday(new Date(today.getFullYear(), today.getMonth() + 1, 1));
setPrevAction('next');
};

const handleYearChange = (year: string) => {
setToday(new Date(Number(year), today.getMonth(), 1));
};

const handleMonthChange = (month: string) => {
setToday(new Date(today.getFullYear(), Number(month), 1));
};

const years = Array.from({ length: 20 }, (_, i) => new Date().getFullYear() - i);
const months = Array.from({ length: 12 }, (_, i) => i);

return (
<div className="bg-background h-full space-y-4">
<div className="flex items-center justify-between">
<Button
disabled={prevAction === 'prev' && recentAddSongs?.length === 0}
variant="ghost"
size="icon"
onClick={handlePrevMonth}
className="m-2"
>
<Button variant="ghost" size="icon" onClick={handlePrevMonth} className="m-2">
<ArrowLeft className="h-5 w-5" />
</Button>

<div className="flex items-center gap-2 px-2 py-4 text-2xl font-bold">
<span>{today.getFullYear()}년</span>
<span>{today.getMonth() + 1}월</span>
<Select value={today.getFullYear().toString()} onValueChange={handleYearChange}>
<SelectTrigger className="border-none p-0 text-2xl font-bold shadow-none focus-visible:ring-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{years.map(year => (
<SelectItem key={year} value={year.toString()}>
{year}년
</SelectItem>
))}
</SelectContent>
</Select>

<Select value={today.getMonth().toString()} onValueChange={handleMonthChange}>
<SelectTrigger className="border-none p-0 text-2xl font-bold shadow-none focus-visible:ring-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{months.map(month => (
<SelectItem key={month} value={month.toString()}>
{month + 1}월
</SelectItem>
))}
</SelectContent>
</Select>
<h1>최신곡</h1>
</div>

<Button
disabled={prevAction === 'next' && recentAddSongs?.length === 0}
variant="ghost"
size="icon"
onClick={handleNextMonth}
className="m-2"
>
<Button variant="ghost" size="icon" onClick={handleNextMonth} className="m-2">
<ArrowRight className="h-5 w-5" />
</Button>
</div>
Expand Down
Loading