diff --git a/README.md b/README.md index 0397398..c830c23 100644 --- a/README.md +++ b/README.md @@ -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 조정, 곡 추천 기능 추가. 글자 자동 스크롤 기능 추가. ## 📝 회고 diff --git a/apps/web/package.json b/apps/web/package.json index 31007f8..e83410f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "web", - "version": "2.2.0", + "version": "2.3.0", "type": "module", "private": true, "scripts": { diff --git a/apps/web/public/changelog.json b/apps/web/public/changelog.json index b22af48..9b4458f 100644 --- a/apps/web/public/changelog.json +++ b/apps/web/public/changelog.json @@ -101,5 +101,12 @@ "일본 가수를 한글로 검색할 수 있습니다. 초성으로도 가능합니다.", "유명 일본 가수를 한눈에 조회할 수 있습니다." ] + }, + "2.3.0": { + "title": "버전 2.3.0", + "message": [ + "인기곡 페이지의 UI를 개선했습니다. 이제 인기곡 페이지에서 곡을 추천할 수 있습니다.", + "로그인 시 제공되는 코인을 30개로 변경했습니다." + ] } } diff --git a/apps/web/public/sitemap-0.xml b/apps/web/public/sitemap-0.xml index f806488..99ebb14 100644 --- a/apps/web/public/sitemap-0.xml +++ b/apps/web/public/sitemap-0.xml @@ -1,4 +1,4 @@ -https://www.singcode.kr2026-02-19T15:16:29.251Zweekly0.7 +https://www.singcode.kr2026-03-02T07:59:31.054Zweekly0.7 \ No newline at end of file diff --git a/apps/web/src/app/api/search/route.ts b/apps/web/src/app/api/search/route.ts index 9e83a67..ee9a2e9 100644 --- a/apps/web/src/app/api/search/route.ts +++ b/apps/web/src/app/api/search/route.ts @@ -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; }[]; @@ -44,7 +47,14 @@ export async function GET(request: Request): Promise ({ + 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({ @@ -90,6 +99,9 @@ export async function GET(request: Request): Promise 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({ diff --git a/apps/web/src/app/api/songs/recent-add/route.ts b/apps/web/src/app/api/songs/recent-add/route.ts index b62a06b..b348afc 100644 --- a/apps/web/src/app/api/songs/recent-add/route.ts +++ b/apps/web/src/app/api/songs/recent-add/route.ts @@ -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>> { +export async function GET(request: Request): Promise>> { 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'); 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( diff --git a/apps/web/src/app/api/user/check-in/route.ts b/apps/web/src/app/api/user/check-in/route.ts index a98c5ee..e968834 100644 --- a/apps/web/src/app/api/user/check-in/route.ts +++ b/apps/web/src/app/api/user/check-in/route.ts @@ -21,7 +21,7 @@ export async function PATCH(): Promise>> { .from('users') .update({ last_check_in: new Date(), - point: user.point + 10, + point: user.point + 30, }) .eq('id', userId); diff --git a/apps/web/src/app/info/like/SongItem.tsx b/apps/web/src/app/info/like/SongItem.tsx index af810c2..6c5b890 100644 --- a/apps/web/src/app/info/like/SongItem.tsx +++ b/apps/web/src/app/info/like/SongItem.tsx @@ -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'; @@ -20,8 +21,8 @@ export default function SongItem({ onCheckedChange={() => onToggleSelect(song.song_id)} />
-

{song.title}

-

{song.artist}

