-
Notifications
You must be signed in to change notification settings - Fork 0
Feat : TJ 공식 차트 API 연동으로 popular 페이지 개편 (#295) #296
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
GulSam00
wants to merge
2
commits into
develop
Choose a base branch
from
feat/295-tjChartApi
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| name: Crawl TJ Chart | ||
|
|
||
| on: | ||
| schedule: | ||
| - cron: "0 1 1 * *" # 매달 1일 KST 오전 10:00 실행 (UTC+9 → UTC 01:00) | ||
| workflow_dispatch: | ||
|
|
||
| jobs: | ||
| run-npm-task: | ||
| runs-on: ubuntu-latest | ||
|
|
||
| steps: | ||
| - uses: actions/checkout@v4 | ||
|
|
||
| - name: Use Node.js 20 | ||
| uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: "20" | ||
|
|
||
| - name: Install pnpm | ||
| uses: pnpm/action-setup@v2 | ||
| with: | ||
| version: 9 | ||
| run_install: false | ||
|
|
||
| - name: Install dependencies | ||
| working-directory: packages/crawling | ||
| run: pnpm install | ||
|
|
||
| - name: Create .env file | ||
| working-directory: packages/crawling | ||
| run: | | ||
| echo "SUPABASE_URL=${{ secrets.SUPABASE_URL }}" >> .env | ||
| echo "SUPABASE_KEY=${{ secrets.SUPABASE_KEY }}" >> .env | ||
|
|
||
| - name: run crawl script - crawlTjChart.ts | ||
| working-directory: packages/crawling | ||
| run: pnpm run tj-chart |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import { NextRequest, NextResponse } from 'next/server'; | ||
|
|
||
| import createClient from '@/lib/supabase/server'; | ||
| import { ApiResponse } from '@/types/apiRoute'; | ||
| import { Song } from '@/types/song'; | ||
| import { StrType, TjChartResponse } from '@/types/tjChart'; | ||
|
|
||
| interface ChartRow { | ||
| rank: number; | ||
| songs: Song | null; | ||
| } | ||
|
|
||
| export async function GET( | ||
| request: NextRequest, | ||
| ): Promise<NextResponse<ApiResponse<TjChartResponse>>> { | ||
| try { | ||
| const supabase = await createClient(); | ||
| const searchParams = request.nextUrl.searchParams; | ||
|
|
||
| const genreParam = searchParams.get('genre') ?? StrType.All; | ||
| const genre = Object.values(StrType).includes(genreParam as StrType) | ||
| ? (genreParam as StrType) | ||
| : StrType.All; | ||
|
|
||
| // 1) 데이터가 존재하는 월 목록 조회 | ||
| const { data: monthRows, error: monthError } = await supabase | ||
| .from('chart_rankings') | ||
| .select('chart_month') | ||
| .order('chart_month', { ascending: false }); | ||
|
|
||
| if (monthError) throw monthError; | ||
|
|
||
| const availableMonths = [...new Set((monthRows ?? []).map(row => row.chart_month as string))]; | ||
|
|
||
| if (availableMonths.length === 0) { | ||
| return NextResponse.json({ | ||
| success: true, | ||
| data: { month: '', genre, availableMonths: [], items: [] }, | ||
| }); | ||
| } | ||
|
|
||
| const monthParam = searchParams.get('month'); | ||
| const targetMonth = | ||
| monthParam && availableMonths.includes(monthParam) ? monthParam : availableMonths[0]; | ||
|
|
||
| // 2) 해당 월 + 장르의 차트 순위 조회 (songs 테이블과 조인) | ||
| const { data, error } = await supabase | ||
| .from('chart_rankings') | ||
| .select('rank, songs(id, title, artist, title_ko, artist_ko, num_tj, num_ky)') | ||
| .eq('chart_month', targetMonth) | ||
| .eq('type', genre) | ||
| .order('rank', { ascending: true }) | ||
| .returns<ChartRow[]>(); | ||
|
|
||
| if (error) throw error; | ||
|
|
||
| const items = (data ?? []) | ||
| .filter((row): row is ChartRow & { songs: Song } => row.songs !== null) | ||
| .map(row => ({ ...row.songs, rank: row.rank })); | ||
|
|
||
| return NextResponse.json({ | ||
| success: true, | ||
| data: { month: targetMonth, genre, availableMonths, items }, | ||
| }); | ||
| } catch (error) { | ||
| console.error('Error in tj-chart API:', error); | ||
| return NextResponse.json({ success: false, error: 'Failed to get tj chart' }, { status: 500 }); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,148 @@ | ||
| 'use client'; | ||
|
|
||
| import { Construction } from 'lucide-react'; | ||
| import { useEffect, useState } from 'react'; | ||
|
|
||
| import MarqueeText from '@/components/MarqueeText'; | ||
| import StaticLoading from '@/components/StaticLoading'; | ||
| import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; | ||
| import { ScrollArea } from '@/components/ui/scroll-area'; | ||
| import { | ||
| Select, | ||
| SelectContent, | ||
| SelectItem, | ||
| SelectTrigger, | ||
| SelectValue, | ||
| } from '@/components/ui/select'; | ||
| import { useTjChartQuery } from '@/queries/tjChartQuery'; | ||
| import { STR_TYPE_LABEL, StrType } from '@/types/tjChart'; | ||
| import { cn } from '@/utils/cn'; | ||
|
|
||
| const getRankStyle = (rank: number) => { | ||
| switch (rank) { | ||
| case 1: | ||
| return 'bg-amber-500 text-white font-bold'; | ||
| case 2: | ||
| return 'bg-gray-300 text-white font-bold'; | ||
| case 3: | ||
| return 'bg-amber-700 text-white font-bold'; | ||
| default: | ||
| return 'bg-muted text-muted-foreground'; | ||
| } | ||
| }; | ||
|
|
||
| const formatMonth = (month: string) => { | ||
| const [year, m] = month.split('-'); | ||
| return `${year}년 ${Number(m)}월`; | ||
| }; | ||
|
|
||
| export default function TjChartRankingList() { | ||
| const [genre, setGenre] = useState<StrType>(StrType.All); | ||
| const [month, setMonth] = useState<string | undefined>(undefined); | ||
|
|
||
| const { data, isPending, isError } = useTjChartQuery(month, genre); | ||
|
|
||
| useEffect(() => { | ||
| if (data?.month && !month) { | ||
| setMonth(data.month); | ||
| } | ||
| }, [data?.month, month]); | ||
|
|
||
| if (isPending) { | ||
| return <StaticLoading />; | ||
| } | ||
|
|
||
| const availableMonths = data?.availableMonths ?? []; | ||
| const items = data?.items ?? []; | ||
|
|
||
| return ( | ||
| <Card className="relative flex min-h-0 flex-1 flex-col"> | ||
| <CardHeader className="flex shrink-0 flex-col gap-3 pb-2"> | ||
| <CardTitle className="text-xl">TJ 인기차트</CardTitle> | ||
|
|
||
| <div className="flex gap-2"> | ||
| <Select value={month} onValueChange={setMonth} disabled={availableMonths.length === 0}> | ||
| <SelectTrigger className="w-[120px]" size="sm"> | ||
| <SelectValue placeholder="월 선택" /> | ||
| </SelectTrigger> | ||
| <SelectContent> | ||
| {availableMonths.map(m => ( | ||
| <SelectItem key={m} value={m}> | ||
| {formatMonth(m)} | ||
| </SelectItem> | ||
| ))} | ||
| </SelectContent> | ||
| </Select> | ||
|
|
||
| <Select value={genre} onValueChange={value => setGenre(value as StrType)}> | ||
| <SelectTrigger className="w-[110px]" size="sm"> | ||
| <SelectValue placeholder="장르 선택" /> | ||
| </SelectTrigger> | ||
| <SelectContent> | ||
| {Object.values(StrType).map(type => ( | ||
| <SelectItem key={type} value={type}> | ||
| {STR_TYPE_LABEL[type]} | ||
| </SelectItem> | ||
| ))} | ||
| </SelectContent> | ||
| </Select> | ||
| </div> | ||
| </CardHeader> | ||
|
|
||
| <ScrollArea className="min-h-0 flex-1"> | ||
| <CardContent className="pt-0"> | ||
| <div className="space-y-0"> | ||
| {isError || items.length === 0 ? ( | ||
| <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> | ||
| ) : ( | ||
| items.map(item => ( | ||
| <div key={item.id} className={cn('flex gap-4 border-b py-3 last:border-0')}> | ||
| <div | ||
| className={cn( | ||
| 'flex h-8 w-8 shrink-0 items-center justify-center rounded-full', | ||
| getRankStyle(item.rank), | ||
| )} | ||
| > | ||
| {item.rank} | ||
| </div> | ||
| <div className="flex w-full justify-between gap-2"> | ||
| <div className="w-[140px] shrink-0"> | ||
| <MarqueeText className="text-sm font-medium">{item.title}</MarqueeText> | ||
| {item.title_ko && item.title_ko !== item.title && ( | ||
| <MarqueeText className="text-muted-foreground text-xs"> | ||
| {item.title_ko} | ||
| </MarqueeText> | ||
| )} | ||
| <MarqueeText className="text-muted-foreground text-xs"> | ||
| {item.artist} | ||
| </MarqueeText> | ||
| {item.artist_ko && item.artist_ko !== item.artist && ( | ||
| <MarqueeText className="text-muted-foreground/70 text-xs"> | ||
| {item.artist_ko} | ||
| </MarqueeText> | ||
| )} | ||
| </div> | ||
|
|
||
| <div> | ||
| <div className="flex items-center"> | ||
| <span className="text-brand-tj mr-1 w-8 text-xs">TJ</span> | ||
| <span className="text-sm font-medium">{item.num_tj}</span> | ||
| </div> | ||
| <div className="flex items-center"> | ||
| <span className="text-brand-ky mr-1 w-8 text-xs">금영</span> | ||
| <span className="text-sm font-medium">{item.num_ky}</span> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| )) | ||
| )} | ||
| </div> | ||
| </CardContent> | ||
| </ScrollArea> | ||
| </Card> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,13 +1,13 @@ | ||
| import PopularRankingList from './PopularRankingList'; | ||
| import TjChartRankingList from './TjChartRankingList'; | ||
|
|
||
| export default function PopularPage() { | ||
| return ( | ||
| <div className="flex h-full flex-col gap-4"> | ||
| <h1 className="shrink-0 text-2xl font-bold">인기 노래</h1> | ||
|
|
||
| {/* 추천 곡 순위 */} | ||
| {/* TJ 공식 차트 기반 인기 순위 */} | ||
|
|
||
| <PopularRankingList /> | ||
| <TjChartRankingList /> | ||
| </div> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import { ApiResponse } from '@/types/apiRoute'; | ||
| import { StrType, TjChartResponse } from '@/types/tjChart'; | ||
|
|
||
| import { instance } from './client'; | ||
|
|
||
| export async function getTjChart(month?: string, genre?: StrType) { | ||
| const response = await instance.get<ApiResponse<TjChartResponse>>('/tj-chart', { | ||
| params: { month, genre }, | ||
| }); | ||
|
|
||
| return response.data; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import { useQuery } from '@tanstack/react-query'; | ||
|
|
||
| import { getTjChart } from '@/lib/api/tjChart'; | ||
| import { StrType } from '@/types/tjChart'; | ||
|
|
||
| export const useTjChartQuery = (month?: string, genre?: StrType) => { | ||
| return useQuery({ | ||
| queryKey: ['tjChart', month, genre], | ||
| queryFn: async () => { | ||
| const response = await getTjChart(month, genre); | ||
|
|
||
| if (!response.success) { | ||
| return null; | ||
| } | ||
| return response.data; | ||
| }, | ||
| }); | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| import { Song } from './song'; | ||
|
|
||
| // TJ 공식 차트(topAndHot100) 장르 구분값 (DB 저장용, 영어) | ||
| // 참고: https://www.tjmedia.com/chart/top100 | ||
| export enum StrType { | ||
| All = 'all', | ||
| Kpop = 'kpop', | ||
| Pop = 'pop', | ||
| Jpop = 'jpop', | ||
| Ballad = 'ballad', | ||
| Dance = 'dance', | ||
| Trot = 'trot', | ||
| Folk = 'folk', | ||
| Ost = 'ost', | ||
| RockMetal = 'rock_metal', | ||
| RapHiphop = 'rap_hiphop', | ||
| RnbUrban = 'rnb_urban', | ||
| } | ||
|
|
||
| export const STR_TYPE_LABEL: Record<StrType, string> = { | ||
| [StrType.All]: '종합', | ||
| [StrType.Kpop]: '가요', | ||
| [StrType.Pop]: 'POP', | ||
| [StrType.Jpop]: 'JPOP', | ||
| [StrType.Ballad]: '발라드', | ||
| [StrType.Dance]: '댄스', | ||
| [StrType.Trot]: '트로트', | ||
| [StrType.Folk]: '포크', | ||
| [StrType.Ost]: 'OST', | ||
| [StrType.RockMetal]: '락/메탈', | ||
| [StrType.RapHiphop]: '랩/힙합', | ||
| [StrType.RnbUrban]: 'R&B/어반', | ||
| }; | ||
|
|
||
| export interface TjChartRankingSong extends Song { | ||
| rank: number; | ||
| } | ||
|
|
||
| export interface TjChartResponse { | ||
| month: string; | ||
| genre: StrType; | ||
| availableMonths: string[]; | ||
| items: TjChartRankingSong[]; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
2. Incomplete month list
🐞 Bug≡ CorrectnessAgent Prompt
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools