-
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
Merged
Merged
Changes from all commits
Commits
Show all changes
24 commits
Select commit
Hold shift + click to select a range
a049b93
feat : TJ 공식 차트 연동 기능 추가 (#295)
GulSam00 471a452
chore : 불일치 로그 업데이트
GulSam00 feeee00
feat : TJ 차트 월 이동 화살표 및 장르 뱃지 필터 적용 (#295)
GulSam00 a8c4fd1
fix : TJ 차트 API 요청 timeout 및 월 파라미터 처리 보완 (#295)
GulSam00 8a22dd8
doc : crawling CLAUDE.md TJ 차트 파이프라인 반영 (#295)
GulSam00 950e148
feat : 인기차트 제목 분리 및 장르 뱃지 이모지·휠 가로 스크롤 적용 (#295)
GulSam00 a29e060
feat : TJ 반주 버전 뱃지 수집 파이프라인 추가 (#295)
GulSam00 29574a5
chore : TJ 백필 구간 갱신 및 수집 로그 추가 (#295)
GulSam00 165e644
doc : crawling CLAUDE.md 뱃지 파이프라인 반영 (#295)
GulSam00 29ada6a
feat : 차트 미매칭 곡 자동 보충 및 API 타임아웃·재시도 보완 (#295)
GulSam00 f233e83
chore : 해소된 TJ 차트 미매칭 로그 정리 (#295)
GulSam00 5152aef
chore : TJ 차트 워크플로우에 미매칭 로그 아티팩트 업로드 추가 (#295)
GulSam00 328cb03
feat : 인기차트에 TJ 반주 버전 뱃지 노출 (#295)
GulSam00 623314f
feat : 검색 결과에도 반주 버전 뱃지 표시 및 UI 통일 (#295)
GulSam00 706021c
feat : TJ에서 사라진 번호의 곡 정리 스크립트 추가 (#295)
GulSam00 d5c09a3
fix : 60 뱃지 라벨에 TJ 명시 (#295)
GulSam00 285f61d
fix : 뱃지 묶음 앞에 TJ 표식을 붙여 출처 명시 (#295)
GulSam00 89a5563
fix : TJ 표식을 뱃지 테두리 안으로 이동 (#295)
GulSam00 0f65d84
fix : 신곡이 죽은 곡으로 오인돼 삭제되던 문제 (#295)
GulSam00 9555959
feat : 장르 필터 스크롤바 노출 및 이모지 교체 (#295)
GulSam00 66a46fc
feat : 가이드 예시 카드 가상화, 검색 추천 제거, 곡 카드 디자인 통일 (#295)
GulSam00 5ad9e3a
fix : 60 뱃지에만 TJ 표식 유지, MR·LIVE는 제거 (#295)
GulSam00 88678c5
fix : 번호 검색에서 언어 필터 숨김 및 값 초기화 (#295)
GulSam00 d95acce
fix : 국가 뱃지 줄바꿈 대신 가로 스크롤 (#295)
GulSam00 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
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
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,49 @@ | ||
| 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 | ||
|
|
||
| # 미매칭·경고 로그는 러너 파일시스템에만 쌓여 잡이 끝나면 사라진다. | ||
| # 실패해도(if: always()) 그때까지의 기록을 받을 수 있게 아티팩트로 올린다. | ||
| - name: Upload unmatched log | ||
| if: always() | ||
| uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: tj-chart-unmatched-${{ github.run_id }} | ||
| path: packages/crawling/src/assets/tjChartUnmatched.txt | ||
| if-no-files-found: ignore | ||
| retention-days: 30 |
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
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,79 @@ | ||
| 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; | ||
| } | ||
|
|
||
| const MONTH_PATTERN = /^\d{4}-\d{2}-\d{2}$/; | ||
|
|
||
| // availableMonths를 만들 때 chart_rankings 전체를 조회하면 PostgREST row 상한에 걸려 과거 월이 잘린다. | ||
| // 월당 최대 10 row만 반환되도록 종합 차트 상위권으로 좁혀서 조회한다. | ||
| const MONTH_LOOKUP_RANK_LIMIT = 10; | ||
|
|
||
| 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') | ||
| .eq('type', StrType.All) | ||
| .lte('rank', MONTH_LOOKUP_RANK_LIMIT) | ||
| .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: [] }, | ||
| }); | ||
| } | ||
|
|
||
| // 형식만 검증하고 요청한 월을 그대로 조회한다. | ||
| // (데이터가 없는 월이면 items가 빈 배열로 내려가고, 클라이언트가 빈 상태를 표시한다) | ||
| const monthParam = searchParams.get('month'); | ||
| const targetMonth = | ||
| monthParam && MONTH_PATTERN.test(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, badges)') | ||
| .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,67 @@ | ||
| 'use client'; | ||
|
|
||
| import { useEffect, useRef } from 'react'; | ||
|
|
||
| import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; | ||
| import { STR_TYPE_EMOJI, STR_TYPE_LABEL, StrType } from '@/types/tjChart'; | ||
|
|
||
| // 장르가 12종이라 줄바꿈 없이 한 줄로 두고 가로 스크롤로 훑게 한다. | ||
| // TabsTrigger 기본 스타일의 flex-1(균등 분할)은 flex-none으로 해제해야 칩 너비가 라벨에 맞는다. | ||
| const CHIP_CLASSES = | ||
| 'flex-none rounded-full border border-border bg-transparent px-3 py-1 text-xs font-medium text-muted-foreground shadow-none transition-all data-[state=active]:border-foreground data-[state=active]:bg-foreground data-[state=active]:text-background dark:data-[state=active]:border-foreground dark:data-[state=active]:bg-foreground dark:data-[state=active]:text-background'; | ||
|
|
||
| // 더 훑을 게 남았다는 걸 알 수 있도록 가로 스크롤바를 노출한다. | ||
| // 기본 스크롤바는 두꺼우므로 thin으로 얇게 하고, pb로 칩과 스크롤바 사이를 띄운다. | ||
| // thumb 색은 border 토큰이 다크 테마에서 배경과 거의 같아 muted-foreground를 쓴다. | ||
| // (Chrome 121+는 scrollbar-width가 ::-webkit-scrollbar보다 우선하므로 표준 속성을 함께 지정) | ||
| const LIST_CLASSES = | ||
| 'h-auto w-full flex-nowrap justify-start gap-1 overflow-x-auto bg-transparent p-0 pb-1 ' + | ||
| '[scrollbar-width:thin] [scrollbar-color:var(--color-muted-foreground)_transparent] ' + | ||
| '[&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-track]:bg-transparent ' + | ||
| '[&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-muted-foreground'; | ||
|
|
||
| interface ChartGenreFilterProps { | ||
| value: StrType; | ||
| onChange: (value: StrType) => void; | ||
| } | ||
|
|
||
| export default function ChartGenreFilter({ value, onChange }: ChartGenreFilterProps) { | ||
| const listRef = useRef<HTMLDivElement>(null); | ||
|
|
||
| // 데스크톱 마우스 휠은 세로 델타만 발생시켜 가로 목록을 훑을 수 없다. 세로 델타를 가로 스크롤로 넘긴다. | ||
| // React의 onWheel은 passive 리스너로 붙어 preventDefault가 막히므로 네이티브로 직접 등록한다. | ||
| useEffect(() => { | ||
| const list = listRef.current; | ||
| if (!list) return; | ||
|
|
||
| const handleWheel = (event: WheelEvent) => { | ||
| // 트랙패드의 가로 제스처는 브라우저 기본 동작에 맡긴다. | ||
| if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return; | ||
| if (list.scrollWidth <= list.clientWidth) return; | ||
|
|
||
| // 양 끝에 닿았으면 페이지 세로 스크롤을 가로채지 않는다. | ||
| const atStart = list.scrollLeft <= 0; | ||
| const atEnd = list.scrollLeft + list.clientWidth >= list.scrollWidth - 1; | ||
| if ((event.deltaY < 0 && atStart) || (event.deltaY > 0 && atEnd)) return; | ||
|
|
||
| event.preventDefault(); | ||
| list.scrollLeft += event.deltaY; | ||
| }; | ||
|
|
||
| list.addEventListener('wheel', handleWheel, { passive: false }); | ||
| return () => list.removeEventListener('wheel', handleWheel); | ||
| }, []); | ||
|
|
||
| return ( | ||
| <Tabs value={value} onValueChange={next => onChange(next as StrType)} className="w-full"> | ||
| <TabsList ref={listRef} className={LIST_CLASSES}> | ||
| {Object.values(StrType).map(type => ( | ||
| <TabsTrigger key={type} value={type} className={CHIP_CLASSES}> | ||
| <span aria-hidden="true">{STR_TYPE_EMOJI[type]}</span> | ||
| {STR_TYPE_LABEL[type]} | ||
| </TabsTrigger> | ||
| ))} | ||
| </TabsList> | ||
| </Tabs> | ||
| ); | ||
| } |
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,122 @@ | ||
| 'use client'; | ||
|
|
||
| import { addMonths, format, parseISO, startOfMonth } from 'date-fns'; | ||
| import { ChevronLeft, ChevronRight, Construction } from 'lucide-react'; | ||
| import { useState } from 'react'; | ||
|
|
||
| import SongSummary from '@/components/SongSummary'; | ||
| import StaticLoading from '@/components/StaticLoading'; | ||
| import { Button } from '@/components/ui/button'; | ||
| import { Card, CardContent, CardHeader } from '@/components/ui/card'; | ||
| import { ScrollArea } from '@/components/ui/scroll-area'; | ||
| import { useTjChartQuery } from '@/queries/tjChartQuery'; | ||
| import { StrType } from '@/types/tjChart'; | ||
| import { cn } from '@/utils/cn'; | ||
| import { getPrevMonthFirstDayKST } from '@/utils/kst'; | ||
|
|
||
| import ChartGenreFilter from './ChartGenreFilter'; | ||
|
|
||
| const MONTH_FORMAT = 'yyyy-MM-dd'; | ||
|
|
||
| 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)}월`; | ||
| }; | ||
|
|
||
| const shiftMonth = (month: string, delta: number) => | ||
| format(startOfMonth(addMonths(parseISO(month), delta)), MONTH_FORMAT); | ||
|
|
||
| export default function ChartRankingList() { | ||
| const [genre, setGenre] = useState<StrType>(StrType.All); | ||
| // 차트는 월 단위로 마감되므로 이번 달 데이터는 아직 없다. 전월을 기본으로 보여준다. | ||
| const [month, setMonth] = useState(getPrevMonthFirstDayKST); | ||
|
|
||
| const { data, isPending, isError, isPlaceholderData } = useTjChartQuery(month, genre); | ||
|
|
||
| if (isPending) { | ||
| return <StaticLoading />; | ||
| } | ||
|
|
||
| const availableMonths = data?.availableMonths ?? []; | ||
| const items = data?.items ?? []; | ||
|
|
||
| // availableMonths는 최신순이므로 마지막 원소가 가장 오래된 월이다. | ||
| const oldestMonth = availableMonths.at(-1); | ||
| const canGoPrev = !!oldestMonth && month > oldestMonth; | ||
| const canGoNext = month < getPrevMonthFirstDayKST(); | ||
|
|
||
| return ( | ||
| <Card className="relative flex min-h-0 flex-1 flex-col"> | ||
| <CardHeader className="flex shrink-0 flex-col gap-3 pb-2"> | ||
| {/* 차트 제목은 페이지 h1로 빠졌고, 헤더에는 조회 월만 중앙에 둔다. */} | ||
| <div className="flex items-center justify-center gap-1"> | ||
| <Button | ||
| variant="ghost" | ||
| size="icon" | ||
| className="size-8" | ||
| aria-label="이전 달" | ||
| disabled={!canGoPrev} | ||
| onClick={() => setMonth(shiftMonth(month, -1))} | ||
| > | ||
| <ChevronLeft /> | ||
| </Button> | ||
|
|
||
| <span className="w-24 text-center text-sm font-medium">{formatMonth(month)}</span> | ||
|
|
||
| <Button | ||
| variant="ghost" | ||
| size="icon" | ||
| className="size-8" | ||
| aria-label="다음 달" | ||
| disabled={!canGoNext} | ||
| onClick={() => setMonth(shiftMonth(month, 1))} | ||
| > | ||
| <ChevronRight /> | ||
| </Button> | ||
| </div> | ||
|
|
||
| <ChartGenreFilter value={genre} onChange={setGenre} /> | ||
| </CardHeader> | ||
|
|
||
| <ScrollArea className="min-h-0 flex-1"> | ||
| <CardContent className="pt-0"> | ||
| <div className={cn('space-y-0 transition-opacity', isPlaceholderData && 'opacity-50')}> | ||
| {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> | ||
| <SongSummary song={item} /> | ||
| </div> | ||
| )) | ||
| )} | ||
| </div> | ||
| </CardContent> | ||
| </ScrollArea> | ||
| </Card> | ||
| ); | ||
| } |
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