+ {song.title} + {song.artist}
); diff --git a/apps/web/src/app/popular/PopularRankingList.tsx b/apps/web/src/app/popular/PopularRankingList.tsx index 27a1a35..a6efef0 100644 --- a/apps/web/src/app/popular/PopularRankingList.tsx +++ b/apps/web/src/app/popular/PopularRankingList.tsx @@ -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() { @@ -17,27 +17,32 @@ export default function PopularRankingList() { } return ( - + 추천 곡 순위 - {/* refetch()} isLoading={isFetching} /> */} - refetch()} className="cursor-pointer hover:animate-spin" /> + refetch()} + className="absolute top-6 right-6 cursor-pointer hover:animate-spin" + /> - -
- {data && data.length > 0 ? ( - data.map((item, index) => ( - - )) - ) : ( -
- -

데이터를 준비중이에요

-
- )} -
-
+ + + +
+ {data && data.length > 0 ? ( + data.map((item, index) => ( + + )) + ) : ( +
+ +

데이터를 준비중이에요

+
+ )} +
+
+
); } diff --git a/apps/web/src/app/popular/page.tsx b/apps/web/src/app/popular/page.tsx index b97552d..866daea 100644 --- a/apps/web/src/app/popular/page.tsx +++ b/apps/web/src/app/popular/page.tsx @@ -1,5 +1,3 @@ -import { ScrollArea } from '@/components/ui/scroll-area'; - import PopularRankingList from './PopularRankingList'; export default function PopularPage() { @@ -8,9 +6,8 @@ export default function PopularPage() {

인기 노래

{/* 추천 곡 순위 */} - - - + + ); } diff --git a/apps/web/src/app/recent/RecentSongCard.tsx b/apps/web/src/app/recent/RecentSongCard.tsx index 724a82f..32f18bb 100644 --- a/apps/web/src/app/recent/RecentSongCard.tsx +++ b/apps/web/src/app/recent/RecentSongCard.tsx @@ -1,3 +1,4 @@ +import MarqueeText from '@/components/MarqueeText'; import { Separator } from '@/components/ui/separator'; import { Song } from '@/types/song'; @@ -10,9 +11,9 @@ export default function RecentSongCard({ song }: { song: Song }) {
{/* 제목 및 가수 */}
-
-

{title}

-

{artist}

+
+ {title} + {artist}
diff --git a/apps/web/src/app/recent/page.tsx b/apps/web/src/app/recent/page.tsx index 707ceec..7e68040 100644 --- a/apps/web/src/app/recent/page.tsx +++ b/apps/web/src/app/recent/page.tsx @@ -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(), @@ -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 (
-
- {today.getFullYear()}년 - {today.getMonth() + 1}월 + + +

최신곡

-
diff --git a/apps/web/src/app/search/AddFolderModal.tsx b/apps/web/src/app/search/AddFolderModal.tsx index 7feeeb2..6f6ad0a 100644 --- a/apps/web/src/app/search/AddFolderModal.tsx +++ b/apps/web/src/app/search/AddFolderModal.tsx @@ -3,6 +3,7 @@ import { CheckCircle, PlusCircle, Save } from 'lucide-react'; import { useEffect, useState } from 'react'; +import MarqueeText from '@/components/MarqueeText'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -112,8 +113,8 @@ export default function AddFolderModal({ {/* 곡 정보 */}
-

{title}

-

{artist}

+ {title} + {artist}
diff --git a/apps/web/src/app/search/HomePage.tsx b/apps/web/src/app/search/HomePage.tsx index b1fb896..bf930c8 100644 --- a/apps/web/src/app/search/HomePage.tsx +++ b/apps/web/src/app/search/HomePage.tsx @@ -62,8 +62,6 @@ export default function SearchPage() { rootMargin: '0px 0px 800px 0px', // 스크롤 하단 600px 이전에 미리 로딩 }); - console.log('inView', inView); - const { guestToSingSongs } = useGuestToSingStore(); const isToSing = (song: SearchSong, songId: string) => { diff --git a/apps/web/src/app/search/MusicCard.tsx b/apps/web/src/app/search/MusicCard.tsx index 5ff07fc..cabb079 100644 --- a/apps/web/src/app/search/MusicCard.tsx +++ b/apps/web/src/app/search/MusicCard.tsx @@ -1,3 +1,5 @@ +import MarqueeText from '@/components/MarqueeText'; + type MusicCardProps = { title: string; artist: string; @@ -11,16 +13,16 @@ export function MusicCard({ title, artist, reason, onClick }: MusicCardProps) {

{reason}

diff --git a/apps/web/src/app/search/SearchResultCard.tsx b/apps/web/src/app/search/SearchResultCard.tsx index 1709730..51144df 100644 --- a/apps/web/src/app/search/SearchResultCard.tsx +++ b/apps/web/src/app/search/SearchResultCard.tsx @@ -1,11 +1,21 @@ -import { Heart, ListPlus, ListRestart, MinusCircle, PlusCircle, ThumbsUp } from 'lucide-react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { + ChevronDown, + Heart, + ListPlus, + ListRestart, + MinusCircle, + PlusCircle, + ThumbsUp, +} from 'lucide-react'; import { useState } from 'react'; import { toast } from 'sonner'; +import MarqueeText from '@/components/MarqueeText'; import ThumbUpModal from '@/components/ThumbUpModal'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; -import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'; +import { Dialog, DialogContent } from '@/components/ui/dialog'; import useAuthStore from '@/stores/useAuthStore'; import { SearchSong } from '@/types/song'; @@ -14,6 +24,7 @@ interface IProps { isToSing: boolean; isLike: boolean; isSave: boolean; + onToggleToSing: () => void; onToggleLike: () => void; onClickSave: () => void; @@ -25,15 +36,18 @@ export default function SearchResultCard({ isToSing, isLike, isSave, + onToggleToSing, onToggleLike, onClickSave, onClickArtist, }: IProps) { - const { id, title, artist, num_tj, num_ky } = song; + const { id, title, artist, num_tj, num_ky, thumb } = song; + const { isAuthenticated } = useAuthStore(); const [open, setOpen] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); const handleClickThumbsUp = () => { if (!isAuthenticated) { @@ -46,86 +60,119 @@ export default function SearchResultCard({ return ( {/* 메인 콘텐츠 영역 */} -
+
{/* 노래 정보 */} -
+
{/* 제목 및 가수 */} -
-
-

{title}

- +
+ {title} + {artist} - +
- setOpen(false)} /> + setOpen(false)} + />
{/* 노래방 번호 */} - -
-
- TJ - {num_tj} -
-
- 금영 - {num_ky} +
+
+
+ TJ + {num_tj} +
+
+ 금영 + {num_ky} +
+ +
- {/* 버튼 영역 - 우측 하단에 고정 */} -
- - - - - -
+ {/* 버튼 영역 - 애니메이션 적용 */} + + {isExpanded && ( + +
+ + + + + +
+
+ )} +
); diff --git a/apps/web/src/app/tosing/ModalSongItem.tsx b/apps/web/src/app/tosing/ModalSongItem.tsx index f5afa7f..bdf0c53 100644 --- a/apps/web/src/app/tosing/ModalSongItem.tsx +++ b/apps/web/src/app/tosing/ModalSongItem.tsx @@ -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'; @@ -26,8 +27,8 @@ export default function ModalSongItem({ disabled={song.isInToSingList} />
-

{song.title}

-

{song.artist}

+ {song.title} + {song.artist}
); diff --git a/apps/web/src/app/tosing/SongCard.tsx b/apps/web/src/app/tosing/SongCard.tsx index de5d74f..74f051b 100644 --- a/apps/web/src/app/tosing/SongCard.tsx +++ b/apps/web/src/app/tosing/SongCard.tsx @@ -4,6 +4,7 @@ import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { ChevronsDown, ChevronsUp, GripVertical, Trash } from 'lucide-react'; +import MarqueeText from '@/components/MarqueeText'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Song } from '@/types/song'; @@ -31,15 +32,15 @@ export default function SongCard({ song, onDelete, onMoveToTop, onMoveToBottom } {/* 메인 콘텐츠 영역 */}
{/* 노래 정보 */} -
+
{/* 제목 및 가수 */}
-

{title}

-

{artist}

+ {title} + {artist}
{/* 노래방 번호 */} -
+
TJ {num_tj} diff --git a/apps/web/src/components/MarqueeText.tsx b/apps/web/src/components/MarqueeText.tsx new file mode 100644 index 0000000..a8abb1f --- /dev/null +++ b/apps/web/src/components/MarqueeText.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { ReactNode, useEffect, useRef, useState } from 'react'; + +import { cn } from '@/utils/cn'; + +interface MarqueeTextProps { + children: ReactNode; + className?: string; + onClick?: () => void; +} + +export default function MarqueeText({ children, className, onClick }: MarqueeTextProps) { + const containerRef = useRef(null); + const [isOverflowing, setIsOverflowing] = useState(false); + + const checkOverflow = () => { + if (containerRef.current) { + const { scrollWidth, clientWidth } = containerRef.current; + setIsOverflowing(scrollWidth > clientWidth); + } + }; + + useEffect(() => { + checkOverflow(); + const resizeObserver = new ResizeObserver(() => checkOverflow()); + if (containerRef.current) resizeObserver.observe(containerRef.current); + return () => resizeObserver.disconnect(); + }, [children]); + + return ( +
+
+ {children} + {/* 오버플로우 시에만 복제본을 렌더링 */} + {isOverflowing && ( + + )} +
+
+ ); +} diff --git a/apps/web/src/components/RankingItem.tsx b/apps/web/src/components/RankingItem.tsx index 0d62d58..e27452d 100644 --- a/apps/web/src/components/RankingItem.tsx +++ b/apps/web/src/components/RankingItem.tsx @@ -1,15 +1,39 @@ +import { ThumbsUp } from 'lucide-react'; +import { useState } from 'react'; +import { toast } from 'sonner'; + +import MarqueeText from '@/components/MarqueeText'; +import ThumbUpModal from '@/components/ThumbUpModal'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent } from '@/components/ui/dialog'; +import useAuthStore from '@/stores/useAuthStore'; import { cn } from '@/utils/cn'; interface RankingItemProps { + id: string; rank: number; title: string; artist: string; + num_tj: string; + num_ky: string; value: number; className?: string; } -export default function RankingItem({ rank, title, artist, value, className }: RankingItemProps) { - // 등수에 따른 색상 및 스타일 결정 +export default function RankingItem({ + id, + rank, + title, + artist, + num_tj, + num_ky, + value, + className, +}: RankingItemProps) { + const { isAuthenticated } = useAuthStore(); + + const [open, setOpen] = useState(false); + const getRankStyle = (rank: number) => { switch (rank) { case 1: @@ -23,8 +47,16 @@ export default function RankingItem({ rank, title, artist, value, className }: R } }; + const handleClickThumbsUp = () => { + if (!isAuthenticated) { + toast.error('로그인하고 곡 추천 기능을 사용해보세요!'); + return; + } + setOpen(true); + }; + return ( -
+
{rank}
-
-
-

{title}

-

{artist}

+
+
+
+ {title} + {artist} +
+ +
+
+ TJ + {num_tj} +
+
+ 금영 + {num_ky} +
+
+
-

{value}회

+ + + + + setOpen(false)} + /> + +
diff --git a/apps/web/src/components/ThumbUpModal.tsx b/apps/web/src/components/ThumbUpModal.tsx index 47a1557..300625a 100644 --- a/apps/web/src/components/ThumbUpModal.tsx +++ b/apps/web/src/components/ThumbUpModal.tsx @@ -17,10 +17,19 @@ import FallingIcons from './FallingIcons'; interface ThumbUpModalProps { songId: string; + title: string; + artist: string; + thumb: number; handleClose: () => void; } -export default function ThumbUpModal({ songId, handleClose }: ThumbUpModalProps) { +export default function ThumbUpModal({ + songId, + title, + artist, + thumb, + handleClose, +}: ThumbUpModalProps) { const [value, setValue] = useState([0]); const { data: user } = useUserQuery(); @@ -52,9 +61,29 @@ export default function ThumbUpModal({ songId, handleClose }: ThumbUpModalProps)
- - - +
+ {/* 레이블 추가로 가독성 향상 */} + + Total Points + + +
+ + + + +
+ + + + + +
+
+
+ +
+
{title}
+
{artist}
+