diff --git a/.github/workflows/backfill_artists.yml b/.github/workflows/backfill_artists.yml new file mode 100644 index 00000000..0f5dcce3 --- /dev/null +++ b/.github/workflows/backfill_artists.yml @@ -0,0 +1,64 @@ +name: Backfill Artists + +on: + schedule: + - cron: "0 1 1 * *" # 매달 1일 KST 오전 10:00 실행 (UTC+9 → UTC 01:00) — 최근 35일치만 증분 반영 + workflow_dispatch: + inputs: + since_days: + description: "최근 N일 이내 등록된 곡만 대상으로 함 (비워두면 전체 곡 대상 백필)" + required: false + +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: Determine backfill window + id: window + run: | + if [ "${{ github.event_name }}" = "schedule" ]; then + echo "since_days=35" >> "$GITHUB_OUTPUT" + else + echo "since_days=${{ inputs.since_days }}" >> "$GITHUB_OUTPUT" + fi + + - name: run backfill script - backfillArtists.ts + working-directory: packages/crawling + env: + ARTIST_BACKFILL_SINCE_DAYS: ${{ steps.window.outputs.since_days }} + run: pnpm run backfill-artists + + # 로그 파일은 러너 파일시스템에만 남고 잡이 끝나면 사라지므로, 실패해도(if: always()) 확인할 수 있게 올려둔다. + - name: Upload backfill log + if: always() + uses: actions/upload-artifact@v4 + with: + name: artist-backfill-log-${{ github.run_id }} + path: packages/crawling/src/assets/artistBackfillLog.txt + if-no-files-found: ignore + retention-days: 30 diff --git a/.github/workflows/finalize_artist_of_month.yml b/.github/workflows/finalize_artist_of_month.yml new file mode 100644 index 00000000..d88b3bd7 --- /dev/null +++ b/.github/workflows/finalize_artist_of_month.yml @@ -0,0 +1,53 @@ +name: Finalize Artist of the Month + +on: + schedule: + # 매일 00:10 KST(UTC 15:10) 실행. 확정 스크립트는 "지난달" 결과만 계산해 덮어쓰므로 + # 하루에 한 번 이상 돌아도 안전하다 — GitHub Actions cron 지연으로 자정 실행을 놓치는 상황에 대비한다. + - cron: "10 15 * * *" + workflow_dispatch: + +jobs: + finalize: + 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: Finalize artist of the month + working-directory: packages/crawling + run: pnpm run finalize-artist + + # 확정된 시상대(1~3위)의 사진만 채운다. 사진이 쓰이는 자리가 거기뿐이라 + # 13,000명을 미리 채워둘 이유가 없다. 이 잡은 매일 돌지만 사진이 이미 있는 아티스트는 + # 대상에서 빠지므로, 그 달 첫 성공 이후로는 대상이 0명이라 외부 API를 부르지 않는다. + # + # continue-on-error인 이유: 사진은 있으면 좋은 것이지 확정의 조건이 아니다. + # 외부 API(MusicBrainz·Deezer)가 죽어 있어도 순위 확정 자체는 성공으로 남겨야 한다. + - name: Backfill podium artist images + working-directory: packages/crawling + continue-on-error: true + env: + ARTIST_IMAGE_PODIUM: "3" + run: pnpm run backfill-artist-images diff --git a/.github/workflows/tagging_song.yml b/.github/workflows/tagging_song.yml index bf7ec9e9..efb142ca 100644 --- a/.github/workflows/tagging_song.yml +++ b/.github/workflows/tagging_song.yml @@ -1,8 +1,9 @@ name: Tagging Songs on: - schedule: - - cron: "0 7 * * *" # 매일 한국 시간 16:00 실행 (UTC+9 → UTC 7:00) + # 태그 기능 비활성화로 스케줄 실행 중단. 필요하면 workflow_dispatch로 수동 실행. + # schedule: + # - cron: "0 7 * * *" # 매일 한국 시간 16:00 실행 (UTC+9 → UTC 7:00) workflow_dispatch: permissions: diff --git a/.github/workflows/translation_jpn.yml b/.github/workflows/translation_jpn.yml index 4a72e37a..e6109622 100644 --- a/.github/workflows/translation_jpn.yml +++ b/.github/workflows/translation_jpn.yml @@ -1,8 +1,9 @@ name: Translation J-POP Songs on: - schedule: - - cron: "0 10 * * *" # 매일 한국 시간 19:00 실행 (UTC+9 → UTC 10:00) + # 태그 기능 비활성화로 스케줄 실행 중단. 필요하면 workflow_dispatch로 수동 실행. + # schedule: + # - cron: "0 10 * * *" # 매일 한국 시간 19:00 실행 (UTC+9 → UTC 10:00) workflow_dispatch: permissions: diff --git a/CLAUDE.md b/CLAUDE.md index 4a88a5b8..3f93438e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,10 @@ packages/ query/ — Shared TanStack Query hooks for open-api (@repo/query) api/ — Internal API utilities (@repo/api), built with tsup ui/ — Shared UI components (@repo/ui) + constants/ — Shared domain constants & rules (@repo/constants). 아티스트 별칭(`artistAlias`), + 이름 정규화(`artistName.ts`), 투표 집계 규칙(`rankArtistVotes.ts`), KST 월 계산 + (`kstMonth.ts`)이 있다. 배치(`packages/crawling`)와 웹이 같은 규칙을 써야 해 + 여기 둔다 — 한쪽만 고치면 화면에 적힌 순위와 저장된 순위가 어긋난다. eslint-config/ — Shared ESLint config (@repo/eslint-config) format-config/ — Shared Prettier config (@repo/format-config) typescript-config/ — Shared tsconfig bases @@ -69,17 +73,17 @@ See [apps/web/CLAUDE.md](apps/web/CLAUDE.md) for full detail. Key points: /start → /spsc → /red → /green → /refactor → /verify → /commit ``` -| 커맨드 | 설명 | 필수 여부 | -| ----------- | --------------------------------------- | --------- | -| `/start` | GitHub Issue 생성 + 작업 브랜치 체크아웃 | 권장 | -| `/spsc` | 이슈 기반 작업 범위 정의 | 권장 | -| `/red` | 실패 테스트 먼저 작성 (TDD) | 생략 가능 | -| `/green` | 구현 코드 작성 | 필수 | -| `/refactor` | 코드 품질 개선 (동작 변경 X) | 생략 가능 | -| `/verify` | build, lint, format, test 전체 검증 | **필수** | -| `/commit` | 커밋 메시지 생성 및 커밋 | **필수** | -| `/pr` | PR 생성 및 Qodo AI 리뷰 요청 | 권장 | -| `/check-review` | Qodo 리뷰 코멘트 읽기 및 이슈 브리핑 | 권장 | +| 커맨드 | 설명 | 필수 여부 | +| --------------- | ---------------------------------------- | --------- | +| `/start` | GitHub Issue 생성 + 작업 브랜치 체크아웃 | 권장 | +| `/spsc` | 이슈 기반 작업 범위 정의 | 권장 | +| `/red` | 실패 테스트 먼저 작성 (TDD) | 생략 가능 | +| `/green` | 구현 코드 작성 | 필수 | +| `/refactor` | 코드 품질 개선 (동작 변경 X) | 생략 가능 | +| `/verify` | build, lint, format, test 전체 검증 | **필수** | +| `/commit` | 커밋 메시지 생성 및 커밋 | **필수** | +| `/pr` | PR 생성 및 Qodo AI 리뷰 요청 | 권장 | +| `/check-review` | Qodo 리뷰 코멘트 읽기 및 이슈 브리핑 | 권장 | ### 단축 사이클 @@ -94,6 +98,7 @@ Branch format: `/-` — flow: `feat/*` → `d Types: `feat`, `fix`, `hotfix`, `chore`, `refactor`, `doc` Branch examples: + ``` feat/42-addSearchFilter fix/57-songCardCss @@ -103,12 +108,60 @@ chore/61-versionBump Commit format: ` : (#issue-number)` (space before and after colon) Examples: + ``` feat : MarqueeText 자동 스크롤 텍스트 적용 (#42) fix : SongCard css 수정 (#57) chore : 버전 2.3.0 (#61) ``` +## 진행 중인 작업 + +### #307 이달의 아티스트 (포인트 투표) + +코드는 완성됐다. 남은 건 배포 설정과 실데이터 QA다. + +**완료** + +- `apps/web/artist-vote-schema.sql` 실행 — 테이블 3종 + RLS 정책 + `artists.image_url`. + 최초 버전에는 정책이 하나도 없어서 앱(anon 키)에서 모든 조회가 조용히 빈 배열로 돌아왔다. + 파일 전체가 `if not exists` / `drop policy if exists`라 다시 실행해도 안전하다. +- `pnpm backfill-artists`로 `artists` 적재 (13,869명). +- 랭킹 화면: 1~3위 액자(1위 금 / 2·3위 황동) + 카드 위 우승 별(`WinStars`) + 득표 파이(1~10위). + 화면 확인용 목업은 제거했다. + +**남은 작업** + +1. GitHub Actions repo secret 확인 — `SUPABASE_URL`, `SUPABASE_KEY`(service_role). + `finalize_artist_of_month.yml`이 확정과 사진 백필 양쪽에 쓴다. 다른 크롤링 워크플로가 + 이미 쓰는 값이라 등록돼 있을 가능성이 높지만, 이 워크플로에서는 처음 참조한다. + **Vercel 쪽에 추가로 넣을 환경변수는 없다.** +2. 실데이터 QA (테스트 스위트 없음) + - 아티스트 검색 자동완성 — RLS 정책이 실제로 붙었는지 확인하는 가장 빠른 길 + - `artists(name_ko, image_url)` 임베드 첫 조회에서 `PGRST200`이 나면 FK 힌트 문법을 명시해야 한다 + - 투표(포인트 차감) → 월간 확정 → 곡 카드 배지까지. 카카오 로그인이라 자동 검증이 어렵다 +3. 아티스트 사진 — `artists.image_url` + - `finalize_artist_of_month.yml`의 마지막 단계가 채운다. 확정된 그달 1~3위 중 사진이 없는 + 사람만 `ARTIST_IMAGE_PODIUM=3`으로 처리한다. 사진이 쓰이는 자리가 시상대뿐이라 + 13,000명을 미리 채우지 않는다. + - 이름 매칭은 MusicBrainz(별칭 사전) → Deezer(사진) 순이다. 우리 DB는 TJ 표기(한글·원어)인데 + 음원 서비스 등록은 로마자라("방탄소년단" ↔ "BTS"), 이 다리가 없으면 사진이 있는데도 못 찾는다. + - 태연·잔나비·폴킴 3명은 이 다리를 붙이기 전에 채워져 팬 수 20~60짜리 중복 등록에서 온 사진이다. + 정품으로 바꾸려면 `update public.artists set image_url = null where name in (...)` 후 다시 돌려야 한다 + (`image_url is null`인 행만 대상이라 그냥 두면 갱신되지 않는다). + +**월간 확정이 도는 자리** + +`packages/crawling`의 `pnpm finalize-artist`다. 원래는 웹앱 API 라우트 +(`/api/artist-vote/finalize`)였는데, 인증 없는 공개 URL이 순위를 지우고 다시 쓰는 구조라 +공유 시크릿으로 잠가야 했다. 배치로 옮기면서 그 엔드포인트와 `ARTIST_VOTE_FINALIZE_SECRET`, +Vercel의 `SUPABASE_SERVICE_ROLE_KEY`가 모두 없어졌다. **확정 로직을 웹으로 되돌리지 말 것** — +공개 엔드포인트가 다시 생긴다. + +### 태그 기능 제거 (완료 — 참고용) + +검색 언어 태그 필터가 오작동해 관련 코드를 전부 걷어냈다: 프론트(`LanguageTagFilter.tsx` 등)와 `api/search`의 `song_tags` 조인, `artists.language_tag_id` 참조(추후 대시보드에서 컬럼 수동 삭제 예정)를 제거했고, `packages/crawling`의 `taggingSongs.ts`/`translationJpn.ts`는 삭제 대신 전체 주석처리했다. `tagging_song.yml`/`translation_jpn.yml`은 `schedule` 트리거만 비활성화(주석)했고 `workflow_dispatch`로 수동 실행은 여전히 가능하다. `tags`/`song_tags` 테이블 자체는 DB에 남아있지만 앱은 더 이상 참조하지 않는다. + ## Self-Maintenance 이 파일(CLAUDE.md)은 프로젝트의 규칙과 구조가 변경될 때 함께 업데이트한다. diff --git a/apps/web/artist-vote-schema.sql b/apps/web/artist-vote-schema.sql new file mode 100644 index 00000000..d2745a53 --- /dev/null +++ b/apps/web/artist-vote-schema.sql @@ -0,0 +1,95 @@ +-- 이달의 아티스트(포인트 투표) 스키마 + RLS 정책. +-- 여러 번 실행해도 안전하도록 작성했으니, 이미 테이블을 만든 환경에서도 이 파일을 그대로 다시 실행하면 된다. + +create table if not exists public.artists ( + id uuid primary key default gen_random_uuid(), + name text not null unique, + name_ko text, + image_url text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +-- 이미 만들어진 환경에도 컬럼을 채워 넣는다. 랭킹 1위 액자에 쓰는 아티스트 사진 URL이다. +alter table public.artists add column if not exists image_url text; + +alter table public.artists enable row level security; + +create table if not exists public.artist_votes ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references public.users(id) on delete cascade, + artist text not null references public.artists(name), + vote_month date not null, -- 매월 1일로 정규화 (예: 2026-08-01) + amount integer not null check (amount > 0), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (user_id, artist, vote_month) +); + +create index if not exists artist_votes_month_artist_idx on public.artist_votes (vote_month, artist); + +alter table public.artist_votes enable row level security; + +create table if not exists public.monthly_artist_rankings ( + id uuid primary key default gen_random_uuid(), + vote_month date not null, + rank smallint not null check (rank between 1 and 30), + artist text not null references public.artists(name), + total_votes integer not null, + top_voter_user_id uuid references public.users(id) on delete set null, + top_voter_amount integer, + decided_at timestamptz not null default now(), + unique (vote_month, rank), + unique (vote_month, artist) +); + +-- 최초 버전은 1~10위만 저장했다. 11~30위 구간을 화면에 쓰기 위해 제약을 넓힌다. +-- 제약 이름은 인라인 check에 postgres가 붙이는 기본 이름이다. +alter table public.monthly_artist_rankings + drop constraint if exists monthly_artist_rankings_rank_check; +alter table public.monthly_artist_rankings + add constraint monthly_artist_rankings_rank_check check (rank between 1 and 30); + +alter table public.monthly_artist_rankings enable row level security; + +-- RLS 정책 -------------------------------------------------------------- +-- 앱의 API 라우트는 사용자 세션(anon 키)으로 Supabase에 접근하므로, 정책이 없으면 +-- RLS가 모든 행을 가려 검색·랭킹이 조용히 빈 배열로 돌아온다. + +-- artists: 아티스트 검색/자동완성에 필요해 누구나 읽는다. +-- 쓰기는 백필 스크립트(service_role)만 하므로 정책을 주지 않는다. +drop policy if exists "artists_select_public" on public.artists; +create policy "artists_select_public" on public.artists for select using (true); + +-- monthly_artist_rankings: 확정된 월간 결과는 공개 데이터다. +-- 쓰기는 finalize 라우트(service_role) 전용이라 정책이 없다. +drop policy if exists "monthly_artist_rankings_select_public" on public.monthly_artist_rankings; +create policy "monthly_artist_rankings_select_public" on public.monthly_artist_rankings + for select using (true); + +-- artist_votes: 본인 투표는 언제나 볼 수 있고, 남의 투표는 "이미 확정된 (월, 아티스트)"만 공개한다. +-- 이렇게 두지 않으면 아직 집계 전인 이번 달 실시간 득표 현황이 그대로 새어나간다. +drop policy if exists "artist_votes_select_own_or_finalized" on public.artist_votes; +create policy "artist_votes_select_own_or_finalized" on public.artist_votes + for select using ( + auth.uid() = user_id + or exists ( + select 1 + from public.monthly_artist_rankings r + where r.vote_month = artist_votes.vote_month + and r.artist = artist_votes.artist + ) + ); + +-- 투표 등록/수정/삭제는 본인 행만. upsert가 insert와 update 정책을 모두 타므로 둘 다 필요하다. +drop policy if exists "artist_votes_insert_own" on public.artist_votes; +create policy "artist_votes_insert_own" on public.artist_votes + for insert with check (auth.uid() = user_id); + +drop policy if exists "artist_votes_update_own" on public.artist_votes; +create policy "artist_votes_update_own" on public.artist_votes + for update using (auth.uid() = user_id) with check (auth.uid() = user_id); + +drop policy if exists "artist_votes_delete_own" on public.artist_votes; +create policy "artist_votes_delete_own" on public.artist_votes + for delete using (auth.uid() = user_id); diff --git a/apps/web/package.json b/apps/web/package.json index 9f048a3a..7d74e819 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -63,6 +63,7 @@ "react-dom": "^19.0.0", "react-intersection-observer": "^9.16.0", "react-joyride": "3.2.0", + "recharts": "^3.10.1", "serwist": "^9.5.7", "sonner": "^2.0.3", "tailwind-merge": "^3.0.2", diff --git a/apps/web/src/Footer.tsx b/apps/web/src/Footer.tsx index 76bd7f4f..09f77f88 100644 --- a/apps/web/src/Footer.tsx +++ b/apps/web/src/Footer.tsx @@ -20,7 +20,7 @@ const navigation: Navigation[] = [ { name: '부를 곡', href: '/tosing', key: 'TOSING' }, { name: '검색', href: '/', key: 'SEARCH' }, - { name: '인기곡', href: '/popular', key: 'POPULAR' }, + { name: '인기 차트', href: '/popular', key: 'POPULAR' }, { name: '정보', href: '/info', key: 'INFO' }, ]; diff --git a/apps/web/src/app/api/artist-vote/_lib/artistWins.ts b/apps/web/src/app/api/artist-vote/_lib/artistWins.ts new file mode 100644 index 00000000..239c6ef3 --- /dev/null +++ b/apps/web/src/app/api/artist-vote/_lib/artistWins.ts @@ -0,0 +1,46 @@ +import { SupabaseClient } from '@supabase/supabase-js'; + +import { ArtistWin } from '@/types/artistVote'; + +interface WinRow { + artist: string; + vote_month: string; + total_votes: number; +} + +/** + * 여러 아티스트의 1위 이력을 한 번에 가져온다. + * 트로피 하나가 우승 한 번이라 개수만이 아니라 "언제, 몇 P로" 받았는지가 필요하다. + * 시상대(1~3위)마다 따로 조회하면 요청이 셋으로 늘어나므로 in 절로 묶는다. + * + * untilMonth로 잘라내는 게 핵심이다. 지난 달 결과를 볼 때는 그 시점에 실제로 갖고 있던 + * 우승만 보여야 한다 — 자르지 않으면 2026-03 화면에 그 뒤에 받은 우승까지 딸려 나와, + * 과거를 볼 때마다 개수가 오늘 기준으로 부풀려진다. + */ +export async function getArtistWins( + supabase: SupabaseClient, + artists: string[], + untilMonth: string, +): Promise> { + const wins = new Map(); + if (artists.length === 0) return wins; + + const { data, error } = await supabase + .from('monthly_artist_rankings') + .select('artist, vote_month, total_votes') + .eq('rank', 1) + .in('artist', artists) + // 조회 중인 달까지 포함한다. 그 달의 1위는 그 달 화면에서 이미 받은 우승이다. + .lte('vote_month', untilMonth) + .order('vote_month', { ascending: true }) + .returns(); + + if (error) throw error; + + for (const row of data ?? []) { + const list = wins.get(row.artist) ?? []; + list.push({ month: row.vote_month, totalVotes: row.total_votes }); + wins.set(row.artist, list); + } + return wins; +} diff --git a/apps/web/src/app/api/artist-vote/current-winner/route.ts b/apps/web/src/app/api/artist-vote/current-winner/route.ts new file mode 100644 index 00000000..c469259a --- /dev/null +++ b/apps/web/src/app/api/artist-vote/current-winner/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from 'next/server'; + +import createClient from '@/lib/supabase/server'; +import { ApiResponse } from '@/types/apiRoute'; +import { CurrentArtistOfMonth } from '@/types/artistVote'; + +// 곡 카드 배지용 — 가장 최근에 확정된 1위 아티스트와 그 달만 가볍게 반환한다. +export async function GET(): Promise>> { + try { + const supabase = await createClient(); + + const { data: latest, error: latestError } = await supabase + .from('monthly_artist_rankings') + .select('artist, vote_month') + .eq('rank', 1) + .order('vote_month', { ascending: false }) + .limit(1) + .maybeSingle(); + + if (latestError) throw latestError; + if (!latest) { + return NextResponse.json({ success: true, data: null }); + } + + return NextResponse.json({ + success: true, + data: { artist: latest.artist, month: latest.vote_month }, + }); + } catch (error) { + console.error('Error in GET current artist of month:', error); + return NextResponse.json( + { success: false, error: 'Failed to get current artist of month' }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/app/api/artist-vote/my-current/route.ts b/apps/web/src/app/api/artist-vote/my-current/route.ts new file mode 100644 index 00000000..f46ef155 --- /dev/null +++ b/apps/web/src/app/api/artist-vote/my-current/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from 'next/server'; + +import createClient from '@/lib/supabase/server'; +import { ApiResponse } from '@/types/apiRoute'; +import { MyArtistVote } from '@/types/artistVote'; +import { getAuthenticatedUser } from '@/utils/getAuthenticatedUser'; +import { getCurrentMonthFirstDayKST } from '@/utils/kst'; + +interface MyVoteRow { + artist: string; + amount: number; + artists: { name_ko: string | null } | null; +} + +export async function GET(): Promise>> { + try { + const supabase = await createClient(); + const userId = await getAuthenticatedUser(supabase); + const currentMonth = getCurrentMonthFirstDayKST(); + + // artist는 artists.name FK라 한국어 표기를 그대로 임베딩해 가져온다. + // 투표 목록에서도 곡 카드처럼 원어 표기 아래 한국어 표기를 보여주기 위함이다. + const { data, error } = await supabase + .from('artist_votes') + .select('artist, amount, artists(name_ko)') + .eq('user_id', userId) + .eq('vote_month', currentMonth) + .order('amount', { ascending: false }) + .returns(); + + if (error) throw error; + + const votes: MyArtistVote[] = (data ?? []).map(row => ({ + artist: row.artist, + amount: row.amount, + artistKo: row.artists?.name_ko ?? null, + })); + + return NextResponse.json({ success: true, data: votes }); + } catch (error) { + if (error instanceof Error && error.cause === 'auth') { + return NextResponse.json( + { success: false, error: 'User not authenticated' }, + { status: 401 }, + ); + } + console.error('Error in GET my-current artist votes:', error); + return NextResponse.json( + { success: false, error: 'Failed to get my artist votes' }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/app/api/artist-vote/rankings/[month]/[artist]/voters/route.ts b/apps/web/src/app/api/artist-vote/rankings/[month]/[artist]/voters/route.ts new file mode 100644 index 00000000..1b04549f --- /dev/null +++ b/apps/web/src/app/api/artist-vote/rankings/[month]/[artist]/voters/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from 'next/server'; + +import createClient from '@/lib/supabase/server'; +import { ApiResponse } from '@/types/apiRoute'; +import { ArtistVoter } from '@/types/artistVote'; + +interface VoterRow { + amount: number; + users: { nickname: string } | null; +} + +export async function GET( + _request: Request, + { params }: { params: Promise<{ month: string; artist: string }> }, +): Promise>> { + try { + const { month, artist: encodedArtist } = await params; + const artist = decodeURIComponent(encodedArtist); + + const supabase = await createClient(); + + // 확정된 랭킹에 포함된 (월, 아티스트) 조합만 공개한다. + // 이 검사가 없으면 아직 확정되지 않은 이번 달 실시간 득표 현황이 새어나간다. + const { data: rankingRow, error: rankingError } = await supabase + .from('monthly_artist_rankings') + .select('id') + .eq('vote_month', month) + .eq('artist', artist) + .maybeSingle(); + + if (rankingError) throw rankingError; + if (!rankingRow) { + return NextResponse.json( + { success: false, error: '확정되지 않은 아티스트입니다.' }, + { status: 404 }, + ); + } + + const { data, error } = await supabase + .from('artist_votes') + .select('amount, users(nickname)') + .eq('vote_month', month) + .eq('artist', artist) + .order('amount', { ascending: false }) + .returns(); + + if (error) throw error; + + const voters: ArtistVoter[] = (data ?? []).map(row => ({ + nickname: row.users?.nickname ?? '알 수 없음', + amount: row.amount, + })); + + return NextResponse.json({ success: true, data: voters }); + } catch (error) { + console.error('Error in GET artist voters:', error); + return NextResponse.json( + { success: false, error: 'Failed to get artist voters' }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/app/api/artist-vote/rankings/route.ts b/apps/web/src/app/api/artist-vote/rankings/route.ts new file mode 100644 index 00000000..c870fcd9 --- /dev/null +++ b/apps/web/src/app/api/artist-vote/rankings/route.ts @@ -0,0 +1,92 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { getArtistWins } from '@/app/api/artist-vote/_lib/artistWins'; +import createClient from '@/lib/supabase/server'; +import { ApiResponse } from '@/types/apiRoute'; +import { ArtistRankingResponse } from '@/types/artistVote'; + +const MONTH_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +interface RankingRow { + rank: number; + artist: string; + total_votes: number; + top_voter_amount: number | null; + users: { nickname: string } | null; + artists: { name_ko: string | null; image_url: string | null } | null; +} + +export async function GET( + request: NextRequest, +): Promise>> { + try { + const supabase = await createClient(); + const searchParams = request.nextUrl.searchParams; + + // 1) 확정된 월 목록 조회 (1위 행만 봐도 그 월이 확정됐는지 알 수 있다) + const { data: monthRows, error: monthError } = await supabase + .from('monthly_artist_rankings') + .select('vote_month') + .eq('rank', 1) + .order('vote_month', { ascending: false }); + + if (monthError) throw monthError; + + const availableMonths = [...new Set((monthRows ?? []).map(row => row.vote_month as string))]; + + // 조회 월의 주인은 클라이언트다. 아직 확정되지 않은 월을 요청해도 그 월을 그대로 돌려주고 + // 결과만 비워 보낸다. 여기서 임의로 다른 월로 바꾸면 월 선택기 값과 화면 데이터가 어긋난다. + const monthParam = searchParams.get('month'); + const targetMonth = + monthParam && MONTH_PATTERN.test(monthParam) ? monthParam : (availableMonths[0] ?? ''); + + if (!availableMonths.includes(targetMonth)) { + return NextResponse.json({ + success: true, + data: { month: targetMonth, availableMonths, items: [] }, + }); + } + + // 2) 해당 월 1~30위 조회 + const { data, error } = await supabase + .from('monthly_artist_rankings') + .select( + 'rank, artist, total_votes, top_voter_amount, users:top_voter_user_id(nickname), artists(name_ko, image_url)', + ) + .eq('vote_month', targetMonth) + .order('rank', { ascending: true }) + .returns(); + + if (error) throw error; + + const rows = data ?? []; + + // 3) 시상대(1~3위)의 1위 이력 조회 — 별(또는 트로피)을 그 개수만큼 새긴다. + // 그 달 1위가 아니어도 과거에 뽑힌 적이 있으면 표식이 남는다. + // 조회 중인 달까지로 자르므로, 지난 달을 보면 그 시점의 개수가 그대로 나온다. + const podiumArtists = rows.filter(row => row.rank <= 3).map(row => row.artist); + const winsByArtist = await getArtistWins(supabase, podiumArtists, targetMonth); + + const items = rows.map(row => ({ + rank: row.rank, + artist: row.artist, + artistKo: row.artists?.name_ko ?? null, + artistImage: row.artists?.image_url ?? null, + totalVotes: row.total_votes, + topVoterNickname: row.users?.nickname ?? null, + topVoterAmount: row.top_voter_amount, + wins: winsByArtist.get(row.artist) ?? [], + })); + + return NextResponse.json({ + success: true, + data: { month: targetMonth, availableMonths, items }, + }); + } catch (error) { + console.error('Error in GET artist rankings:', error); + return NextResponse.json( + { success: false, error: 'Failed to get artist rankings' }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/app/api/artist-vote/route.ts b/apps/web/src/app/api/artist-vote/route.ts new file mode 100644 index 00000000..6053db48 --- /dev/null +++ b/apps/web/src/app/api/artist-vote/route.ts @@ -0,0 +1,136 @@ +import { NextResponse } from 'next/server'; + +import createClient from '@/lib/supabase/server'; +import { ApiResponse } from '@/types/apiRoute'; +import { getAuthenticatedUser } from '@/utils/getAuthenticatedUser'; +import { getCurrentMonthFirstDayKST } from '@/utils/kst'; + +// artist_votes는 (user, artist, 이번 달) 당 1행이며 amount는 "현재 걸어둔 포인트"다. +// amount(절대값)를 보내면 기존 값과의 차액만큼 포인트를 차감/환불하고, +// amount=0이면 행을 삭제해 전액 환불한다. vote_month는 항상 서버가 계산한 +// "이번 달"만 사용해 지난 달 투표는 이 라우트로 절대 건드릴 수 없게 한다. +export async function PUT(request: Request): Promise>> { + try { + const supabase = await createClient(); + const userId = await getAuthenticatedUser(supabase); + + const { artist, amount } = await request.json(); + + if (typeof artist !== 'string' || !artist.trim()) { + return NextResponse.json({ success: false, error: 'artist is required' }, { status: 400 }); + } + if (typeof amount !== 'number' || !Number.isInteger(amount) || amount < 0) { + return NextResponse.json( + { success: false, error: 'amount는 0 이상의 정수여야 합니다.' }, + { status: 400 }, + ); + } + + const normalizedArtist = artist.trim(); + const currentMonth = getCurrentMonthFirstDayKST(); + + // artist_votes.artist는 artists.name을 FK로 참조한다. amount=0(삭제)이 아니라면 + // 여기서 미리 걸러야 포인트 차감 전에 실패하고, 사용자에게도 원인이 분명히 보인다. + if (amount > 0) { + const { data: artistRow, error: artistError } = await supabase + .from('artists') + .select('name') + .eq('name', normalizedArtist) + .maybeSingle(); + + if (artistError) throw artistError; + if (!artistRow) { + return NextResponse.json( + { success: false, error: '존재하지 않는 아티스트입니다.' }, + { status: 400 }, + ); + } + } + + const { data: existing, error: existingError } = await supabase + .from('artist_votes') + .select('amount') + .eq('user_id', userId) + .eq('artist', normalizedArtist) + .eq('vote_month', currentMonth) + .maybeSingle(); + + if (existingError) throw existingError; + + const currentAmount = existing?.amount ?? 0; + const diff = amount - currentAmount; + + if (diff === 0) { + return NextResponse.json({ success: true }); + } + + if (diff > 0) { + const { data: userData, error: userError } = await supabase + .from('users') + .select('point') + .eq('id', userId) + .single(); + + if (userError || !userData) throw userError ?? new Error('User not found'); + + if (userData.point < diff) { + return NextResponse.json( + { + success: false, + error: `포인트가 부족합니다. 필요: ${diff}P, 보유: ${userData.point}P`, + }, + { status: 400 }, + ); + } + } + + if (amount === 0) { + const { error: deleteError } = await supabase + .from('artist_votes') + .delete() + .match({ user_id: userId, artist: normalizedArtist, vote_month: currentMonth }); + + if (deleteError) throw deleteError; + } else { + const { error: upsertError } = await supabase.from('artist_votes').upsert( + { + user_id: userId, + artist: normalizedArtist, + vote_month: currentMonth, + amount, + updated_at: new Date().toISOString(), + }, + { onConflict: 'user_id,artist,vote_month' }, + ); + + if (upsertError) throw upsertError; + } + + const description = + diff > 0 + ? `아티스트 투표: ${normalizedArtist} (+${diff}P)` + : `아티스트 투표 조정: ${normalizedArtist} (${diff}P)`; + + const { error: pointError } = await supabase.rpc('record_point_change', { + p_user_id: userId, + p_amount: -diff, + p_description: description, + }); + + if (pointError) throw pointError; + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof Error && error.cause === 'auth') { + return NextResponse.json( + { success: false, error: 'User not authenticated' }, + { status: 401 }, + ); + } + console.error('Error in PUT artist-vote:', error); + return NextResponse.json( + { success: false, error: 'Failed to update artist vote' }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/app/api/artists/search/route.ts b/apps/web/src/app/api/artists/search/route.ts new file mode 100644 index 00000000..4af01e71 --- /dev/null +++ b/apps/web/src/app/api/artists/search/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import createClient from '@/lib/supabase/server'; +import { ApiResponse } from '@/types/apiRoute'; +import { ArtistSearchResult } from '@/types/artist'; + +const RESULT_LIMIT = 10; + +export async function GET( + request: NextRequest, +): Promise>> { + try { + const query = request.nextUrl.searchParams.get('q')?.trim(); + if (!query) { + return NextResponse.json({ success: true, data: [] }); + } + + const supabase = await createClient(); + const pattern = `%${query}%`; + + // 원어 표기(name)와 한국어 표기(name_ko) 양쪽을 각각 조회해 합친다. + // ilike 값은 supabase-js가 바인딩 파라미터로 보내므로 or() 필터 문자열 조립보다 안전하다. + const [byName, byNameKo] = await Promise.all([ + supabase.from('artists').select('name, name_ko').ilike('name', pattern).limit(RESULT_LIMIT), + supabase + .from('artists') + .select('name, name_ko') + .ilike('name_ko', pattern) + .limit(RESULT_LIMIT), + ]); + + if (byName.error) throw byName.error; + if (byNameKo.error) throw byNameKo.error; + + const merged = new Map(); + for (const row of [...(byName.data ?? []), ...(byNameKo.data ?? [])]) { + merged.set(row.name, row); + } + + return NextResponse.json({ + success: true, + data: [...merged.values()].slice(0, RESULT_LIMIT), + }); + } catch (error) { + console.error('Error in GET artists search:', error); + return NextResponse.json( + { success: false, error: 'Failed to search artists' }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/app/api/search/route.ts b/apps/web/src/app/api/search/route.ts index c9876cfa..a662a57e 100644 --- a/apps/web/src/app/api/search/route.ts +++ b/apps/web/src/app/api/search/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from 'next/server'; -import { LANGUAGE_TAGS } from '@/constants/languageTags'; import createClient from '@/lib/supabase/server'; import { ApiResponse } from '@/types/apiRoute'; import { SearchSong, Song } from '@/types/song'; @@ -57,36 +56,20 @@ function buildColumnOrGroup(columns: string[], pattern: string): string { return columns.map(column => `${column}.ilike.${pattern}`).join(','); } -// languageTag가 있으면 song_tags(언어 태그) 조인 필터를 추가한다. -// select 절에 `song_tags!inner(tag_id)`가 함께 포함되어 있어야 한다. -function applyLanguageTagFilter T }>( - query: T, - languageTag?: number, -): T { - return languageTag ? query.eq('song_tags.tag_id', languageTag) : query; -} - // 정확 일치: 전체 구가 한 컬럼과 일치 (랭킹 상위). 띄어쓰기 차이는 무시한다. -function applyExactFilter(baseQuery: any, type: string, searchText: string, languageTag?: number) { +function applyExactFilter(baseQuery: any, type: string, searchText: string) { if (type === 'number') { - const query = baseQuery.or(`num_tj.eq.${searchText},num_ky.eq.${searchText}`); - return applyLanguageTagFilter(query, languageTag); + return baseQuery.or(`num_tj.eq.${searchText},num_ky.eq.${searchText}`); } const phrase = buildPhrasePattern(tokenizeQuery(searchText)); const columns = getSearchColumns(type); - const query = baseQuery.or(buildColumnOrGroup(columns, phrase)); - return applyLanguageTagFilter(query, languageTag); + return baseQuery.or(buildColumnOrGroup(columns, phrase)); } // 부분 일치: 입력한 모든 토큰이 (순서 무관) 포함돼야 매칭(AND) + 정확 일치 결과는 제외. -function applyPartialFilter( - baseQuery: any, - type: string, - searchText: string, - languageTag?: number, -) { +function applyPartialFilter(baseQuery: any, type: string, searchText: string) { const tokens = tokenizeQuery(searchText); const phrase = buildPhrasePattern(tokens); const columns = getSearchColumns(type); @@ -107,7 +90,7 @@ function applyPartialFilter( query = query.or(`${column}.not.ilike.${phrase},${column}.is.null`); } - return applyLanguageTagFilter(query, languageTag); + return query; } async function executeSearchQueries( @@ -118,7 +101,6 @@ async function executeSearchQueries( order: string, from: number, to: number, - languageTag?: number, ): Promise<{ data: DBSong[]; hasNext: boolean } | { error: string }> { const size = to - from + 1; @@ -128,17 +110,11 @@ async function executeSearchQueries( supabase.from('songs').select(selectClause, { count: 'exact', head: true }), type, query, - languageTag, ); if (exactCountResult.error) return { error: exactCountResult.error.message }; const exactTotal = exactCountResult.count ?? 0; - const exactQuery = applyExactFilter( - supabase.from('songs').select(selectClause), - type, - query, - languageTag, - ); + const exactQuery = applyExactFilter(supabase.from('songs').select(selectClause), type, query); const { data, error } = await exactQuery.order(order).range(from, to); if (error) return { error: error.message }; @@ -158,13 +134,11 @@ async function executeSearchQueries( supabase.from('songs').select(selectClause, { count: 'exact', head: true }), type, query, - languageTag, ); const partialCountQuery = applyPartialFilter( supabase.from('songs').select(selectClause, { count: 'exact', head: true }), type, query, - languageTag, ); const [exactCountResult, partialCountResult] = await Promise.all([ @@ -189,7 +163,6 @@ async function executeSearchQueries( supabase.from('songs').select(selectClause), type, query, - languageTag, ); const { data, error } = await partialQuery.order(order).range(from, to); if (error) return { error: error.message }; @@ -202,7 +175,6 @@ async function executeSearchQueries( supabase.from('songs').select(selectClause), type, query, - languageTag, ); const { data, error } = await partialQuery.order(order).range(partialFrom, partialTo); if (error) return { error: error.message }; @@ -210,12 +182,7 @@ async function executeSearchQueries( } else { // 현재 페이지가 정확 일치 영역에 포함 (경계에 걸릴 수도 있음) const exactTo = Math.min(to, exactTotal - 1); - const exactQuery = applyExactFilter( - supabase.from('songs').select(selectClause), - type, - query, - languageTag, - ); + const exactQuery = applyExactFilter(supabase.from('songs').select(selectClause), type, query); const exactResult = await exactQuery.order(order).range(from, exactTo); if (exactResult.error) return { error: exactResult.error.message }; exactData = (exactResult.data as DBSong[]) ?? []; @@ -227,7 +194,6 @@ async function executeSearchQueries( supabase.from('songs').select(selectClause), type, query, - languageTag, ); const partialResult = await partialQuery.order(order).range(0, partialTo); if (partialResult.error) return { error: partialResult.error.message }; @@ -251,23 +217,6 @@ export async function GET(request: Request): Promise tag.id === parsedLanguageTag); - if (!isValidLanguageTag) { - return NextResponse.json( - { - success: false, - error: '유효하지 않은 languageTag 값입니다', - }, - { status: 400 }, - ); - } - languageTag = parsedLanguageTag; - } - const page = parseInt(searchParams.get('page') || '0', 10); const size = 20; const from = page * size; @@ -285,25 +234,11 @@ export async function GET(request: Request): Promise, }, ]; diff --git a/apps/web/src/app/info/reports/page.tsx b/apps/web/src/app/info/reports/page.tsx index 4d470825..1ab0394f 100644 --- a/apps/web/src/app/info/reports/page.tsx +++ b/apps/web/src/app/info/reports/page.tsx @@ -30,7 +30,7 @@ export default function MyReportsPage() { -

내 신고 내역

+

수정 요청 내역

diff --git a/apps/web/src/app/popular/ArtistName.tsx b/apps/web/src/app/popular/ArtistName.tsx new file mode 100644 index 00000000..6a919282 --- /dev/null +++ b/apps/web/src/app/popular/ArtistName.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { cn } from '@/utils/cn'; + +interface ArtistNameProps { + name: string; + artistKo: string | null; + /** 바깥 래퍼(원어 표기 줄)에 얹을 스타일 */ + className?: string; +} + +/** + * 아티스트 원어 표기 + 그 아래 작은 한국어 표기. + * + * 곡 카드(SongSummary)가 제목·아티스트의 한국어 표기를 다루는 규칙을 그대로 따른다. + * 한국어 표기가 원어 표기와 같으면(예: name과 name_ko가 모두 'IVE') 같은 글자가 두 번 + * 보일 뿐이라 감춘다. 버튼 안에서도 쓰이므로 래퍼는 div가 아닌 span이다. + */ +export default function ArtistName({ name, artistKo, className }: ArtistNameProps) { + const hasKoName = !!artistKo && artistKo !== name; + + return ( + + {name} + {hasKoName && ( + {artistKo} + )} + + ); +} diff --git a/apps/web/src/app/popular/ArtistOfMonthTab.tsx b/apps/web/src/app/popular/ArtistOfMonthTab.tsx new file mode 100644 index 00000000..9139493b --- /dev/null +++ b/apps/web/src/app/popular/ArtistOfMonthTab.tsx @@ -0,0 +1,117 @@ +'use client'; + +import { addMonths, format, parseISO, startOfMonth } from 'date-fns'; +import { ChartPie, Construction, List } from 'lucide-react'; +import { useState } from 'react'; + +import StaticLoading from '@/components/StaticLoading'; +import { Button } from '@/components/ui/button'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { useArtistRankingsQuery } from '@/queries/artistVoteQuery'; +import { cn } from '@/utils/cn'; +import { getCurrentMonthFirstDayKST, getPrevMonthFirstDayKST } from '@/utils/kst'; + +import ArtistRankingBoard from './ArtistRankingBoard'; +import ArtistRankingChart from './ArtistRankingChart'; +import ArtistVotePanel from './ArtistVotePanel'; +import MonthSelector from './MonthSelector'; + +const MONTH_FORMAT = 'yyyy-MM-dd'; +const shiftMonth = (month: string, delta: number) => + format(startOfMonth(addMonths(parseISO(month), delta)), MONTH_FORMAT); + +export default function ArtistOfMonthTab() { + // 순위는 월말에 확정되므로 인기곡 차트와 똑같이 전월을 기본으로 본다. + const [month, setMonth] = useState(getPrevMonthFirstDayKST); + // 같은 순위 데이터를 순위표로 볼지, 득표 비중 파이로 볼지 고르는 스위치 + const [view, setView] = useState<'list' | 'chart'>('list'); + + const currentMonth = getCurrentMonthFirstDayKST(); + // 이번 달은 아직 집계 전이라 순위 대신 내 투표를 편집하는 화면을 보여준다. + const isVotingMonth = month === currentMonth; + + const { data, isPending, isPlaceholderData } = useArtistRankingsQuery(month); + + if (isPending) { + return ; + } + + const availableMonths = data?.availableMonths ?? []; + const items = data?.items ?? []; + + // 확정 월 + 이번 달(투표) + 현재 조회 월을 합쳐 최신순으로 둔다. + // 아직 확정되지 않은 월을 보고 있어도 셀렉트 값이 비지 않게 하기 위함이다. + const selectableMonths = [...new Set([...availableMonths, currentMonth, month])].sort().reverse(); + + const oldestMonth = selectableMonths.at(-1); + const canGoPrev = !!oldestMonth && month > oldestMonth; + const canGoNext = month < currentMonth; + + return ( +
+ setMonth(shiftMonth(month, -1))} + onNext={() => setMonth(shiftMonth(month, 1))} + onChange={setMonth} + /> + + {isVotingMonth ? ( + + ) : ( + <> + {items.length > 0 && ( +
+ {/* 지난 달 결과를 보다가 바로 이번 달 투표로 넘어갈 수 있게 둔다. + 월 선택기의 다음 달 버튼으로도 갈 수 있지만 그건 눈에 잘 띄지 않는다. */} + + +
+ + +
+
+ )} + + +
+ {items.length === 0 ? ( +
+ +

아직 확정된 결과가 없어요

+
+ ) : view === 'chart' ? ( + + ) : ( + + )} +
+
+ + )} +
+ ); +} diff --git a/apps/web/src/app/popular/ArtistPortrait.tsx b/apps/web/src/app/popular/ArtistPortrait.tsx new file mode 100644 index 00000000..85973c51 --- /dev/null +++ b/apps/web/src/app/popular/ArtistPortrait.tsx @@ -0,0 +1,119 @@ +'use client'; + +import { cn } from '@/utils/cn'; + +/** + * 액자 톤. 1위만 금이고 2·3위는 황동 하나를 똑같이 나눠 쓴다. + * + * 금과 같은 난색 계열을 쓰되 밝기가 아니라 **채도**로 갈라놓은 게 핵심이다. + * 금보다 어둡기만 한 색(구리·청동)을 옆에 놓으면 다른 금속이 아니라 '바랜 금'으로 읽힌다. + * 채도를 떨어뜨리면 같은 계열 안에서도 금은 보석, 황동은 기물로 분리돼 보인다. + * + * 2·3위가 완전히 같은 톤이라 둘 사이의 위계는 색이 아니라 순위 라벨과 카드 순서만 진다. + * 은(회색)을 쓰지 않는 이유는 따로 있다 — 회색은 UI에서 이미 '비활성'을 뜻해서 + * 2위가 대접받는 게 아니라 꺼져 있는 것처럼 보인다. + */ +export type PortraitTone = 'gold' | 'brass' | 'plain'; + +/** + * 액자 몰딩의 결. 한 방향 그라데이션에 밝은 면과 어두운 면을 번갈아 넣어야 + * 납작한 색 띠가 아니라 빛을 받는 금속·나무 테로 보인다. + */ +const FRAME_SURFACE: Record = { + gold: 'linear-gradient(135deg, #f9e6a4 0%, #dcae3e 30%, #a8801f 55%, #f4dd93 78%, #c99b2c 100%)', + // 금과 같은 각도·같은 명암 구조에 채도만 덜어낸 결. 값을 만질 땐 금 쪽과 나란히 놓고 봐야 한다. + brass: 'linear-gradient(135deg, #e6d9bb 0%, #c0a97c 30%, #8a7550 55%, #dccfae 78%, #a38c64 100%)', + plain: 'linear-gradient(135deg, #c29260 0%, #8a6440 30%, #6b4c30 55%, #a67c52 100%)', +}; + +/** 사진이 없을 때 그림 자리에 깔리는 색 */ +const CANVAS_FALLBACK: Record = { + gold: 'bg-amber-500/15 text-amber-700 dark:text-amber-400', + brass: 'bg-[#a38c64]/15 text-[#8a7450] dark:text-[#d3bf95]', + plain: 'bg-muted text-muted-foreground', +}; + +/** + * 몰딩 위를 지나가는 하이라이트 띠. 금속 결 위에 이 한 겹을 더 깔고 위치만 흘린다. + * 각도(120deg)와 띠 폭은 React Bits의 ShinyText가 쓰는 값을 그대로 가져왔다. + */ +const FRAME_SHEEN = + 'linear-gradient(120deg, transparent 38%, rgb(255 255 255 / 0.75) 50%, transparent 62%)'; + +// 몰딩의 입체감: 바깥으로 드리우는 그림자 + 위쪽 하이라이트 + 아래쪽 음영. +const FRAME_SHADOW = + '0 3px 8px rgb(0 0 0 / 0.28), inset 0 1px 0 rgb(255 255 255 / 0.6), inset 0 -1px 0 rgb(0 0 0 / 0.35)'; +// 그림이 매트보다 안쪽으로 들어가 보이게 하는 그림자 +const CANVAS_SHADOW = 'inset 0 2px 6px rgb(0 0 0 / 0.35)'; + +interface ArtistPortraitProps { + name: string; + imageUrl: string | null; + /** 액자 바깥 한 변의 픽셀 크기 */ + size: number; + tone?: PortraitTone; + className?: string; +} + +/** + * 아티스트 사진 액자. 몰딩 → 매트(여백) → 그림 순으로 세 겹을 쌓아 실제 액자처럼 보이게 한다. + * 안쪽 여백을 %로 잡아 size를 바꿔도 세 겹의 비율이 그대로 유지된다. + * + * artists.image_url은 이제 막 생긴 컬럼이라 당분간 대부분 비어 있다. + * 빈 액자를 그리면 화면이 무너져 보여, 사진이 채워지기 전까지는 이니셜이 그 자리를 지킨다. + */ +export default function ArtistPortrait({ + name, + imageUrl, + size, + tone = 'plain', + className, +}: ArtistPortraitProps) { + return ( +
+ {/* 매트 — 몰딩과 그림 사이의 여백. 몰딩과 함께 얇아져야 테 전체가 얇아 보인다. + 몰딩만 줄이면 흰 여백이 남아 테두리 두께가 그대로인 것처럼 읽힌다. */} +
+
+ {imageUrl ? ( + // next/image가 아닌 이유: image_url에 어떤 도메인이 들어올지 정해지지 않았는데 + // next.config.ts에 images.remotePatterns가 없어 외부 URL이면 런타임에 깨진다. + // eslint-disable-next-line @next/next/no-img-element + {name} + ) : ( +
+ {name.slice(0, 1)} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/app/popular/ArtistRankingBoard.tsx b/apps/web/src/app/popular/ArtistRankingBoard.tsx new file mode 100644 index 00000000..ed65492a --- /dev/null +++ b/apps/web/src/app/popular/ArtistRankingBoard.tsx @@ -0,0 +1,163 @@ +'use client'; + +import { ArtistRankingItem } from '@/types/artistVote'; +import { cn } from '@/utils/cn'; +import { formatVoteShare } from '@/utils/formatVoteShare'; + +import ArtistName from './ArtistName'; +import ArtistPortrait, { PortraitTone } from './ArtistPortrait'; +import WinStars from './WinStars'; + +/** + * 1~3위 액자 카드의 톤·크기. 1위만 한 단계 크게 둬서 위계를 남긴다. + * 2·3위는 은·동 대신 황동 하나를 그대로 나눠 쓴다(이유는 PortraitTone 주석 참고). + * 둘의 카드가 완전히 같으므로, 크기 차이를 1위와의 사이에만 두는 지금 배치를 유지해야 + * 2위와 3위가 순위 라벨과 순서로만 구분된다. + */ +const MEDAL = { + 1: { tone: 'gold', size: 96, card: 'border-amber-500 bg-amber-500/10' }, + 2: { tone: 'brass', size: 80, card: 'border-[#a38c64] bg-[#a38c64]/10' }, + 3: { tone: 'brass', size: 80, card: 'border-[#a38c64] bg-[#a38c64]/10' }, +} satisfies Record; + +/** + * 액자가 차지하는 자리의 폭. 액자 자체는 순위마다 작아져도 이 칸은 1위 크기로 고정한다. + * 칸이 액자를 따라 줄면 오른쪽 본문이 카드마다 다른 지점에서 시작해, 세 카드를 세로로 + * 쌓았을 때 이름 줄이 계단처럼 어긋난다. 1위 크기를 그대로 참조해 둘이 벌어지지 않게 한다. + */ +const PORTRAIT_SLOT = MEDAL[1].size; + +/** + * 4위 아래 막대의 농도. 같은 색 하나를 옅게 단계지어 구간을 구분한다. + * 서로 다른 색을 쓰면 순위가 아니라 종류가 다른 것처럼 읽힌다. + */ +const BAR_TONES = [ + { from: 4, to: 10, bar: 'bg-foreground/70' }, + { from: 11, to: 20, bar: 'bg-foreground/45' }, + { from: 21, to: 30, bar: 'bg-foreground/25' }, +]; + +const getBarTone = (rank: number) => + BAR_TONES.find(tone => rank >= tone.from && rank <= tone.to)?.bar ?? 'bg-foreground/25'; + +interface MedalCardProps { + item: ArtistRankingItem; + total: number; +} + +/** 1~3위 — 왼쪽 액자, 오른쪽 정보 */ +function MedalCard({ item, total }: MedalCardProps) { + const medal = MEDAL[item.rank as 1 | 2 | 3]; + + return ( +
+ {/* 역대 우승 표식 — 프로 스포츠가 엠블럼 위에 우승 별을 달듯 카드 맨 위에 새긴다. */} + + +
+ {/* 작은 액자는 1위와 같은 폭의 칸 안에 가운데로 놓는다. + 왼쪽으로 붙이면 액자 오른쪽에만 빈 자리가 생겨 칸을 고정한 티가 난다. */} + + + + + + {item.rank === 1 ? ( + + 🏆 이달의 아티스트 + + ) : ( + {item.rank}위 + )} + + + + + {item.totalVotes}P ·{' '} + {formatVoteShare(item.totalVotes, total)} + + + {item.topVoterNickname && ( + + 최다 투표 {item.topVoterNickname} + {item.topVoterAmount !== null && ` (${item.topVoterAmount}P)`} + + )} + +
+
+ ); +} + +interface BarRowProps { + item: ArtistRankingItem; + total: number; + /** 1위 득표. 막대 길이의 기준이라 전 구간이 같은 자로 재진다. */ + maxVotes: number; +} + +/** 4~30위 — 액자 없이 한 줄. 막대 길이는 득표량, 농도는 순위 구간을 뜻한다. */ +function BarRow({ item, total, maxVotes }: BarRowProps) { + const width = maxVotes > 0 ? (item.totalVotes / maxVotes) * 100 : 0; + + return ( +
+ + {item.rank} + + + + + + {/* 득표가 아주 적어도 막대가 사라지지 않도록 최소 폭을 남긴다 */} + + + + + + {item.totalVotes}P + {formatVoteShare(item.totalVotes, total)} + +
+ ); +} + +interface ArtistRankingBoardProps { + items: ArtistRankingItem[]; +} + +export default function ArtistRankingBoard({ items }: ArtistRankingBoardProps) { + const total = items.reduce((sum, item) => sum + item.totalVotes, 0); + const maxVotes = Math.max(...items.map(item => item.totalVotes), 1); + + const medalists = items.filter(item => item.rank <= 3); + const rest = items.filter(item => item.rank >= 4); + + return ( +
+ {medalists.map(item => ( + + ))} + + {rest.length > 0 && ( +
+ {rest.map(item => ( + + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/app/popular/ArtistRankingChart.tsx b/apps/web/src/app/popular/ArtistRankingChart.tsx new file mode 100644 index 00000000..63a0b9de --- /dev/null +++ b/apps/web/src/app/popular/ArtistRankingChart.tsx @@ -0,0 +1,217 @@ +'use client'; + +import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts'; + +import { ArtistRankingItem } from '@/types/artistVote'; +import { formatVoteShare } from '@/utils/formatVoteShare'; + +// 1~10위를 각자 조각으로 두고, 11위 아래만 한 덩어리로 접는다. +// 조각이 열 개면 파이가 원래 잘하는 "한눈에 비중" 읽기는 포기하는 셈이라, +// 아래 범례가 순위·이름·득표를 다 적어 표 역할을 겸한다. 색은 조각을 세는 수단일 뿐이다. +const TOP_SLICE_COUNT = 10; + +// 검증을 통과한 카테고리 팔레트(globals.css). 순위가 아니라 슬롯 순서대로 배정한다. +const HUES = [ + 'var(--chart-1)', + 'var(--chart-2)', + 'var(--chart-3)', + 'var(--chart-4)', + 'var(--chart-5)', + 'var(--chart-6)', + 'var(--chart-7)', + 'var(--chart-8)', +]; + +/** + * 아홉·열 번째 조각. 카테고리 색은 여덟 칸이 한계라 새 색을 만들지 않는다 — + * 아홉 번째로 지어낸 색은 반드시 앞의 여덟 중 하나와 붙어 구분이 무너진다. + * 대신 앞선 색을 빗금으로 다시 써서, 구분을 색이 아니라 색+질감이 지게 한다. + * 각도는 45°와 그 거울인 135° 둘뿐이다. 각도를 더 쪼개면 그것도 결국 색과 같은 문제를 만난다. + */ +const HATCHES = [ + { id: 'artist-slice-hatch-a', hue: 'var(--chart-1)', angle: 45 }, + { id: 'artist-slice-hatch-b', hue: 'var(--chart-2)', angle: 135 }, +]; + +/** 빗금 위에 얹는 그늘. 같은 색의 어두운 결로 보이도록 검정을 옅게 깐다. */ +const HATCH_INK = 'rgb(0 0 0 / 0.38)'; + +// "그 외"는 카테고리가 아니라 나머지를 뭉친 덩어리라 색을 빼고 회색으로 둔다. +const REST_COLOR = 'var(--muted-foreground)'; + +interface Slice { + /** 조각 식별자. 이름이 겹쳐도 되도록 순위를 섞어 만든다. */ + key: string; + /** 툴팁·범례에 뜨는 이름 */ + name: string; + /** 범례 앞에 붙는 순위. "그 외" 묶음은 순위가 없다. */ + rank: number | null; + value: number; + /** 파이 조각의 fill (색 또는 빗금 패턴) */ + fill: string; + /** 범례 칩의 CSS background. 파이 조각과 같은 모양이어야 한다. */ + chip: string; +} + +/** 순위(0부터)에 맞는 칠. 여덟 칸을 넘어가면 빗금으로 넘어간다. */ +function getFill(index: number): Pick { + if (index < HUES.length) { + return { fill: HUES[index], chip: HUES[index] }; + } + + const hatch = HATCHES[index - HUES.length]; + return { + fill: `url(#${hatch.id})`, + // 파이는 SVG 패턴, 범례는 CSS 그라데이션이라 방식이 다르다. 결과가 같아야 해서 + // 줄 간격(3px/6px)과 그늘 농도를 양쪽에 같은 값으로 적어 둔다. + chip: `repeating-linear-gradient(${hatch.angle}deg, ${HATCH_INK} 0 3px, transparent 3px 6px), ${hatch.hue}`, + }; +} + +/** + * 빗금 패턴 정의. + * recharts가 차트 안에 끼워 넣은 자식을 그대로 통과시키는지에 기대지 않으려고 별도 svg에 둔다. + * 패턴은 id로 참조되므로 문서 어디에 있든 걸린다. + */ +function HatchDefs() { + return ( + + + {HATCHES.map(hatch => ( + + {/* fill 속성 대신 style인 이유: 프로젝트 색이 CSS 변수라 값이 확실히 + 풀리는 자리에 둔다. 표현 속성에서의 var()는 브라우저별로 이력이 있다. */} + + + + ))} + + + ); +} + +interface SliceLegendProps { + slices: Slice[]; + total: number; +} + +/** + * 조각이 열한 개라 원 안에 이름을 넣을 자리가 없다. 색 단독으로 구분하지 않도록 + * 범례가 순위·이름·수치를 다 적어 표 역할까지 겸한다. + * 조각은 12시부터 순위 순으로 도니, 범례 순서가 곧 조각 순서다. + */ +function SliceLegend({ slices, total }: SliceLegendProps) { + return ( +
    + {slices.map(slice => ( +
  • + + {/* "그 외" 줄도 자리는 비워 둔다 — 순위 칸이 사라지면 이름 줄이 혼자 밀린다. */} + + {slice.rank ?? ''} + + {slice.name} + + {slice.value}P · {formatVoteShare(slice.value, total)} + +
  • + ))} +
+ ); +} + +interface ArtistRankingChartProps { + items: ArtistRankingItem[]; +} + +export default function ArtistRankingChart({ items }: ArtistRankingChartProps) { + const total = items.reduce((sum, item) => sum + item.totalVotes, 0); + + if (items.length === 0 || total === 0) { + return

표시할 득표가 없어요.

; + } + + const top = items.slice(0, TOP_SLICE_COUNT); + const rest = items.slice(TOP_SLICE_COUNT); + const restTotal = rest.reduce((sum, item) => sum + item.totalVotes, 0); + + const slices: Slice[] = [ + ...top.map((item, index) => ({ + key: `${item.rank}-${item.artist}`, + name: item.artistKo && item.artistKo !== item.artist ? item.artistKo : item.artist, + rank: item.rank, + value: item.totalVotes, + ...getFill(index), + })), + ...(restTotal > 0 + ? [ + { + key: 'rest', + name: `그 외 ${rest.length}명`, + rank: null, + value: restTotal, + fill: REST_COLOR, + chip: REST_COLOR, + }, + ] + : []), + ]; + + return ( +
+ + +
+ + + + {slices.map(slice => ( + + ))} + + { + const amount = Number(value); + return [`${amount}P (${formatVoteShare(amount, total)})`, String(name)]; + }} + contentStyle={{ + background: 'var(--popover)', + border: '1px solid var(--border)', + borderRadius: 8, + fontSize: 12, + color: 'var(--popover-foreground)', + }} + itemStyle={{ color: 'var(--popover-foreground)' }} + /> + + +
+ + +
+ ); +} diff --git a/apps/web/src/app/popular/ArtistVotePanel.tsx b/apps/web/src/app/popular/ArtistVotePanel.tsx new file mode 100644 index 00000000..95c1972f --- /dev/null +++ b/apps/web/src/app/popular/ArtistVotePanel.tsx @@ -0,0 +1,254 @@ +'use client'; + +import { useState } from 'react'; + +import SearchAutocomplete from '@/app/search/SearchAutocomplete'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { useMyArtistVotesQuery, useSaveArtistVotesMutation } from '@/queries/artistVoteQuery'; +import { useArtistSearchQuery } from '@/queries/artistsQuery'; +import { useUserQuery } from '@/queries/userQuery'; +import useModalStore from '@/stores/useModalStore'; +import { ArtistSearchResult } from '@/types/artist'; + +import ArtistName from './ArtistName'; +import ArtistVoteRow from './ArtistVoteRow'; + +const STEP = 10; + +/** + * 이번 달(아직 확정되지 않은 달) 화면. 랭킹 대신 내가 이번 달에 투표한 아티스트를 편집한다. + * 검색으로 고른 아티스트는 0P로 목록에 담기기만 하고, 값을 고친 뒤 엔터(또는 저장 버튼)로 + * 확인 창을 거쳐야 실제 요청이 나간다. + */ +export default function ArtistVotePanel() { + const [query, setQuery] = useState(''); + const [isFocusAuto, setIsFocusAuto] = useState(false); + // 저장 전 편집값. 키는 아티스트명, 값은 화면에 보이는 투표 포인트. + const [draft, setDraft] = useState>({}); + // 검색으로 담았지만 아직 저장하지 않아 서버 목록에는 없는 아티스트. + // 저장 전에도 한국어 표기를 보여줘야 해서 이름만이 아니라 검색 결과를 통째로 들고 있는다. + const [addedArtists, setAddedArtists] = useState([]); + const [isConfirmOpen, setIsConfirmOpen] = useState(false); + + const openMessage = useModalStore(state => state.openMessage); + + const { data: user } = useUserQuery(); + const point = user?.point ?? 0; + const isAuthenticated = !!user; + + const { data: myVotes = [] } = useMyArtistVotesQuery(isAuthenticated); + const { mutate: saveVotes, isPending } = useSaveArtistVotesMutation(); + const { data: searchResults = [], isFetching: isSearching } = useArtistSearchQuery(query); + + const autoCompleteList = searchResults.map(artist => ({ + // 한국어 표기가 원어 표기와 같으면(예: 'IVE') 같은 글자를 두 번 보여줄 뿐이라 생략한다. + label: + artist.name_ko && artist.name_ko !== artist.name + ? `${artist.name} (${artist.name_ko})` + : artist.name, + value: artist.name, + })); + // 자동완성은 결과가 없으면 아무것도 그리지 않아 검색이 동작하는지조차 알기 어렵다. + // 검색어를 넣었는데 후보가 없으면 그 사실을 그대로 알려준다. + const isSearchEmpty = query.trim().length > 0 && !isSearching && autoCompleteList.length === 0; + + const savedAmounts = new Map(myVotes.map(vote => [vote.artist, vote.amount])); + const rows = [ + ...myVotes.map(vote => ({ + artist: vote.artist, + artistKo: vote.artistKo, + savedAmount: vote.amount, + })), + ...addedArtists + .filter(artist => !savedAmounts.has(artist.name)) + .map(artist => ({ artist: artist.name, artistKo: artist.name_ko, savedAmount: 0 })), + ].map(row => ({ ...row, amount: draft[row.artist] ?? row.savedAmount })); + + const changedRows = rows.filter(row => row.amount !== row.savedAmount); + // 늘린 만큼 차감되고 줄인 만큼 환불되므로, 차액 합계가 이번 저장에서 실제로 쓰는 포인트다. + const spending = changedRows.reduce((sum, row) => sum + (row.amount - row.savedAmount), 0); + const remainingPoint = point - spending; + const isOverPoint = remainingPoint < 0; + const canSave = !isPending && changedRows.length > 0 && !isOverPoint; + + // 자동완성 항목은 onMouseDown을 막아 입력창 포커스를 유지시킨다. 그래서 아티스트를 담은 뒤 + // 다시 타이핑해도 onFocus가 재발생하지 않아, 여기서 직접 열어주지 않으면 목록이 닫힌 채로 남는다. + const handleChangeQuery = (value: string) => { + setQuery(value); + setIsFocusAuto(true); + }; + + const handleSelectArtist = (name: string) => { + setQuery(''); + setIsFocusAuto(false); + + if (savedAmounts.has(name) || addedArtists.some(artist => artist.name === name)) return; + + const selected = searchResults.find(artist => artist.name === name); + setAddedArtists(prev => [...prev, selected ?? { name, name_ko: null }]); + }; + + const handleChange = (artist: string, amount: number) => { + setDraft(prev => ({ ...prev, [artist]: Math.max(0, amount) })); + }; + + const handleDelete = (artist: string) => { + // 이미 저장된 투표는 0P로 두고 저장할 때 환불받는다. 담기만 한 아티스트는 목록에서 뺀다. + if (savedAmounts.has(artist)) { + handleChange(artist, 0); + return; + } + setAddedArtists(prev => prev.filter(item => item.name !== artist)); + setDraft(prev => { + const next = { ...prev }; + delete next[artist]; + return next; + }); + }; + + const handleSubmit = () => { + if (!canSave) return; + setIsConfirmOpen(true); + }; + + const handleConfirm = () => { + setIsConfirmOpen(false); + saveVotes( + changedRows.map(({ artist, amount }) => ({ artist, amount })), + { + onSuccess: () => { + setDraft({}); + setAddedArtists([]); + }, + onError: error => { + openMessage({ title: '투표 실패', message: error.message, variant: 'error' }); + }, + }, + ); + }; + + if (!isAuthenticated) { + return ( +
+

로그인하고 투표해보세요

+

+ 보유 포인트로 이달의 아티스트를 뽑을 수 있어요. +

+
+ ); + } + + return ( +
+
+ 보유 포인트 {point}P + {changedRows.length > 0 && ( + + {' '} + → 저장 후 {remainingPoint}P + + )} +
+ +
+ handleChangeQuery(event.target.value)} + onFocus={() => setIsFocusAuto(true)} + onBlur={() => setIsFocusAuto(false)} + /> + {isFocusAuto && ( + + )} +
+ + {isSearchEmpty && ( +

+ ‘{query.trim()}’ 검색 결과가 없어요. +

+ )} + + +
+ {rows.length === 0 ? ( +

+ 검색해서 투표할 아티스트를 담아보세요. +

+ ) : ( + rows.map(row => ( + handleChange(row.artist, amount)} + onSubmit={handleSubmit} + onDelete={() => handleDelete(row.artist)} + /> + )) + )} +
+
+ +
+ {isOverPoint && ( +

+ 포인트가 {-remainingPoint}P 부족해요. 투표값을 줄여주세요. +

+ )} + +
+ + + + + 투표를 반영할까요? + + 저장하면 차액만큼 포인트가 차감되거나 환불돼요. 이번 달에는 언제든 다시 조정할 수 + 있어요. + + + +
+ {changedRows.map(row => ( +
+ + + {row.savedAmount}P → {row.amount}P + +
+ ))} +
+ +
+ {spending >= 0 ? '차감 포인트' : '환불 포인트'} + {Math.abs(spending)}P +
+ + + + + +
+
+
+ ); +} diff --git a/apps/web/src/app/popular/ArtistVoteRow.tsx b/apps/web/src/app/popular/ArtistVoteRow.tsx new file mode 100644 index 00000000..a37850a3 --- /dev/null +++ b/apps/web/src/app/popular/ArtistVoteRow.tsx @@ -0,0 +1,104 @@ +'use client'; + +import { Minus, Plus, Trash2 } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { cn } from '@/utils/cn'; + +import ArtistName from './ArtistName'; + +interface ArtistVoteRowProps { + artist: string; + artistKo: string | null; + /** 화면에 보이는(=편집 중인) 투표 포인트 */ + amount: number; + /** 서버에 저장돼 있는 투표 포인트. 다르면 변경 표시를 띄운다. */ + savedAmount: number; + step: number; + disabled: boolean; + onChange: (amount: number) => void; + /** 입력창에서 엔터를 눌렀을 때 (저장 확인) */ + onSubmit: () => void; + onDelete: () => void; +} + +export default function ArtistVoteRow({ + artist, + artistKo, + amount, + savedAmount, + step, + disabled, + onChange, + onSubmit, + onDelete, +}: ArtistVoteRowProps) { + const isChanged = amount !== savedAmount; + + return ( +
+
+ + {isChanged && ( + + {savedAmount}P → {amount}P (저장 전) + + )} +
+ +
+ + + {/* 숫자를 직접 고칠 수 있어야 해서 입력창으로 둔다. 엔터가 곧 저장 확인이다. */} + onChange(Number(event.target.value.replace(/\D/g, '') || 0))} + onKeyDown={event => { + if (event.key === 'Enter') onSubmit(); + }} + /> + + + + +
+
+ ); +} diff --git a/apps/web/src/app/popular/ArtistVotersDialog.tsx b/apps/web/src/app/popular/ArtistVotersDialog.tsx new file mode 100644 index 00000000..2aeb6694 --- /dev/null +++ b/apps/web/src/app/popular/ArtistVotersDialog.tsx @@ -0,0 +1,55 @@ +'use client'; + +import StaticLoading from '@/components/StaticLoading'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { useArtistVotersQuery } from '@/queries/artistVoteQuery'; + +interface ArtistVotersDialogProps { + month: string; + artist: string; + artistKo: string | null; + onClose: () => void; +} + +export default function ArtistVotersDialog({ + month, + artist, + artistKo, + onClose, +}: ArtistVotersDialogProps) { + const { data: voters, isPending } = useArtistVotersQuery(month, artist, true); + + return ( + !open && onClose()}> + + + + {artist} 투표자 + {!!artistKo && artistKo !== artist && ( + {artistKo} + )} + + + + {isPending ? ( + + ) : ( + +
+ {(voters ?? []).map((voter, index) => ( +
+ {voter.nickname} + {voter.amount}P +
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/apps/web/src/app/popular/ChartRankingList.tsx b/apps/web/src/app/popular/ChartRankingList.tsx index 6ae350e8..aacb9788 100644 --- a/apps/web/src/app/popular/ChartRankingList.tsx +++ b/apps/web/src/app/popular/ChartRankingList.tsx @@ -1,37 +1,22 @@ 'use client'; import { addMonths, format, parseISO, startOfMonth } from 'date-fns'; -import { ChevronLeft, ChevronRight, Construction } from 'lucide-react'; +import { 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 { ScrollArea } from '@/components/ui/scroll-area'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; import { useTjChartQuery } from '@/queries/tjChartQuery'; import { StrType } from '@/types/tjChart'; import { cn } from '@/utils/cn'; import { getPrevMonthFirstDayKST } from '@/utils/kst'; import ChartGenreFilter from './ChartGenreFilter'; +import MonthSelector from './MonthSelector'; const MONTH_FORMAT = 'yyyy-MM-dd'; -// 조회 월은 항상 'yyyy-MM-01' 문자열이라 파싱 없이 잘라 쓴다. -const getYear = (month: string) => month.slice(0, 4); -const getMonthNumber = (month: string) => month.slice(5, 7); - -// 년/월을 그냥 텍스트처럼 보이게 두고, 눌러야 드롭다운이 열린다는 건 셀렉트 기본 화살표로 알린다. -const SELECT_TRIGGER_CLASSES = - 'h-auto w-auto gap-1 border-none p-0 text-xl font-bold shadow-none focus-visible:ring-0'; - const getRankStyle = (rank: number) => { switch (rank) { case 1: @@ -73,73 +58,18 @@ export default function ChartRankingList() { ? availableMonths : [...availableMonths, month].sort().reverse(); - const years = [...new Set(selectableMonths.map(getYear))]; - // 연도 안에서는 1월 → 12월 순으로 훑는 게 자연스러워 오름차순으로 둔다. - const monthsInYear = selectableMonths.filter(item => getYear(item) === getYear(month)).sort(); - - // 연도를 바꿀 때 같은 달이 있으면 유지하고, 없으면 그 해에서 가장 최근 달로 이동한다. - const handleYearChange = (nextYear: string) => { - const candidates = selectableMonths.filter(item => getYear(item) === nextYear); - if (candidates.length === 0) return; - - const sameMonth = candidates.find(item => getMonthNumber(item) === getMonthNumber(month)); - setMonth(sameMonth ?? candidates[0]); - }; - return (
{/* 차트 제목은 페이지 h1로 빠졌고, 여기서는 조회 월 선택만 한 줄을 통째로 쓴다. */} -
- - -
- - - -
- - -
+ setMonth(shiftMonth(month, -1))} + onNext={() => setMonth(shiftMonth(month, 1))} + onChange={setMonth} + /> diff --git a/apps/web/src/app/popular/MonthSelector.tsx b/apps/web/src/app/popular/MonthSelector.tsx new file mode 100644 index 00000000..77e9208c --- /dev/null +++ b/apps/web/src/app/popular/MonthSelector.tsx @@ -0,0 +1,108 @@ +'use client'; + +import { ChevronLeft, ChevronRight } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +// 년/월을 그냥 텍스트처럼 보이게 두고, 눌러야 드롭다운이 열린다는 건 셀렉트 기본 화살표로 알린다. +const SELECT_TRIGGER_CLASSES = + 'h-auto w-auto gap-1 border-none p-0 text-xl font-bold shadow-none focus-visible:ring-0'; + +// 조회 월은 항상 'yyyy-MM-dd' 문자열이라 파싱 없이 잘라 쓴다. +const getYear = (month: string) => month.slice(0, 4); +const getMonthNumber = (month: string) => month.slice(5, 7); + +interface MonthSelectorProps { + month: string; + /** 이미 정렬/보정까지 끝낸 선택 가능 월 목록 (최신순). 화면마다 보정 규칙이 달라 호출부에서 계산해 넘긴다. */ + selectableMonths: string[]; + canGoPrev: boolean; + canGoNext: boolean; + onPrev: () => void; + onNext: () => void; + onChange: (month: string) => void; +} + +export default function MonthSelector({ + month, + selectableMonths, + canGoPrev, + canGoNext, + onPrev, + onNext, + onChange, +}: MonthSelectorProps) { + const years = [...new Set(selectableMonths.map(getYear))]; + // 연도 안에서는 1월 → 12월 순으로 훑는 게 자연스러워 오름차순으로 둔다. + const monthsInYear = selectableMonths.filter(item => getYear(item) === getYear(month)).sort(); + + // 연도를 바꿀 때 같은 달이 있으면 유지하고, 없으면 그 해에서 가장 최근 달로 이동한다. + const handleYearChange = (nextYear: string) => { + const candidates = selectableMonths.filter(item => getYear(item) === nextYear); + if (candidates.length === 0) return; + + const sameMonth = candidates.find(item => getMonthNumber(item) === getMonthNumber(month)); + onChange(sameMonth ?? candidates[0]); + }; + + return ( +
+ + +
+ + + +
+ + +
+ ); +} diff --git a/apps/web/src/app/popular/PopularTabs.tsx b/apps/web/src/app/popular/PopularTabs.tsx new file mode 100644 index 00000000..30844ead --- /dev/null +++ b/apps/web/src/app/popular/PopularTabs.tsx @@ -0,0 +1,25 @@ +'use client'; + +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; + +import ArtistOfMonthTab from './ArtistOfMonthTab'; +import ChartRankingList from './ChartRankingList'; + +export default function PopularTabs() { + return ( + + + 노래방 인기곡 + 이달의 아티스트 + + + + + + + + + + + ); +} diff --git a/apps/web/src/app/popular/WinDetailDialog.tsx b/apps/web/src/app/popular/WinDetailDialog.tsx new file mode 100644 index 00000000..aa10c69f --- /dev/null +++ b/apps/web/src/app/popular/WinDetailDialog.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { ArtistWin } from '@/types/artistVote'; + +/** 'YYYY-MM-DD' → 사람이 읽는 표기 */ +export const toFullLabel = (month: string) => + `${month.slice(0, 4)}년 ${Number(month.slice(5, 7))}월`; + +interface WinDetailDialogProps { + artist: string; + /** 아티스트의 전체 우승 이력(오래된 순). 몇 회차 우승인지 세는 데 쓴다. */ + wins: ArtistWin[]; + /** 열려 있는 우승 기록. null이면 닫힌 상태다. */ + selected: ArtistWin | null; + onClose: () => void; +} + +/** + * 우승 한 번의 상세. 카드 위 별(WinStars)을 누르면 열린다. + * + * 우승 표식과 분리해 둔 이유는, 표식의 생김새(별·트로피·뱃지 무엇이든)와 "우승 하나를 + * 눌렀을 때 보여줄 내용"이 서로 다른 관심사이기 때문이다. 표식을 바꿔도 이 파일은 그대로다. + */ +export default function WinDetailDialog({ artist, wins, selected, onClose }: WinDetailDialogProps) { + return ( + !open && onClose()}> + + {selected && ( + <> + + {toFullLabel(selected.month)} 이달의 아티스트 + + +
+
+ 아티스트 + {artist} +
+
+ 그달 득표 + {selected.totalVotes}P +
+
+ 통산 + + {wins.findIndex(win => win.month === selected.month) + 1}회차 우승 + +
+
+ + )} +
+
+ ); +} diff --git a/apps/web/src/app/popular/WinStars.tsx b/apps/web/src/app/popular/WinStars.tsx new file mode 100644 index 00000000..c7c3ea41 --- /dev/null +++ b/apps/web/src/app/popular/WinStars.tsx @@ -0,0 +1,108 @@ +'use client'; + +import { useState } from 'react'; + +import { ArtistWin } from '@/types/artistVote'; + +import WinDetailDialog, { toFullLabel } from './WinDetailDialog'; + +/** + * 낱개로 새길 별의 최대 개수. 넘어가는 만큼은 앞(오래된 쪽)에서 +N으로 접는다. + * 축구 유니폼이 우승 열 번을 별 하나로 묶는 것과 같은 이유다 — 별이 한 줄을 넘어가기 시작하면 + * 개수를 세는 표식이 아니라 그냥 띠 하나로 읽힌다. + */ +const STAR_LIMIT = 12; + +/** 꼭짓점 다섯 개짜리 별. viewBox 24×24 기준 */ +const STAR_PATH = + 'M12 2.6l2.9 5.9 6.5.95-4.7 4.58 1.11 6.47L12 17.44l-5.81 3.06 1.11-6.47L2.6 9.45l6.5-.95z'; + +interface StarProps { + win: ArtistWin; + onClick: () => void; +} + +/** + * 별 하나 = 우승 한 번. + * + * 이모지(⭐) 대신 SVG인 이유는 트로피와 같다 — 폰트마다 모양과 색이 제각각이라 + * 카드에 새긴 표식이 아니라 본문에 끼어든 글자로 보인다. + * 금색 면에 어두운 금 테를 두르는 건 카드 배경(1~3위는 메달색 틴트)이 무엇이든 + * 별의 윤곽이 살아남게 하기 위함이다. + */ +function Star({ win, onClick }: StarProps) { + // 한 달에 우승은 한 팀뿐이라 월을 그대로 id로 써도 카드끼리 겹치지 않는다. + const gradientId = `star-${win.month}`; + + return ( + + ); +} + +interface WinStarsProps { + artist: string; + wins: ArtistWin[]; +} + +/** + * 역대 우승을 카드 위쪽에 별로 새긴다. 프로 스포츠가 엠블럼 위에 우승 별을 다는 방식이다. + * + * 별 한 줄은 "몇 번"만 남기고 "언제"는 감춘다 — 그건 별을 눌러 다이얼로그로 본다. + * 카드 한 장에 세로 공간을 거의 쓰지 않으면서 통산 우승을 한눈에 보여주는 게 목적이라, + * 우승마다 년·월을 지면에 적는 표현으로 되돌리면 이 장점이 사라진다. + */ +export default function WinStars({ artist, wins }: WinStarsProps) { + const [selected, setSelected] = useState(null); + + if (wins.length === 0) return null; + + // 오래된 순으로 들어오므로 뒤에서 자른다 — 접히는 건 언제나 옛 기록이고, 최근 우승은 남는다. + const shown = wins.slice(-STAR_LIMIT); + const folded = wins.length - shown.length; + + return ( +
+ {folded > 0 && ( + + +{folded} + + )} + + {shown.map(win => ( + setSelected(win)} /> + ))} + + setSelected(null)} + /> +
+ ); +} diff --git a/apps/web/src/app/popular/page.tsx b/apps/web/src/app/popular/page.tsx index 4d1e2b9f..a1a394e9 100644 --- a/apps/web/src/app/popular/page.tsx +++ b/apps/web/src/app/popular/page.tsx @@ -1,4 +1,4 @@ -import ChartRankingList from './ChartRankingList'; +import PopularTabs from './PopularTabs'; // 차트 기본 조회 월을 "현재 기준 전월"로 계산하므로 빌드 시점에 프리렌더되면 값이 낡는다. // 요청 시점에 렌더해 서버/클라이언트가 같은 월을 계산하도록 한다. @@ -6,11 +6,11 @@ export const dynamic = 'force-dynamic'; export default function PopularPage() { return ( -
- {/* TJ 공식 차트 기반 인기 순위 */} -

인기차트

+
+ {/* TJ 공식 차트 기반 인기 순위 / 포인트 투표로 뽑는 이달의 아티스트 */} +

인기 차트

- +
); } diff --git a/apps/web/src/app/recent/RecentSongCard.tsx b/apps/web/src/app/recent/RecentSongCard.tsx index b31a1175..e5a9658a 100644 --- a/apps/web/src/app/recent/RecentSongCard.tsx +++ b/apps/web/src/app/recent/RecentSongCard.tsx @@ -1,12 +1,12 @@ import SongSummary from '@/components/SongSummary'; -import { Separator } from '@/components/ui/separator'; import { Song } from '@/types/song'; export default function RecentSongCard({ song }: { song: Song }) { + // 간격과 구분선은 인기 차트(ChartRankingList)와 같은 규칙을 쓴다. + // 행 아래쪽 테두리로 선을 긋고 마지막 행에서만 지워, 리스트 끝에 선이 떠 있지 않게 한다. return ( -
- - +
+
); } diff --git a/apps/web/src/app/search/HomePage.tsx b/apps/web/src/app/search/HomePage.tsx index 2aa8937e..7ed25ea6 100644 --- a/apps/web/src/app/search/HomePage.tsx +++ b/apps/web/src/app/search/HomePage.tsx @@ -21,7 +21,6 @@ import { cn } from '@/utils/cn'; import AddFolderModal from './AddFolderModal'; // import ChatBot from './ChatBot'; import JpnArtistList from './JpnArtistList'; -import LanguageTagFilter from './LanguageTagFilter'; import NumberKeypad from './NumberKeypad'; import PopularSearchHistory from './PopularSearchHistory'; import SearchAutocomplete from './SearchAutocomplete'; @@ -38,8 +37,6 @@ export default function SearchPage() { autoCompleteList, query, queryType, - languageTag, - queryLanguageTag, searchResults, isPendingSearch, @@ -49,7 +46,6 @@ export default function SearchPage() { isError, handleSearchTypeChange, - handleLanguageTagChange, handleSearch, handleToggleToSing, handleToggleLike, @@ -64,7 +60,7 @@ export default function SearchPage() { handleToggleSave, postSaveSong, patchSaveSong, - } = useSaveSongModal(query, queryType, queryLanguageTag); + } = useSaveSongModal(query, queryType); const [isJpnArtistModalOpen, setIsJpnArtistModalOpen] = useState(false); const [isFocusAuto, setIsFocusAuto] = useState(false); @@ -188,11 +184,6 @@ export default function SearchPage() { setSearch(''); setIsNumberKeypadOpen(false); } - // 번호 검색에서는 언어 필터를 숨기는데, 값이 남아 있으면 화면에 보이지 않는 채로 - // 결과가 걸러져 "번호가 맞는데 안 나온다"가 된다. 상태까지 비운다. - if (value === 'number') { - handleLanguageTagChange(undefined); - } handleSearchTypeChange(value as SearchType); }; @@ -359,14 +350,6 @@ export default function SearchPage() {
)}
- - {/* 번호 검색에는 언어 필터가 의미가 없다. - 값이 남아 있으면 번호가 맞는데도 결과가 걸러져 원인을 알기 어렵다. */} - {searchType !== 'number' && ( -
- -
- )}
{isTourDemo && ( diff --git a/apps/web/src/app/search/LanguageTagFilter.tsx b/apps/web/src/app/search/LanguageTagFilter.tsx deleted file mode 100644 index 6ddd6f9e..00000000 --- a/apps/web/src/app/search/LanguageTagFilter.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { JP, KR, US } from 'country-flag-icons/react/3x2'; - -import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { LANGUAGE_TAGS } from '@/constants/languageTags'; -import { cn } from '@/utils/cn'; - -const ALL_VALUE = 'all'; - -// Tailwind는 클래스명을 정적으로 스캔하므로, 색상 클래스는 태그마다 완전한 문자열로 나열해야 한다. -// 태그별로 chart-1/2/4/5 네온 컬러를 고정 배정해 "노래방 네온 칩" 느낌을 낸다. -// 선택 시에는 해당 색으로 배경을 가득 채우고 글자색을 흰색으로 바꿔 명확히 구분한다. -// TabsTrigger 기본 스타일에 dark:data-[state=active]:bg-input/30, dark:data-[state=active]:text-foreground가 -// 이미 있어서, dark: 접두사가 없는 클래스는 variant 체인이 달라 tailwind-merge가 덮어쓰지 못한다. -// 다크모드용도 명시적으로 함께 지정해야 실제로 배경/글자색이 바뀐다. -const TAG_ACCENT_CLASSES: Record = { - 100: 'border-chart-1/40 text-chart-1/80 data-[state=active]:border-chart-1 data-[state=active]:bg-chart-1 data-[state=active]:text-white dark:data-[state=active]:border-chart-1 dark:data-[state=active]:bg-chart-1 dark:data-[state=active]:text-white data-[state=active]:shadow-[0_0_10px_var(--chart-1)]', - 101: 'border-chart-2/40 text-chart-2/80 data-[state=active]:border-chart-2 data-[state=active]:bg-chart-2 data-[state=active]:text-white dark:data-[state=active]:border-chart-2 dark:data-[state=active]:bg-chart-2 dark:data-[state=active]:text-white data-[state=active]:shadow-[0_0_10px_var(--chart-2)]', - 102: 'border-chart-4/40 text-chart-4/80 data-[state=active]:border-chart-4 data-[state=active]:bg-chart-4 data-[state=active]:text-white dark:data-[state=active]:border-chart-4 dark:data-[state=active]:bg-chart-4 dark:data-[state=active]:text-white data-[state=active]:shadow-[0_0_10px_var(--chart-4)]', - 103: 'border-chart-5/40 text-chart-5/80 data-[state=active]:border-chart-5 data-[state=active]:bg-chart-5 data-[state=active]:text-white dark:data-[state=active]:border-chart-5 dark:data-[state=active]:bg-chart-5 dark:data-[state=active]:text-white data-[state=active]:shadow-[0_0_10px_var(--chart-5)]', -}; - -// "전체"는 특정 언어를 대표하지 않으므로 네온 글로우 없이 중립 톤(전경색 가득 채움)만 유지한다. -const ALL_ACCENT_CLASSES = - 'border-border text-muted-foreground 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 data-[state=active]:shadow-none'; - -const CHIP_BASE_CLASSES = - 'flex-none rounded-full border bg-transparent px-2.5 py-1.5 font-medium shadow-none transition-all'; - -// 좁은 화면에서 칩이 줄바꿈되면 검색창 아래 높이가 들쭉날쭉해진다. -// 한 줄로 고정하고 넘치면 가로 스크롤한다. 스크롤바는 더 볼 게 남았다는 신호라 노출하되, -// 기본 스크롤바는 두꺼우므로 thin으로 얇게 하고 pb로 칩과 띄운다. -// (인기차트 장르 필터와 같은 방식) -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'; - -const ALL_EMOJI = '🎵'; - -const FLAG_CLASSES = 'h-3 w-auto rounded-[2px]'; - -const TAG_ICONS: Record = { - 100: , - 101: , - 102: , - 103: '🌐', -}; - -interface LanguageTagFilterProps { - value: number | undefined; - onChange: (value: number | undefined) => void; -} - -export default function LanguageTagFilter({ value, onChange }: LanguageTagFilterProps) { - const handleValueChange = (next: string) => { - onChange(next === ALL_VALUE ? undefined : Number(next)); - }; - - return ( - - - - {ALL_EMOJI} 전체 - - {LANGUAGE_TAGS.map(tag => ( - - {TAG_ICONS[tag.id]} {tag.name} - - ))} - - - ); -} diff --git a/apps/web/src/app/search/SearchTour.tsx b/apps/web/src/app/search/SearchTour.tsx index 6cee0724..0cca9232 100644 --- a/apps/web/src/app/search/SearchTour.tsx +++ b/apps/web/src/app/search/SearchTour.tsx @@ -73,11 +73,6 @@ export default function SearchTour({ content: '전체·제목·가수·번호 중 원하는 방식으로 검색할 수 있어요.', skipBeacon: true, }, - { - target: '[data-tour="language-filter"]', - title: '국가로 필터링', - content: '한국·일본·팝송처럼 국가(언어)로 검색 범위를 좁혀볼 수 있어요.', - }, { target: '[data-tour="search-result-list"]', title: '검색 결과 살펴보기', diff --git a/apps/web/src/components/SongSummary.tsx b/apps/web/src/components/SongSummary.tsx index d98dc004..beeaff30 100644 --- a/apps/web/src/components/SongSummary.tsx +++ b/apps/web/src/components/SongSummary.tsx @@ -1,5 +1,10 @@ +'use client'; + +import { getPrimaryArtistName } from '@repo/constants'; + import MarqueeText from '@/components/MarqueeText'; import SongBadges from '@/components/SongBadges'; +import { useCurrentArtistOfMonthQuery } from '@/queries/artistVoteQuery'; import { Song } from '@/types/song'; import { cn } from '@/utils/cn'; @@ -26,6 +31,12 @@ export default function SongSummary({ song, className }: SongSummaryProps) { const hasKoTitle = !!title_ko && title_ko !== title; const hasKoArtist = !!artist_ko && artist_ko !== artist; + // songs.artist는 "IU(Feat.최백호)"처럼 원문 그대로라, artists 마스터에 등록된 정규화된 + // 이름과 곧이곧대로 비교하면 우승 아티스트의 피처링·듀엣 곡에 배지가 빠진다. + // 맨 앞에 적힌 주 아티스트만 뽑아 비교한다. + const { data: artistOfMonth } = useCurrentArtistOfMonthQuery(); + const isArtistOfMonth = !!artistOfMonth && artistOfMonth.artist === getPrimaryArtistName(artist); + return (
{/* min-w-0가 없으면 MarqueeText가 부모를 밀어내 번호 영역이 잘린다 */} @@ -41,6 +52,13 @@ export default function SongSummary({ song, className }: SongSummaryProps) { {hasKoArtist && ( {artist_ko} )} + {isArtistOfMonth && ( + + {/* month는 'YYYY-MM-DD'(매월 1일)라 앞 7자리만 잘라 쓴다. + "이달"이 언제인지는 곡 카드만 보면 알 수 없어 선정된 달을 함께 적는다. */} + 🏆 {artistOfMonth.month.slice(0, 7)} 이달의 아티스트 + + )}
{/* 번호는 자릿수가 달라도 세로로 맞아떨어지도록 고정폭 + tabular-nums */} diff --git a/apps/web/src/constants/languageTags.ts b/apps/web/src/constants/languageTags.ts deleted file mode 100644 index 7c2b2623..00000000 --- a/apps/web/src/constants/languageTags.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Tag } from '@/types/tag'; - -export const LANGUAGE_TAGS: Tag[] = [ - { id: 100, name: '한국' }, - { id: 101, name: '일본' }, - { id: 102, name: '팝송' }, - { id: 103, name: '글로벌' }, -]; diff --git a/apps/web/src/globals.css b/apps/web/src/globals.css index 3ff8e797..2094500e 100644 --- a/apps/web/src/globals.css +++ b/apps/web/src/globals.css @@ -48,6 +48,9 @@ --color-chart-3: var(--chart-3); --color-chart-4: var(--chart-4); --color-chart-5: var(--chart-5); + --color-chart-6: var(--chart-6); + --color-chart-7: var(--chart-7); + --color-chart-8: var(--chart-8); --color-sidebar: var(--sidebar); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-primary: var(--sidebar-primary); @@ -103,11 +106,17 @@ --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); --ring: oklch(0.75 0.2 145); - --chart-1: oklch(0.75 0.2 145); - --chart-2: oklch(0.65 0.2 350); - --chart-3: oklch(0.696 0.17 162.48); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.627 0.265 303.9); + /* 차트 카테고리 팔레트 8칸 — 색각 이상 분리도/명도 대역 검증을 통과한 조합이다. + 순서를 바꾸거나 중간 색을 갈아끼우면 인접 조각이 구분되지 않으니 순서대로만 쓴다. + 여덟 칸이 한계다. 아홉 번째 색을 새로 만들지 말고 질감(빗금)을 얹어 구분한다. */ + --chart-1: #2a78d6; + --chart-2: #eb6834; + --chart-3: #1baf7a; + --chart-4: #eda100; + --chart-5: #e87ba4; + --chart-6: #008300; + --chart-7: #4a3aa7; + --chart-8: #e34948; --sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.145 0 0); --sidebar-primary: oklch(0.75 0.2 145); @@ -143,11 +152,15 @@ --border: oklch(0.3 0.01 260); --input: oklch(0.25 0.01 260); --ring: oklch(0.75 0.2 145); - --chart-1: oklch(0.75 0.2 145); - --chart-2: oklch(0.65 0.2 350); - --chart-3: oklch(0.696 0.17 162.48); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.627 0.265 303.9); + /* 다크 표면에 맞춰 다시 고른 같은 계열 8색 (밝기만 뒤집은 값이 아니다) */ + --chart-1: #3987e5; + --chart-2: #d95926; + --chart-3: #199e70; + --chart-4: #c98500; + --chart-5: #d55181; + --chart-6: #008300; + --chart-7: #9085e9; + --chart-8: #e66767; --sidebar: oklch(0.18 0.01 260); --sidebar-foreground: oklch(0.9 0 0); --sidebar-primary: oklch(0.75 0.2 145); @@ -213,9 +226,35 @@ button { animation: character-shake 0.3s ease-in-out 2; } +/* 액자 몰딩 광택 (React Bits의 ShinyText 기법을 테두리에 옮긴 것). + 배경을 두 겹으로 깔고 첫 겹(하이라이트 띠)의 위치만 흘린다. 금속 결 자체가 흐르면 + 테가 미끄러지는 것처럼 보이지만, 결은 고정하고 빛만 지나가면 광택으로 읽힌다. + background-position만 움직이므로 레이아웃 계산이 없다. */ +@keyframes frame-sheen { + from { + background-position: + 150% center, + center; + } + to { + background-position: + -50% center, + center; + } +} + +.animate-frame-sheen { + background-size: + 200% auto, + auto; + background-repeat: no-repeat; + animation: frame-sheen 4.5s linear infinite; +} + @media (prefers-reduced-motion: reduce) { .animate-character-bounce, - .animate-character-shake { + .animate-character-shake, + .animate-frame-sheen { animation: none; } } diff --git a/apps/web/src/hooks/useSaveSongModal.ts b/apps/web/src/hooks/useSaveSongModal.ts index 60f7ddc2..07c268de 100644 --- a/apps/web/src/hooks/useSaveSongModal.ts +++ b/apps/web/src/hooks/useSaveSongModal.ts @@ -10,11 +10,7 @@ import { SearchSong, SearchType } from '@/types/song'; type SaveModalType = '' | 'POST' | 'PATCH'; -export default function useSaveSongModal( - query: string, - queryType: SearchType, - languageTag?: number, -) { +export default function useSaveSongModal(query: string, queryType: SearchType) { const { isAuthenticated } = useAuthStore(); const { setFooterAnimateKey } = useFooterAnimateStore(); @@ -44,7 +40,7 @@ export default function useSaveSongModal( } setFooterAnimateKey('INFO'); - postSong({ songId, folderName, query, searchType: queryType, languageTag }); + postSong({ songId, folderName, query, searchType: queryType }); }; const patchSaveSong = async (songId: string, folderId: string) => { diff --git a/apps/web/src/hooks/useSearchSong.ts b/apps/web/src/hooks/useSearchSong.ts index ea48b884..898210b0 100644 --- a/apps/web/src/hooks/useSearchSong.ts +++ b/apps/web/src/hooks/useSearchSong.ts @@ -22,19 +22,14 @@ export default function useSearchSong() { const [searchType, setSearchType] = useState('all'); const [query, setQuery] = useState(''); const [queryType, setQueryType] = useState('all'); - // languageTag: Tabs에서 선택 중인(draft) 값. queryLanguageTag: 검색 실행 시 커밋되어 실제 쿼리에 쓰이는 값. - const [languageTag, setLanguageTag] = useState(undefined); - const [queryLanguageTag, setQueryLanguageTag] = useState(undefined); const { mutate: toggleToSing, isPending: isToggleToSingPending } = useToggleToSingMutation( query, queryType, - queryLanguageTag, ); const { mutate: toggleLike, isPending: isToggleLikePending } = useToggleLikeMutation( query, queryType, - queryLanguageTag, ); const { @@ -44,7 +39,7 @@ export default function useSearchSong() { isFetchingNextPage, isLoading: isPendingSearch, isError, - } = useInfiniteSearchSongQuery(query, queryType, isAuthenticated, queryLanguageTag); + } = useInfiniteSearchSongQuery(query, queryType, isAuthenticated); const { mutate: postSearchLog } = usePostSearchLogMutation(); @@ -83,7 +78,6 @@ export default function useSearchSong() { setQuery(parsedSearch); setSearch(parsedSearch); setQueryType(typeOverride ?? searchType); - setQueryLanguageTag(languageTag); addToHistory(parsedSearch); postSearchLog(parsedSearch); } @@ -93,10 +87,6 @@ export default function useSearchSong() { setSearchType(value); }; - const handleLanguageTagChange = (value: number | undefined) => { - setLanguageTag(value); - }; - const handleToggleToSing = useCallback( async (song: Song, method: Method) => { if (!isAuthenticated) { @@ -156,8 +146,6 @@ export default function useSearchSong() { autoCompleteList, query, queryType, - languageTag, - queryLanguageTag, searchResults, fetchNextPage, @@ -167,7 +155,6 @@ export default function useSearchSong() { isError, handleSearchTypeChange, - handleLanguageTagChange, handleSearch, handleToggleToSing, handleToggleLike, diff --git a/apps/web/src/lib/api/artistVote.ts b/apps/web/src/lib/api/artistVote.ts new file mode 100644 index 00000000..e7cf365f --- /dev/null +++ b/apps/web/src/lib/api/artistVote.ts @@ -0,0 +1,40 @@ +import { ApiResponse } from '@/types/apiRoute'; +import { + ArtistRankingResponse, + ArtistVoter, + CurrentArtistOfMonth, + MyArtistVote, +} from '@/types/artistVote'; + +import { instance } from './client'; + +export async function putArtistVote(body: { artist: string; amount: number }) { + const response = await instance.put>('/artist-vote', body); + return response.data; +} + +export async function getMyArtistVotes() { + const response = await instance.get>('/artist-vote/my-current'); + return response.data; +} + +export async function getArtistRankings(month?: string) { + const response = await instance.get>('/artist-vote/rankings', { + params: month ? { month } : undefined, + }); + return response.data; +} + +export async function getArtistVoters(month: string, artist: string) { + const response = await instance.get>( + `/artist-vote/rankings/${month}/${encodeURIComponent(artist)}/voters`, + ); + return response.data; +} + +export async function getCurrentArtistOfMonth() { + const response = await instance.get>( + '/artist-vote/current-winner', + ); + return response.data; +} diff --git a/apps/web/src/lib/api/artists.ts b/apps/web/src/lib/api/artists.ts new file mode 100644 index 00000000..bdee158c --- /dev/null +++ b/apps/web/src/lib/api/artists.ts @@ -0,0 +1,11 @@ +import { ApiResponse } from '@/types/apiRoute'; +import { ArtistSearchResult } from '@/types/artist'; + +import { instance } from './client'; + +export async function getArtistSearch(query: string) { + const response = await instance.get>('/artists/search', { + params: { q: query }, + }); + return response.data; +} diff --git a/apps/web/src/lib/api/searchSong.ts b/apps/web/src/lib/api/searchSong.ts index 8052e51d..40456a7d 100644 --- a/apps/web/src/lib/api/searchSong.ts +++ b/apps/web/src/lib/api/searchSong.ts @@ -8,10 +8,9 @@ export async function getInfiniteSearchSong( searchType: string, isAuthenticated: boolean, page?: number, - languageTag?: number, ) { const response = await instance.get>('/search', { - params: { q: search, type: searchType, authenticated: isAuthenticated, page, languageTag }, + params: { q: search, type: searchType, authenticated: isAuthenticated, page }, }); return response.data; diff --git a/apps/web/src/queries/artistVoteQuery.ts b/apps/web/src/queries/artistVoteQuery.ts new file mode 100644 index 00000000..731a7295 --- /dev/null +++ b/apps/web/src/queries/artistVoteQuery.ts @@ -0,0 +1,95 @@ +import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import axios from 'axios'; + +import { + getArtistRankings, + getArtistVoters, + getCurrentArtistOfMonth, + getMyArtistVotes, + putArtistVote, +} from '@/lib/api/artistVote'; +import { ApiErrorResponse } from '@/types/apiRoute'; +import { ArtistVoteInput } from '@/types/artistVote'; + +// 투표 실패 사유(포인트 부족, 없는 아티스트 등)는 라우트가 error 필드로 내려주므로 그대로 꺼내 쓴다. +const getVoteErrorMessage = (error: unknown) => { + if (axios.isAxiosError(error)) { + return (error.response?.data as ApiErrorResponse | undefined)?.error ?? error.message; + } + return error instanceof Error ? error.message : '투표 반영에 실패했어요'; +}; + +export const useMyArtistVotesQuery = (enabled: boolean) => { + return useQuery({ + queryKey: ['myArtistVotes'], + queryFn: async () => { + const response = await getMyArtistVotes(); + if (!response.success) return []; + return response.data ?? []; + }, + enabled, + }); +}; + +/** + * 편집한 투표 목록을 한 번에 저장한다. + * PUT 라우트가 아티스트 단위라 변경분을 순차로 보내고, 하나라도 실패하면 거기서 멈춘다. + * amount는 절대값이라 재시도해도 중복 차감되지 않는다. + */ +export const useSaveArtistVotesMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (votes: ArtistVoteInput[]) => { + for (const vote of votes) { + try { + const response = await putArtistVote(vote); + if (!response.success) throw new Error(response.error ?? '투표 반영에 실패했어요'); + } catch (error) { + throw new Error(getVoteErrorMessage(error)); + } + } + }, + // 중간에 실패해도 이미 반영된 건 남으므로, 성공/실패 관계없이 서버 값을 다시 읽어 화면을 맞춘다. + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ['myArtistVotes'] }); + queryClient.invalidateQueries({ queryKey: ['userCheckIn'] }); + queryClient.invalidateQueries({ queryKey: ['pointLogs'] }); + }, + }); +}; + +export const useArtistRankingsQuery = (month?: string) => { + return useQuery({ + queryKey: ['artistRankings', month], + queryFn: async () => { + const response = await getArtistRankings(month); + if (!response.success) return null; + return response.data; + }, + placeholderData: keepPreviousData, + }); +}; + +export const useArtistVotersQuery = (month: string, artist: string, enabled: boolean) => { + return useQuery({ + queryKey: ['artistVoters', month, artist], + queryFn: async () => { + const response = await getArtistVoters(month, artist); + if (!response.success) return []; + return response.data ?? []; + }, + enabled, + }); +}; + +export const useCurrentArtistOfMonthQuery = () => { + return useQuery({ + queryKey: ['currentArtistOfMonth'], + queryFn: async () => { + const response = await getCurrentArtistOfMonth(); + if (!response.success) return null; + return response.data ?? null; + }, + staleTime: 1000 * 60 * 30, + }); +}; diff --git a/apps/web/src/queries/artistsQuery.ts b/apps/web/src/queries/artistsQuery.ts new file mode 100644 index 00000000..8029ba3f --- /dev/null +++ b/apps/web/src/queries/artistsQuery.ts @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query'; + +import { getArtistSearch } from '@/lib/api/artists'; + +export const useArtistSearchQuery = (query: string) => { + const trimmed = query.trim(); + + return useQuery({ + queryKey: ['artistSearch', trimmed], + queryFn: async () => { + const response = await getArtistSearch(trimmed); + if (!response.success) return []; + return response.data ?? []; + }, + enabled: trimmed.length > 0, + staleTime: 1000 * 60, + }); +}; diff --git a/apps/web/src/queries/searchSongQuery.ts b/apps/web/src/queries/searchSongQuery.ts index 3fba511c..8e8d186c 100644 --- a/apps/web/src/queries/searchSongQuery.ts +++ b/apps/web/src/queries/searchSongQuery.ts @@ -30,25 +30,17 @@ interface FolderProps { folderName: string; query: string; searchType: string; - languageTag?: number; } export const useInfiniteSearchSongQuery = ( search: string, searchType: string, isAuthenticated: boolean, - languageTag?: number, ) => { return useInfiniteQuery({ - queryKey: ['searchSong', search, searchType, languageTag], + queryKey: ['searchSong', search, searchType], queryFn: async ({ pageParam }) => { - const response = await getInfiniteSearchSong( - search, - searchType, - isAuthenticated, - pageParam, - languageTag, - ); + const response = await getInfiniteSearchSong(search, searchType, isAuthenticated, pageParam); if (!response.success) { throw new Error('Search API failed'); @@ -71,11 +63,7 @@ export const useInfiniteSearchSongQuery = ( }); }; -export const useToggleToSingMutation = ( - query: string, - searchType: string, - languageTag?: number, -) => { +export const useToggleToSingMutation = (query: string, searchType: string) => { const queryClient = useQueryClient(); return useMutation({ // 낙관적 업데이트 검증 코드 @@ -89,7 +77,7 @@ export const useToggleToSingMutation = ( } }, onMutate: async ({ songId, method }: SongProps) => { - const queryKey = ['searchSong', query, searchType, languageTag]; + const queryKey = ['searchSong', query, searchType]; await queryClient.cancelQueries({ queryKey }); const prev = queryClient.getQueryData(queryKey); const isToSing = method === 'POST'; @@ -106,15 +94,12 @@ export const useToggleToSingMutation = ( }; }); - return { prev, query, searchType, languageTag }; + return { prev, query, searchType }; }, onError: (error, variables, context) => { console.error('error', error); alert(error.message ?? 'POST 실패'); - queryClient.setQueryData( - ['searchSong', context?.query, context?.searchType, context?.languageTag], - context?.prev, - ); + queryClient.setQueryData(['searchSong', context?.query, context?.searchType], context?.prev); }, onSettled: () => { if (invalidateToSingTimeout) { @@ -122,7 +107,7 @@ export const useToggleToSingMutation = ( } invalidateToSingTimeout = setTimeout(() => { queryClient.invalidateQueries({ - queryKey: ['searchSong', query, searchType, languageTag], + queryKey: ['searchSong', query, searchType], }); queryClient.invalidateQueries({ queryKey: ['toSingSong'] }); }, 1000); @@ -130,7 +115,7 @@ export const useToggleToSingMutation = ( }); }; -export const useToggleLikeMutation = (query: string, searchType: string, languageTag?: number) => { +export const useToggleLikeMutation = (query: string, searchType: string) => { const queryClient = useQueryClient(); return useMutation({ @@ -142,7 +127,7 @@ export const useToggleLikeMutation = (query: string, searchType: string, languag } }, onMutate: async ({ songId, method }: SongProps) => { - const queryKey = ['searchSong', query, searchType, languageTag]; + const queryKey = ['searchSong', query, searchType]; await queryClient.cancelQueries({ queryKey }); const prev = queryClient.getQueryData(queryKey); const isLike = method === 'POST'; @@ -158,15 +143,12 @@ export const useToggleLikeMutation = (query: string, searchType: string, languag }; }); - return { prev, query, searchType, languageTag }; + return { prev, query, searchType }; }, onError: (error, variables, context) => { console.error('error', error); alert(error.message ?? 'POST 실패'); - queryClient.setQueryData( - ['searchSong', context?.query, context?.searchType, context?.languageTag], - context?.prev, - ); + queryClient.setQueryData(['searchSong', context?.query, context?.searchType], context?.prev); }, onSettled: () => { if (invalidateLikeTimeout) { @@ -174,7 +156,7 @@ export const useToggleLikeMutation = (query: string, searchType: string, languag } invalidateLikeTimeout = setTimeout(() => { queryClient.invalidateQueries({ - queryKey: ['searchSong', query, searchType, languageTag], + queryKey: ['searchSong', query, searchType], }); queryClient.invalidateQueries({ queryKey: ['likeSong'] }); }, 1000); @@ -189,8 +171,8 @@ export const useSaveMutation = () => { mutationFn: ({ songId, folderName }: FolderProps) => { return postSaveSong({ songId, folderName }); }, - onMutate: async ({ songId, query, searchType, languageTag }: FolderProps) => { - const queryKey = ['searchSong', query, searchType, languageTag]; + onMutate: async ({ songId, query, searchType }: FolderProps) => { + const queryKey = ['searchSong', query, searchType]; await queryClient.cancelQueries({ queryKey }); const prev = queryClient.getQueryData(queryKey); @@ -206,19 +188,16 @@ export const useSaveMutation = () => { }; }); - return { prev, query, searchType, languageTag }; + return { prev, query, searchType }; }, onError: (error, variables, context) => { console.error('error', error); alert(error.message ?? 'POST 실패'); - queryClient.setQueryData( - ['searchSong', context?.query, context?.searchType, context?.languageTag], - context?.prev, - ); + queryClient.setQueryData(['searchSong', context?.query, context?.searchType], context?.prev); }, onSettled: (data, error, context) => { queryClient.invalidateQueries({ - queryKey: ['searchSong', context?.query, context?.searchType, context?.languageTag], + queryKey: ['searchSong', context?.query, context?.searchType], }); queryClient.invalidateQueries({ queryKey: ['saveSongFolder'] }); queryClient.invalidateQueries({ queryKey: ['saveSongFolderList'] }); diff --git a/apps/web/src/types/artist.ts b/apps/web/src/types/artist.ts new file mode 100644 index 00000000..740b0e6c --- /dev/null +++ b/apps/web/src/types/artist.ts @@ -0,0 +1,4 @@ +export interface ArtistSearchResult { + name: string; + name_ko: string | null; +} diff --git a/apps/web/src/types/artistVote.ts b/apps/web/src/types/artistVote.ts new file mode 100644 index 00000000..0192cfe4 --- /dev/null +++ b/apps/web/src/types/artistVote.ts @@ -0,0 +1,50 @@ +/** 투표 저장 요청 단위. 서버로 보내는 값은 항상 이 두 개뿐이다. */ +export interface ArtistVoteInput { + artist: string; + amount: number; +} + +export interface MyArtistVote extends ArtistVoteInput { + /** 아티스트 한국어 표기. 없거나 원어 표기와 같으면 화면에 따로 그리지 않는다. */ + artistKo: string | null; +} + +/** 이달의 아티스트로 뽑힌 한 번의 기록. 트로피 하나에 대응한다. */ +export interface ArtistWin { + /** 'YYYY-MM-DD'(매월 1일) */ + month: string; + totalVotes: number; +} + +export interface ArtistRankingItem { + rank: number; + artist: string; + artistKo: string | null; + /** 아티스트 사진 URL. 없으면 이니셜 액자로 대체한다. */ + artistImage: string | null; + totalVotes: number; + topVoterNickname: string | null; + topVoterAmount: number | null; + /** + * 1위 기록(오래된 순). 시상대(1~3위)만 채워진다. + * 조회 중인 달까지로 잘라 담기므로, 지난 달을 보면 그 시점의 기록만 들어 있다. + */ + wins: ArtistWin[]; +} + +export interface ArtistRankingResponse { + month: string; + availableMonths: string[]; + items: ArtistRankingItem[]; +} + +export interface ArtistVoter { + nickname: string; + amount: number; +} + +export interface CurrentArtistOfMonth { + artist: string; + /** 선정된 달. 'YYYY-MM-DD'(매월 1일)로 내려온다. */ + month: string; +} diff --git a/apps/web/src/types/tag.ts b/apps/web/src/types/tag.ts deleted file mode 100644 index 21ec96d0..00000000 --- a/apps/web/src/types/tag.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface Tag { - id: number; - name: string; -} diff --git a/apps/web/src/utils/formatVoteShare.ts b/apps/web/src/utils/formatVoteShare.ts new file mode 100644 index 00000000..e2ddd1f9 --- /dev/null +++ b/apps/web/src/utils/formatVoteShare.ts @@ -0,0 +1,9 @@ +/** + * 득표가 전체에서 차지하는 비중을 소수 첫째 자리까지 적는다. + * + * 순위표(ArtistRankingBoard)와 파이(ArtistRankingChart)는 같은 데이터를 다른 방식으로 + * 보여주는 한 쌍이라, 같은 득표에 대해 두 화면이 다른 숫자를 적으면 곧바로 오류로 읽힌다. + * 자릿수 규칙을 한 곳에 둬야 그 어긋남이 생기지 않는다. + */ +export const formatVoteShare = (votes: number, total: number) => + total > 0 ? `${((votes / total) * 100).toFixed(1)}%` : '0%'; diff --git a/apps/web/src/utils/kst.ts b/apps/web/src/utils/kst.ts index 33166306..db35d291 100644 --- a/apps/web/src/utils/kst.ts +++ b/apps/web/src/utils/kst.ts @@ -1,18 +1,8 @@ -import { addDays, addHours, format, startOfMonth, subMonths } from 'date-fns'; +import { addDays, addHours } from 'date-fns'; -/** 오늘 KST 날짜를 'YYYY-MM-DD' 문자열로 반환 (서버 타임존 무관) */ -export function getTodayKST(): string { - return addHours(new Date(), 9).toISOString().split('T')[0]; -} - -/** - * 이전 달 1일을 'YYYY-MM-DD' 문자열로 반환 (KST 기준, 서버 타임존 무관) - * 월 단위로 집계되는 차트의 기본 조회 월로 사용한다. - */ -export function getPrevMonthFirstDayKST(): string { - const [year, month] = getTodayKST().split('-').map(Number); - return format(startOfMonth(subMonths(new Date(year, month - 1, 1), 1)), 'yyyy-MM-dd'); -} +// 월 계산은 확정 배치(packages/crawling)와 같은 규칙을 써야 해 @repo/constants로 옮겼다. +// 기존 import 경로를 유지하려고 여기서 재수출한다. +export { getCurrentMonthFirstDayKST, getPrevMonthFirstDayKST, getTodayKST } from '@repo/constants'; /** 내일 KST 날짜를 'YYYY-MM-DD' 문자열로 반환 */ export function getTomorrowKST(): string { diff --git a/packages/constants/package.json b/packages/constants/package.json index 19076769..58225f8d 100644 --- a/packages/constants/package.json +++ b/packages/constants/package.json @@ -14,5 +14,8 @@ "@types/node": "^22.13.10", "tsup": "^8.4.0", "typescript": "^5.8.2" + }, + "dependencies": { + "date-fns": "^4.1.0" } } diff --git a/packages/constants/src/artistName.ts b/packages/constants/src/artistName.ts new file mode 100644 index 00000000..fec11326 --- /dev/null +++ b/packages/constants/src/artistName.ts @@ -0,0 +1,74 @@ +import { artistAlias } from "./artistAlias"; + +// 쉼표, &(반각/전각), ×, ・, feat, with가 하나라도 들어있으면 다른 조건과 무관하게 +// 이 곡의 artist 값 자체를 무효 처리한다(백필 대상에서 제외) — 애매하게 앞부분만 +// 잘라 쓰는 것보다 아예 안 쓰는 게 안전하다. +// ×(가운뎃점 아님, 곱하기 기호)와 ・(가운뎃점)는 일본어 콘텐츠의 협업 표기에 흔히 +// 쓰인다 — "絢香×コブクロ", "林原めぐみ・関智一"처럼. ・는 한 사람 이름 안에서 +// 성·이름을 구분할 때도 쓰이긴 하지만(예: オノ・ヨーコ), 실측 데이터엔 전부 +// 두 아티스트를 잇는 용도로만 나타나 무효 처리 대상에 포함했다. +const INVALID_ARTIST_PATTERN = /[,&&×・]|\bfeat\b|\bwith\b/i; +// "지코 X 아이유"처럼 앞뒤로 다른 단어가 있을 때만 x를 콜라보 구분자로 본다. +// 이 제약이 없으면 "X JAPAN"처럼 이름 자체가 X로 시작하는 실존 아티스트까지 걸린다. +const X_JOIN_PATTERN = /\S\s+[xX]\s+\S/; + +// 콜라보 표기를 참여자 단위로 쪼갤 때 쓰는 구분자. +// INVALID_ARTIST_PATTERN과 같은 기호를 보지만 목적이 다르다 — 저쪽은 "버릴지"를 정하고 +// 이쪽은 "어디까지가 맨 앞 아티스트인지"를 정한다. feat/with는 앞의 공백까지 함께 잘라 +// "IU Feat.최백호"에서 뒤쪽 이름만 떨어져 나가게 한다. +const COLLAB_SEPARATOR_PATTERN = /[,&&×・]|\s+(?:feat|with)\b\.?|\s+[xX]\s+/i; + +// artistAlias: { [원어 공식 표기]: [한국어 표기 별칭들] }. +// 별칭이 songs.artist 값으로 등장하는 일은 드물지만, 등장하면 공식 표기로 접어준다. +const aliasToOfficial = new Map(); +for (const [officialName, aliases] of Object.entries(artistAlias)) { + aliasToOfficial.set(officialName, officialName); + aliases.forEach((alias) => aliasToOfficial.set(alias, officialName)); +} + +/** + * songs.artist 원문이 무효 처리 대상인지 판별한다: 쉼표/&/&/×/・/feat/with 포함, + * 또는 " X "/" x " 형태의 콜라보 구분자가 있으면 true. + */ +export function isInvalidArtist(raw: string): boolean { + return INVALID_ARTIST_PATTERN.test(raw) || X_JOIN_PATTERN.test(raw); +} + +/** + * songs.artist 원문에서 첫 괄호 앞부분만 남긴다(괄호 안 내용은 무엇이든 전부 버림 — + * 누락을 감수하는 대신 규칙을 단순하게 유지). isInvalidArtist를 먼저 걸러낸 뒤에만 + * 호출한다는 전제라 쉼표/&/feat/with/x-조인은 여기서 신경 쓰지 않는다. + * + * 자른 결과가 빈 문자열이 되면(예: "(여자)아이들"처럼 이름 자체가 괄호로 시작하는 + * 경우) 잘라내지 않고 원문을 그대로 돌려준다. + */ +export function extractPrimaryArtist(raw: string): string { + const s = raw.trim(); + + const firstParenIdx = s.indexOf("("); + if (firstParenIdx === -1 || firstParenIdx === 0) return s; + + return s.slice(0, firstParenIdx).trim() || s; +} + +/** + * songs.artist 원문에서 "맨 앞에 적힌 주 아티스트" 하나를 뽑아낸다. + * + * 백필(extractPrimaryArtist + isInvalidArtist)은 마스터 테이블에 올릴 이름을 고르는 쪽이라 + * 콜라보 표기를 통째로 버리지만, 화면 표시(예: 이달의 아티스트 배지)는 틀려도 비용이 작고 + * 반대로 안 붙는 누락이 훨씬 눈에 띈다. 그래서 버리는 대신 맨 앞 이름만 취한다. + * 참여자 전원을 인정하지 않는 이유는 피처링으로 한 소절 부른 곡까지 주인공처럼 보이면 + * 배지가 흔해져 값어치를 잃기 때문이다. + * + * 괄호를 먼저 떼는 순서가 중요하다. "IU(Feat.최백호)"는 구분자를 먼저 보면 "IU(" 가 남는다. + * + * "IU(Feat.최백호)" → "IU" / "IU,나윤권" → "IU" / "DAOKO X 米津玄師" → "DAOKO" + */ +export function getPrimaryArtistName(raw: string): string { + if (!raw) return ""; + + const primary = extractPrimaryArtist(raw); + const leading = primary.split(COLLAB_SEPARATOR_PATTERN)[0]?.trim() || primary; + + return aliasToOfficial.get(leading) ?? leading; +} diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts index 533baaa1..635599fb 100644 --- a/packages/constants/src/index.ts +++ b/packages/constants/src/index.ts @@ -1 +1,5 @@ export { artistAlias } from './artistAlias'; +export { extractPrimaryArtist, getPrimaryArtistName, isInvalidArtist } from './artistName'; +export { getCurrentMonthFirstDayKST, getPrevMonthFirstDayKST, getTodayKST } from './kstMonth'; +export { TOP_RANK_LIMIT, rankTopArtists } from './rankArtistVotes'; +export type { ArtistAggregate, VoteRow } from './rankArtistVotes'; diff --git a/packages/constants/src/kstMonth.ts b/packages/constants/src/kstMonth.ts new file mode 100644 index 00000000..94df9809 --- /dev/null +++ b/packages/constants/src/kstMonth.ts @@ -0,0 +1,28 @@ +import { addHours, format, startOfMonth, subMonths } from 'date-fns'; + +/** + * KST 기준 월 계산. + * + * 웹(기본 조회 월 표시)과 백필/확정 스크립트(집계 대상 월)가 같은 답을 내야 한다. + * 한쪽만 고치면 "화면이 기본으로 보여주는 달"과 "실제로 확정된 달"이 어긋나, 사용자에게는 + * 결과가 통째로 사라진 것처럼 보인다. 그래서 규칙을 공유 패키지에 둔다. + * + * 서버 타임존이 무엇이든 같은 값을 내도록 UTC에 9시간을 더해 KST 벽시계를 만든 뒤 자른다. + */ + +/** 오늘 KST 날짜를 'YYYY-MM-DD' 문자열로 반환 */ +export function getTodayKST(): string { + return addHours(new Date(), 9).toISOString().split('T')[0]; +} + +/** 이전 달 1일 'YYYY-MM-DD' (KST 기준). 월 단위 집계의 대상 월이다. */ +export function getPrevMonthFirstDayKST(): string { + const [year, month] = getTodayKST().split('-').map(Number); + return format(startOfMonth(subMonths(new Date(year, month - 1, 1), 1)), 'yyyy-MM-dd'); +} + +/** 이번 달 1일 'YYYY-MM-DD' (KST 기준). 아티스트 투표는 이 달에만 수정할 수 있다. */ +export function getCurrentMonthFirstDayKST(): string { + const [year, month] = getTodayKST().split('-').map(Number); + return format(startOfMonth(new Date(year, month - 1, 1)), 'yyyy-MM-dd'); +} diff --git a/packages/constants/src/rankArtistVotes.ts b/packages/constants/src/rankArtistVotes.ts new file mode 100644 index 00000000..ce6e6d27 --- /dev/null +++ b/packages/constants/src/rankArtistVotes.ts @@ -0,0 +1,66 @@ +/** + * 아티스트 투표 집계 규칙. + * + * 확정 배치(packages/crawling)와 웹이 같은 규칙을 봐야 해서 공유 패키지에 둔다 — + * 순위 개수나 동점 처리가 한쪽만 바뀌면 화면에 적힌 순위와 저장된 순위가 어긋난다. + */ + +/** 확정 저장하는 순위 개수. monthly_artist_rankings의 rank check 제약과 같아야 한다. */ +export const TOP_RANK_LIMIT = 30; + +export interface VoteRow { + user_id: string; + artist: string; + amount: number; + created_at: string; +} + +export interface ArtistAggregate { + artist: string; + total: number; + topVoterUserId: string; + topVoterAmount: number; + firstCreatedAt: string; +} + +/** + * 아티스트별로 득표를 합산해 상위 30명을 반환한다. + * 동점이면 그 달에 먼저 득표를 모으기 시작한 아티스트가 우선, 그래도 같으면 이름순. + */ +export function rankTopArtists(votes: VoteRow[]): ArtistAggregate[] { + const aggregateMap = new Map(); + + for (const vote of votes) { + const existing = aggregateMap.get(vote.artist); + + if (!existing) { + aggregateMap.set(vote.artist, { + artist: vote.artist, + total: vote.amount, + topVoterUserId: vote.user_id, + topVoterAmount: vote.amount, + firstCreatedAt: vote.created_at, + }); + continue; + } + + existing.total += vote.amount; + if (vote.created_at < existing.firstCreatedAt) { + existing.firstCreatedAt = vote.created_at; + } + if (vote.amount > existing.topVoterAmount) { + existing.topVoterUserId = vote.user_id; + existing.topVoterAmount = vote.amount; + } + } + + return [...aggregateMap.values()] + .sort((a, b) => { + if (b.total !== a.total) return b.total - a.total; + if (a.firstCreatedAt !== b.firstCreatedAt) { + return a.firstCreatedAt < b.firstCreatedAt ? -1 : 1; + } + return a.artist.localeCompare(b.artist); + }) + .slice(0, TOP_RANK_LIMIT); +} diff --git a/packages/crawling/CLAUDE.md b/packages/crawling/CLAUDE.md index d48429cc..c4d66800 100644 --- a/packages/crawling/CLAUDE.md +++ b/packages/crawling/CLAUDE.md @@ -19,6 +19,9 @@ pnpm tj-badges # TJ 반주 버전 뱃지(MV/MR/LV/60) 수집 (badges가 pnpm remove-dead-songs # TJ에서 사라진 번호의 곡 정리 (기본 미리보기, DEAD_SONGS_APPLY=true로 실행) pnpm tj-chart # TJ 공식 차트(TOP100) 전월분 수집 pnpm tj-chart-backfill # TJ 공식 차트 과거 월 일괄 백필 (기간은 스크립트 상수로 지정) +pnpm backfill-artists # songs.artist에서 artists 마스터 테이블 백필 +pnpm backfill-artist-images # artists.image_url을 Deezer 공개 API로 채움 (키 불필요) +pnpm finalize-artist # 이달의 아티스트 월간 확정 (지난달 투표 집계 → 순위 저장) pnpm tag-songs # AI 기반 곡 자동 태깅 pnpm trans-jpn # J-POP 곡 제목/아티스트 한국어 번역 pnpm test # vitest 실행 @@ -99,14 +102,15 @@ findKYByOpen.ts ### Supabase 테이블 -| 테이블 | 용도 | -| ------------------ | -------------------------------- | -| `songs` | 메인 곡 데이터 (TJ/KY 번호 포함) | -| `invalid_ky_songs` | KY 번호 수집 실패 목록 | -| `tags` | 태그 마스터 (id, name, category) | -| `song_tags` | 곡-태그 매핑 (song_id, tag_id) | -| `verify_ky_songs` | KY 번호 검증 완료 목록 | -| `chart_rankings` | TJ 공식 차트 월별/장르별 순위 | +| 테이블 | 용도 | +| ------------------ | ------------------------------------------ | +| `songs` | 메인 곡 데이터 (TJ/KY 번호 포함) | +| `invalid_ky_songs` | KY 번호 수집 실패 목록 | +| `tags` | 태그 마스터 (id, name, category) | +| `song_tags` | 곡-태그 매핑 (song_id, tag_id) | +| `verify_ky_songs` | KY 번호 검증 완료 목록 | +| `chart_rankings` | TJ 공식 차트 월별/장르별 순위 | +| `artists` | 아티스트 마스터 (name, name_ko, image_url) | ### AI 유틸 @@ -176,14 +180,15 @@ crawlTjBadges.ts (pnpm tj-badges) ### GitHub Actions 워크플로우 -| 워크플로우 파일 | 스케줄 (UTC) | 실행 스크립트 | -| ------------------------- | ----------------- | -------------------- | -| `crawl_recent_tj.yml` | 매일 14:00 | `pnpm recent-tj` | -| `crawl_tj_chart.yml` | 매달 1일 01:00 | `pnpm tj-chart` | -| `tagging_song.yml` | 매일 07:00 | `pnpm tag-songs` | -| `translation_jpn.yml` | 매일 10:00 | `pnpm trans-jpn` | -| `update_ky_youtube.yml` | 매일 14:00 | `pnpm ky-youtube` | -| `verify_ky_youtube.yml` | 매주 월요일 14:00 | `pnpm ky-verify` | -| `crawl_tj_all_number.yml` | 수동 전용 | `pnpm tj-all-number` | +| 워크플로우 파일 | 스케줄 (UTC) | 실행 스크립트 | +| ------------------------------ | ----------------- | ------------------------------------------------------ | +| `crawl_recent_tj.yml` | 매일 14:00 | `pnpm recent-tj` | +| `crawl_tj_chart.yml` | 매달 1일 01:00 | `pnpm tj-chart` | +| `tagging_song.yml` | 매일 07:00 | `pnpm tag-songs` | +| `translation_jpn.yml` | 매일 10:00 | `pnpm trans-jpn` | +| `update_ky_youtube.yml` | 매일 14:00 | `pnpm ky-youtube` | +| `verify_ky_youtube.yml` | 매주 월요일 14:00 | `pnpm ky-verify` | +| `crawl_tj_all_number.yml` | 수동 전용 | `pnpm tj-all-number` | +| `finalize_artist_of_month.yml` | 매일 15:10 | `pnpm finalize-artist` + `pnpm backfill-artist-images` | `crawl_tj_all_number.yml`을 제외한 모든 워크플로우는 스케줄 + `workflow_dispatch`(수동) 양쪽으로 실행 가능하다. `crawl_tj_all_number.yml`은 번호 구간을 `matrix`로 병렬 분할해 수동으로만 실행한다. diff --git a/packages/crawling/package.json b/packages/crawling/package.json index fa3a2228..f0d54d1c 100644 --- a/packages/crawling/package.json +++ b/packages/crawling/package.json @@ -19,6 +19,9 @@ "remove-dead-songs": "tsx src/cron/removeDeadTjSongs.ts", "tj-chart": "tsx src/cron/crawlTjChart.ts", "tj-chart-backfill": "tsx src/cron/crawlTjChartBackfill.ts", + "backfill-artists": "tsx src/cron/backfillArtists.ts", + "backfill-artist-images": "tsx src/cron/backfillArtistImages.ts", + "finalize-artist": "tsx src/cron/finalizeArtistOfMonth.ts", "lint": "eslint .", "test": "vitest run", "format": "prettier --write \"**/*.{ts,tsx,md}\"" diff --git a/packages/crawling/src/assets/artistBackfillLog.txt b/packages/crawling/src/assets/artistBackfillLog.txt new file mode 100644 index 00000000..ff645610 --- /dev/null +++ b/packages/crawling/src/assets/artistBackfillLog.txt @@ -0,0 +1,20 @@ + +===== 아티스트 백필 실행: 2026-08-21T07:53:09.595Z ===== +전체 곡을 대상으로 백필합니다. +대상 곡 수: 78160 +고유 아티스트 수: 24001 +아티스트 upsert 결과 — 성공: 24001건, 실패: 0건 + +===== 아티스트 백필 실행: 2026-08-21T09:21:17.710Z ===== +전체 곡을 대상으로 백필합니다. +대상 곡 수: 78160 +무효 처리(쉼표/feat/with/한국어-& 포함)로 제외한 곡 수: 10270 +고유 아티스트 수: 14418 +아티스트 upsert 결과 — 성공: 14418건, 실패: 0건 + +===== 아티스트 백필 실행: 2026-08-21T09:34:31.797Z ===== +전체 곡을 대상으로 백필합니다. +대상 곡 수: 78160 +무효 처리(쉼표/&/feat/with/x-콜라보 포함)로 제외한 곡 수: 10515 +고유 아티스트 수: 13902 +아티스트 upsert 결과 — 성공: 13902건, 실패: 0건 diff --git a/packages/crawling/src/assets/artistFinalizeLog.txt b/packages/crawling/src/assets/artistFinalizeLog.txt new file mode 100644 index 00000000..6a69e04a --- /dev/null +++ b/packages/crawling/src/assets/artistFinalizeLog.txt @@ -0,0 +1,5 @@ + +===== 이달의 아티스트 확정 실행: 2026-08-23T14:42:46.248Z ===== +대상 월: 2026-07-01 +투표 수: 0건 +투표가 없어 종료합니다 (기존 순위는 그대로 둔다). diff --git a/packages/crawling/src/assets/artistImageBackfillLog.txt b/packages/crawling/src/assets/artistImageBackfillLog.txt new file mode 100644 index 00000000..0febb2f3 --- /dev/null +++ b/packages/crawling/src/assets/artistImageBackfillLog.txt @@ -0,0 +1,56 @@ + +===== 아티스트 이미지 백필(Deezer) 실행: 2026-08-23T09:44:07.541Z ===== +대상 10명 (DRY RUN — DB에 쓰지 않음) +사진이 비어 있는 아티스트: 10명 +[없음] 건 — 사진 있는 동일 이름 없음 · 후보: GuNN | 건희 +[없음] 결 — 사진 있는 동일 이름 없음 · 후보: KYUL +[없음] 겸 — 사진 있는 동일 이름 없음 · 후보: GYE0M +[없음] 길 — 사진 있는 동일 이름 없음 · 후보: Gill +[없음] 넬 — 사진 있는 동일 이름 없음 · 후보: Nell +[없음] 뉴 — 사진 있는 동일 이름 없음 · 후보: NEW (THE BOYZ) | New | NU'EST | 뉴이스트 W | 뉴 크리에이션 워십 New Creation Worship | 뉴제너레이션워십 New Generation Worship +[없음] 던 — 사진 있는 동일 이름 없음 · 후보: Dawn | DAWN 던 +[없음] 딘 — 사진 있는 동일 이름 없음 · 후보: Dean | Dean +[없음] 란 — 사진 있는 동일 이름 없음 · 후보: Ran | Ran | Ran | Ran +[없음] 렌 — 사진 있는 동일 이름 없음 · 후보: LEN | REN | LEN | ReN | REN +결과 — 채움: 0명, 사진 못 찾음: 10명, 실패: 0명 + +===== 아티스트 이미지 백필(Deezer) 실행: 2026-08-23T09:45:14.973Z ===== +대상 지정 15명 (DRY RUN — DB에 쓰지 않음) +사진이 비어 있는 아티스트: 10명 +[없음] 싸이 — 사진 있는 동일 이름 없음 · 후보: 싸이 (PSY) | Psy | Psy | Simons | Ciipher | xikers (싸이커스) | Sizer +[찾음] 태연 → 태연 (팬 66) https://cdn-images.dzcdn.net/images/artist/0c3af2f07d2afe670691d72742bcbcf9/1000x1000-000000-80-0-0.jpg +[찾음] 폴킴 → 폴킴 (팬 17) https://cdn-images.dzcdn.net/images/artist/06b09294f5a59e641c69ce6928a68b4b/1000x1000-000000-80-0-0.jpg +[없음] 임영웅 — 사진 있는 동일 이름 없음 · 후보: Lim Young Woong | LIM YOUNG MIN | Lim Youngwoong | im young woong +[찾음] 잔나비 → 잔나비 (팬 21) https://cdn-images.dzcdn.net/images/artist/21a64bdc3fd7a2f3bb7f7b625520c3da/1000x1000-000000-80-0-0.jpg +[없음] 방탄소년단 — 사진 있는 동일 이름 없음 · 후보: BTS | Supreme Boi +[없음] 볼빨간사춘기 — 사진 있는 동일 이름 없음 · 후보: BOL4 +[찾음] IU → IU (팬 305837) https://cdn-images.dzcdn.net/images/artist/c619e4cef93d6fa98a38018d92106bb6/1000x1000-000000-80-0-0.jpg +[찾음] YOASOBI → YOASOBI (팬 126490) https://cdn-images.dzcdn.net/images/artist/2cbfaf626a591e162bfcc2b4b0214217/1000x1000-000000-80-0-0.jpg +[없음] 米津玄師 — 사진 있는 동일 이름 없음 · 후보: 결과 없음 +결과 — 채움: 0명, 사진 못 찾음: 5명, 실패: 0명 + +===== 아티스트 이미지 백필(Deezer) 실행: 2026-08-23T09:48:01.729Z ===== +대상 지정 5명 +사진이 비어 있는 아티스트: 5명 +[찾음] 태연 → 태연 (팬 66) https://cdn-images.dzcdn.net/images/artist/0c3af2f07d2afe670691d72742bcbcf9/1000x1000-000000-80-0-0.jpg +[찾음] 폴킴 → 폴킴 (팬 17) https://cdn-images.dzcdn.net/images/artist/06b09294f5a59e641c69ce6928a68b4b/1000x1000-000000-80-0-0.jpg +[찾음] 잔나비 → 잔나비 (팬 21) https://cdn-images.dzcdn.net/images/artist/21a64bdc3fd7a2f3bb7f7b625520c3da/1000x1000-000000-80-0-0.jpg +[찾음] IU → IU (팬 305837) https://cdn-images.dzcdn.net/images/artist/c619e4cef93d6fa98a38018d92106bb6/1000x1000-000000-80-0-0.jpg +[찾음] YOASOBI → YOASOBI (팬 126490) https://cdn-images.dzcdn.net/images/artist/2cbfaf626a591e162bfcc2b4b0214217/1000x1000-000000-80-0-0.jpg +결과 — 채움: 5명, 사진 못 찾음: 0명, 실패: 0명 + +===== 아티스트 이미지 백필(Deezer) 실행: 2026-08-23T09:57:32.741Z ===== +대상 지정 10명 (DRY RUN — DB에 쓰지 않음) +사진이 비어 있는 아티스트: 5명 +[찾음] 싸이 → Psy (팬 893608) https://cdn-images.dzcdn.net/images/artist/fff83cade2015634a283e3c955871162/1000x1000-000000-80-0-0.jpg +[찾음] 임영웅 → Lim Young Woong (팬 829) https://cdn-images.dzcdn.net/images/artist/243dd9e39fd2c303b8da825197ded197/1000x1000-000000-80-0-0.jpg +[찾음] 방탄소년단 → BTS (팬 3850357) https://cdn-images.dzcdn.net/images/artist/b5c64fa8216ca158e52b4d88bd9388ff/1000x1000-000000-80-0-0.jpg +[찾음] 볼빨간사춘기 → BOL4 (팬 16648) https://cdn-images.dzcdn.net/images/artist/b4b21c7205dc80813c88a742e82e9154/1000x1000-000000-80-0-0.jpg +[찾음] 米津玄師 → Kenshi Yonezu (팬 67445) https://cdn-images.dzcdn.net/images/artist/29288251d9b8a8bd9c8497fb7b4fbcec/1000x1000-000000-80-0-0.jpg +결과 — 채움: 0명, 사진 못 찾음: 0명, 실패: 0명 + +===== 아티스트 이미지 백필(Deezer) 실행: 2026-08-23T10:04:25.158Z ===== +대상 시상대 3명 (DRY RUN — DB에 쓰지 않음) +최근 확정 월 상위 3명: 없음 +사진이 비어 있는 아티스트: 0명 +결과 — 채움: 0명, 사진 못 찾음: 0명, 실패: 0명 diff --git a/packages/crawling/src/cron/backfillArtistImages.ts b/packages/crawling/src/cron/backfillArtistImages.ts new file mode 100644 index 00000000..de851d44 --- /dev/null +++ b/packages/crawling/src/cron/backfillArtistImages.ts @@ -0,0 +1,230 @@ +import axios from 'axios'; +import fs from 'fs'; +import path from 'path'; + +import { artistAlias } from '@repo/constants'; + +import { + getArtistsByNamesDB, + getArtistsWithoutImageDB, + getLatestPodiumArtistNamesDB, +} from '@/supabase/getDB'; +import { updateArtistImageDB } from '@/supabase/postDB'; +import { ArtistImageTarget } from '@/types'; +import { MUSICBRAINZ_INTERVAL_MS, getArtistNameSet } from '@/utils/musicbrainz'; +import { normalizeArtistName as normalize } from '@/utils/normalizeArtistName'; + +/** + * artists.image_url을 Deezer 공개 API로 채운다. + * + * Deezer를 쓰는 이유는 키 발급이 없어서다. 저장하는 건 파일이 아니라 URL이라, + * 나중에 출처를 바꿔도 이 컬럼만 갈아끼우면 된다. + * + * 이름은 MusicBrainz로 한 번 번역해서 넘긴다. 우리 DB는 TJ 표기(한글·원어)를 쓰는데 + * Deezer의 정품 등록은 로마자라("방탄소년단" ↔ "BTS"), 표기 그대로 찾으면 사진이 있는데도 + * 못 찾는다. 실제로 이 단계가 없을 때 방탄소년단·임영웅·볼빨간사춘기·싸이가 전부 빈손이었다. + * + * 환경변수 + * - ARTIST_IMAGE_PODIUM: N을 주면 가장 최근 확정 월의 상위 N명만 처리한다(월간 확정 워크플로용) + * - ARTIST_IMAGE_NAMES: 쉼표로 구분한 이름 목록. 지정하면 그 아티스트만 처리한다 + * - ARTIST_IMAGE_LIMIT: 이번 실행에서 처리할 아티스트 수 (기본 10, NAMES 지정 시 무시) + * - ARTIST_IMAGE_DRY_RUN: '1'이면 DB에 쓰지 않고 무엇이 잡혔는지만 찍는다 + */ + +const LOG_FILE = path.join('src', 'assets', 'artistImageBackfillLog.txt'); + +function log(message: string) { + console.log(message); + fs.appendFileSync(LOG_FILE, message + '\n', 'utf-8'); +} + +const LIMIT = Number(process.env.ARTIST_IMAGE_LIMIT ?? 10); +const PODIUM_TOP = Number(process.env.ARTIST_IMAGE_PODIUM ?? 0); +const NAMES = (process.env.ARTIST_IMAGE_NAMES ?? '') + .split(',') + .map(name => name.trim()) + .filter(Boolean); +const DRY_RUN = process.env.ARTIST_IMAGE_DRY_RUN === '1'; + +// Deezer는 IP당 5초에 50회까지 받는다. 초당 3~4회면 그 절반도 안 쓰니 재시도 로직이 필요 없다. +const REQUEST_INTERVAL_MS = 300; + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +interface DeezerArtist { + id: number; + name: string; + /** 1000x1000. 사진이 없으면 실루엣 기본 이미지 URL이 온다. */ + picture_xl: string; + picture_big: string; + nb_fan: number; +} + +/** + * 사진이 없는 아티스트에게 Deezer가 돌려주는 실루엣 URL 판별. + * 이 경우 이미지 md5 자리가 비어 경로에 슬래시가 두 번 겹친다(.../images/artist//1000x1000-...). + * 실루엣을 저장하면 액자에 회색 사람 모양이 걸리는데, 그건 이니셜 폴백보다 나쁘다. + */ +const isPlaceholderPicture = (url: string) => !url || url.includes('/images/artist//'); + +// artistAlias는 키가 고정된 객체 리터럴이라 임의의 이름으로 색인할 수 없다. Map으로 한 번 편다. +const aliasesByOfficialName = new Map(Object.entries(artistAlias)); + +/** + * 이 아티스트로 인정할 이름들. 공식 표기에 artistAlias의 한국어 별칭을 더한다. + * Deezer가 "아이유" 대신 "IU"를 돌려주는 식의 표기 차이를 여기서 흡수한다. + */ +function buildAcceptedNames(target: ArtistImageTarget): Set { + const accepted = new Set([normalize(target.name)]); + + if (target.name_ko) accepted.add(normalize(target.name_ko)); + for (const alias of aliasesByOfficialName.get(target.name) ?? []) accepted.add(normalize(alias)); + + return accepted; +} + +/** + * MusicBrainz에서 이 아티스트의 다른 표기를 받아 인정 이름 집합에 더하고, 대표 표기를 돌려준다. + * + * 실패해도(못 찾음·서버 오류) 원래 표기로는 계속 찾아보므로 여기서 멈추지 않는다 — + * 이름 사전은 검색 결과를 넓혀 주는 보조 수단이지 없으면 안 되는 재료가 아니다. + */ +async function expandWithMusicbrainz(name: string, accepted: Set) { + let canonicalName: string | undefined; + + try { + const nameSet = await getArtistNameSet(name); + if (nameSet) { + canonicalName = nameSet.canonicalName; + nameSet.names.forEach(alias => accepted.add(normalize(alias))); + } + } catch (error) { + log( + `[MB실패] ${name} — ${error instanceof Error ? error.message : String(error)} (표기 그대로 계속)`, + ); + } + + await sleep(MUSICBRAINZ_INTERVAL_MS); + + return canonicalName; +} + +async function searchDeezer(query: string): Promise { + const { data } = await axios.get<{ data?: DeezerArtist[] }>( + 'https://api.deezer.com/search/artist', + // 같은 아티스트가 중복 등록돼 있는 경우가 많아 넉넉히 받는다(아래 pickBestMatch 주석 참고). + { params: { q: query, limit: 10 }, timeout: 10000 }, + ); + return data.data ?? []; +} + +/** + * 이름이 정확히 일치하는 후보들 중 팬 수가 가장 많고 사진이 있는 하나를 고른다. + * + * "가장 비슷한 것"을 고르지 않는 게 첫 번째 원칙이다 — 검색 API는 무엇을 넣든 뭔가를 + * 돌려주므로, 느슨하게 받으면 1위 액자에 다른 사람 얼굴이 걸린다. 틀린 사진은 빈 액자보다 나쁘다. + * + * 팬 수로 다시 거르는 건 Deezer에 같은 아티스트가 여러 번 등록돼 있기 때문이다. + * 정품 등록(팬 수십만)과 누군가 업로드하며 생긴 중복 항목(팬 수십)이 같은 이름으로 공존하고, + * 중복 쪽은 사진이 비어 있는 일이 잦다. 이름만 보고 첫 번째를 집으면 실루엣을 집게 된다. + */ +function pickBestMatch(candidates: DeezerArtist[], accepted: Set) { + const matched = candidates + .filter(candidate => accepted.has(normalize(candidate.name))) + .sort((a, b) => b.nb_fan - a.nb_fan); + + return matched.find( + candidate => !isPlaceholderPicture(candidate.picture_xl || candidate.picture_big), + ); +} + +log(`\n===== 아티스트 이미지 백필(Deezer) 실행: ${new Date().toISOString()} =====`); +/** + * 처리 대상 결정. + * - PODIUM: 월간 확정 직후 시상대만. 이미 사진이 있는 아티스트는 걸러지므로, 같은 달에 + * 여러 번 돌아도(이 워크플로는 매일 돈다) 두 번째부터는 대상이 0명이라 외부 요청이 없다. + * - NAMES: 이름을 콕 집어 시험할 때. + * - 기본: 사진이 빈 아티스트를 이름순으로 LIMIT명. + */ +async function resolveTargets() { + if (PODIUM_TOP > 0) { + const names = await getLatestPodiumArtistNamesDB(PODIUM_TOP); + log(`최근 확정 월 상위 ${PODIUM_TOP}명: ${names.join(', ') || '없음'}`); + return names.length > 0 ? getArtistsByNamesDB(names) : []; + } + + if (NAMES.length > 0) return getArtistsByNamesDB(NAMES); + + return getArtistsWithoutImageDB(LIMIT); +} + +log( + `대상 ${PODIUM_TOP > 0 ? `시상대 ${PODIUM_TOP}명` : NAMES.length > 0 ? `지정 ${NAMES.length}명` : `${LIMIT}명`}${DRY_RUN ? ' (DRY RUN — DB에 쓰지 않음)' : ''}`, +); + +const targets = await resolveTargets(); +log(`사진이 비어 있는 아티스트: ${targets.length}명`); + +let filled = 0; +let noMatch = 0; +let failed = 0; + +for (const target of targets) { + await sleep(REQUEST_INTERVAL_MS); + + try { + const accepted = buildAcceptedNames(target); + + // 1) 이 아티스트의 다른 표기를 모아 인정 이름과 검색어를 넓힌다. + const canonicalName = await expandWithMusicbrainz(target.name, accepted); + + // 2) 대표 표기(보통 로마자) → 원어 표기 → 한국어 표기 순으로 찾는다. + // 대표 표기를 먼저 두는 건 그쪽에 정품 등록이 몰려 있어서다. 중복 등록에서 건지는 + // 사진보다 정품 쪽 사진이 낫다. + const queries = [ + ...(canonicalName && normalize(canonicalName) !== normalize(target.name) + ? [canonicalName] + : []), + target.name, + ...(target.name_ko && target.name_ko !== target.name ? [target.name_ko] : []), + ]; + + // 후보를 다 모아 두고 마지막에 한 번에 고른다. 검색어마다 끊어 고르면 먼저 돈 검색어의 + // 중복 등록이 뒤에 나올 정품 등록을 이겨버린다. + const seenCandidates: DeezerArtist[] = []; + + for (const query of queries) { + seenCandidates.push(...(await searchDeezer(query))); + await sleep(REQUEST_INTERVAL_MS); + } + + const matched = pickBestMatch(seenCandidates, accepted); + + if (!matched) { + // 놓친 후보 이름을 함께 남긴다. 로마자 표기(예: 米津玄師 → Kenshi Yonezu)로만 사진이 + // 있는 경우가 있어, 이 로그가 곧 artistAlias에 별칭을 더할지 판단하는 근거가 된다. + const missed = + [...new Set(seenCandidates.map(candidate => candidate.name))].join(' | ') || '결과 없음'; + noMatch++; + log(`[없음] ${target.name} — 사진 있는 동일 이름 없음 · 후보: ${missed}`); + continue; + } + + const picture = matched.picture_xl || matched.picture_big; + + log(`[찾음] ${target.name} → ${matched.name} (팬 ${matched.nb_fan}) ${picture}`); + + if (DRY_RUN) continue; + + if (await updateArtistImageDB(target.name, picture)) { + filled++; + } else { + failed++; + } + } catch (error) { + failed++; + log(`[실패] ${target.name} — ${error instanceof Error ? error.message : String(error)}`); + } +} + +log(`결과 — 채움: ${filled}명, 사진 못 찾음: ${noMatch}명, 실패: ${failed}명`); diff --git a/packages/crawling/src/cron/backfillArtists.ts b/packages/crawling/src/cron/backfillArtists.ts new file mode 100644 index 00000000..593f40fd --- /dev/null +++ b/packages/crawling/src/cron/backfillArtists.ts @@ -0,0 +1,97 @@ +import { subDays } from 'date-fns'; +import fs from 'fs'; +import path from 'path'; + +import { artistAlias } from '@repo/constants'; + +import { getSongsForArtistBackfillDB } from '@/supabase/getDB'; +import { upsertArtistsDB } from '@/supabase/postDB'; +import { ArtistUpsert } from '@/types'; +import { extractPrimaryArtist, isInvalidArtist } from '@/utils/extractPrimaryArtist'; + +const LOG_FILE = path.join('src', 'assets', 'artistBackfillLog.txt'); + +function log(message: string) { + console.log(message); + fs.appendFileSync(LOG_FILE, message + '\n', 'utf-8'); +} + +// artistAlias: { [원어 공식 표기]: [한국어 표기 별칭들] }. +// 공식 표기는 songs.artist 원문과 그대로 일치하고, 별칭은 한국어 검색용 힌트라 +// songs.artist 값으로 등장하는 일은 거의 없다 — 그래도 등장하면 공식 표기로 접어준다(방어적 정규화). +// "공식 표기 → 한국어 대표 표기(별칭 0번째)"는 translationJpn.ts와 동일하게 name_ko 1순위로 쓴다. +const aliasToOfficial = new Map(); +const officialToKo = new Map(); + +for (const [officialName, aliases] of Object.entries(artistAlias)) { + officialToKo.set(officialName, aliases[0]); + aliasToOfficial.set(officialName, officialName); + aliases.forEach(alias => aliasToOfficial.set(alias, officialName)); +} + +function resolveCanonicalName(rawArtist: string): string { + // 1) isInvalidArtist를 통과한(쉼표/&/feat/with/x-콜라보 없는) 원문에서 괄호만 제거 + // 2) 그 결과가 artistAlias에 등록된 원어 표기와 일치하면 공식 표기로 접어준다(방어적 정규화) + const primary = extractPrimaryArtist(rawArtist); + return aliasToOfficial.get(primary) ?? primary; +} + +interface ArtistGroup { + artistKoCounts: Map; +} + +// 미설정(최초 백필)이면 전체 곡, 설정하면 그 일수 이내 등록된 곡만(월간 증분 갱신) 대상으로 한다. +const sinceDaysEnv = process.env.ARTIST_BACKFILL_SINCE_DAYS; +const sinceIso = sinceDaysEnv ? subDays(new Date(), Number(sinceDaysEnv)).toISOString() : undefined; + +log(`\n===== 아티스트 백필 실행: ${new Date().toISOString()} =====`); +log( + sinceIso + ? `최근 ${sinceDaysEnv}일 이내 등록/수정된 곡만 대상으로 백필합니다.` + : '전체 곡을 대상으로 백필합니다.', +); + +const rows = await getSongsForArtistBackfillDB(sinceIso); +log(`대상 곡 수: ${rows.length}`); + +const groups = new Map(); +let invalidCount = 0; + +for (const row of rows) { + if (!row.artist) continue; + + // 쉼표/&·&/×·・/feat/with, 또는 " X " 콜라보 구분자가 있으면 다른 조건과 무관하게 백필 대상에서 제외한다. + if (isInvalidArtist(row.artist)) { + invalidCount++; + continue; + } + + const canonicalName = resolveCanonicalName(row.artist); + const group = groups.get(canonicalName) ?? { artistKoCounts: new Map() }; + + if (row.artist_ko) { + group.artistKoCounts.set(row.artist_ko, (group.artistKoCounts.get(row.artist_ko) ?? 0) + 1); + } + + groups.set(canonicalName, group); +} + +log(`무효 처리(쉼표/&·&/×·・/feat/with/x-콜라보 포함)로 제외한 곡 수: ${invalidCount}`); +log(`고유 아티스트 수: ${groups.size}`); + +const upsertRows: ArtistUpsert[] = [...groups.entries()].map(([name, group]) => { + // name_ko 우선순위: 1) artistAlias 큐레이션 값 2) DB artist_ko 중 가장 흔한 값 + const aliasKo = officialToKo.get(name); + const mostCommonArtistKo = [...group.artistKoCounts.entries()].sort( + (a, b) => b[1] - a[1], + )[0]?.[0]; + + return { + name, + name_ko: aliasKo ?? mostCommonArtistKo ?? null, + }; +}); + +const { upserted, failed } = await upsertArtistsDB(upsertRows); + +log(`아티스트 upsert 결과 — 성공: ${upserted}건, 실패: ${failed}건`); diff --git a/packages/crawling/src/cron/finalizeArtistOfMonth.ts b/packages/crawling/src/cron/finalizeArtistOfMonth.ts new file mode 100644 index 00000000..f15ce56c --- /dev/null +++ b/packages/crawling/src/cron/finalizeArtistOfMonth.ts @@ -0,0 +1,62 @@ +import fs from 'fs'; +import path from 'path'; + +import { getPrevMonthFirstDayKST, rankTopArtists } from '@repo/constants'; + +import { getArtistVotesByMonthDB } from '@/supabase/getDB'; +import { MonthlyRankingInsert, replaceMonthlyRankingsDB } from '@/supabase/postDB'; + +/** + * 이달의 아티스트 월간 확정. + * + * 원래는 웹앱의 API 라우트(`/api/artist-vote/finalize`)였다. 그 구조에서는 확정 트리거가 + * 인증 없는 공개 URL이라 공유 시크릿으로 잠가야 했고, 순위를 지우고 다시 쓰는 작업이 + * 외부에서 언제든 호출될 수 있었다. 여기로 옮기면서 그 문(門) 자체가 없어졌다 — + * 이제 확정은 이 워크플로 안에서만 일어나고, 시크릿도 필요 없다. + * + * "지금이 이번 달"이라는 전제로 지난달을 계산해 매번 같은 달을 덮어쓰므로, + * 하루에 여러 번 돌아도 결과가 같다(idempotent). 그래서 워크플로를 매일 돌려 + * cron 지연으로 1일 실행을 놓치는 상황에 대비할 수 있다. + */ + +const LOG_FILE = path.join('src', 'assets', 'artistFinalizeLog.txt'); + +function log(message: string) { + console.log(message); + fs.appendFileSync(LOG_FILE, message + '\n', 'utf-8'); +} + +const targetMonth = getPrevMonthFirstDayKST(); + +log(`\n===== 이달의 아티스트 확정 실행: ${new Date().toISOString()} =====`); +log(`대상 월: ${targetMonth}`); + +const votes = await getArtistVotesByMonthDB(targetMonth); +log(`투표 수: ${votes.length}건`); + +if (votes.length === 0) { + // 투표가 없으면 아무것도 하지 않는다. 여기서 기존 순위를 지우면, 투표 조회가 일시적으로 + // 비어 돌아온 날 이미 확정된 결과까지 날아간다. + log('투표가 없어 종료합니다 (기존 순위는 그대로 둔다).'); +} else { + const ranked = rankTopArtists(votes); + + const rows: MonthlyRankingInsert[] = ranked.map((entry, index) => ({ + vote_month: targetMonth, + rank: index + 1, + artist: entry.artist, + total_votes: entry.total, + top_voter_user_id: entry.topVoterUserId, + top_voter_amount: entry.topVoterAmount, + })); + + const saved = await replaceMonthlyRankingsDB(targetMonth, rows); + + log(`확정 완료 — ${saved}명 저장`); + log( + `시상대: ${rows + .slice(0, 3) + .map(row => `${row.rank}위 ${row.artist}(${row.total_votes}P)`) + .join(' · ')}`, + ); +} diff --git a/packages/crawling/src/cron/taggingSongs.ts b/packages/crawling/src/cron/taggingSongs.ts index c68e8c09..03c0f805 100644 --- a/packages/crawling/src/cron/taggingSongs.ts +++ b/packages/crawling/src/cron/taggingSongs.ts @@ -1,62 +1,65 @@ -import { getSongTagSongIdsDB, getSongsAllDB } from '@/supabase/getDB'; -import { postSongTagsDB } from '@/supabase/postDB'; -import { autoTagSong, getTagsForPrompt } from '@/utils/getSongTag'; - -const resultsLog = { - success: 0, - failed: 0, - skipped: 0, -}; - -// 1. 전체 곡 조회 + 이미 태그된 곡 ID + 태그 프롬프트 로드 -const [allSongs, taggedSongIds, tagsPrompt] = await Promise.all([ - getSongsAllDB(), - getSongTagSongIdsDB(), - getTagsForPrompt(), -]); - -console.log('전체 곡 수:', allSongs.length); -console.log('이미 태그된 곡 수:', taggedSongIds.size); - -let processedCount = 0; -for (const song of allSongs) { - if (processedCount >= 20000) break; - if (taggedSongIds.has(song.id)) { - resultsLog.skipped++; - continue; - } - - try { - const tagId = await autoTagSong(song.title, song.artist, tagsPrompt); - - if (tagId === null) { - resultsLog.failed++; - console.log(`[FAIL] ${song.title} - ${song.artist}: 태그 없음`); - continue; - } - - const success = await postSongTagsDB(song.id, [tagId]); - if (success) { - resultsLog.success++; - console.log(`[OK] ${song.title} - ${song.artist}: [${tagId}]`); - } else { - resultsLog.failed++; - } - } catch (error) { - resultsLog.failed++; - console.error(`[ERROR] ${song.title} - ${song.artist}:`, error); - } - - processedCount++; - - // OpenAI rate limit 대비 딜레이 - await new Promise(resolve => setTimeout(resolve, 200)); -} - -// 3. 결과 출력 -console.log(` - 총 ${allSongs.length}곡 중: - - 스킵 (이미 태그됨): ${resultsLog.skipped}곡 - - 성공: ${resultsLog.success}곡 - - 실패: ${resultsLog.failed}곡 -`); +// 태그 기능 비활성화로 전체 주석처리. tags/song_tags 테이블 자체는 남아있지만 +// 이 스크립트로 새로 태그를 등록하지는 않는다. +// +// import { getSongTagSongIdsDB, getSongsAllDB } from '@/supabase/getDB'; +// import { postSongTagsDB } from '@/supabase/postDB'; +// import { autoTagSong, getTagsForPrompt } from '@/utils/getSongTag'; +// +// const resultsLog = { +// success: 0, +// failed: 0, +// skipped: 0, +// }; +// +// // 1. 전체 곡 조회 + 이미 태그된 곡 ID + 태그 프롬프트 로드 +// const [allSongs, taggedSongIds, tagsPrompt] = await Promise.all([ +// getSongsAllDB(), +// getSongTagSongIdsDB(), +// getTagsForPrompt(), +// ]); +// +// console.log('전체 곡 수:', allSongs.length); +// console.log('이미 태그된 곡 수:', taggedSongIds.size); +// +// let processedCount = 0; +// for (const song of allSongs) { +// if (processedCount >= 20000) break; +// if (taggedSongIds.has(song.id)) { +// resultsLog.skipped++; +// continue; +// } +// +// try { +// const tagId = await autoTagSong(song.title, song.artist, tagsPrompt); +// +// if (tagId === null) { +// resultsLog.failed++; +// console.log(`[FAIL] ${song.title} - ${song.artist}: 태그 없음`); +// continue; +// } +// +// const success = await postSongTagsDB(song.id, [tagId]); +// if (success) { +// resultsLog.success++; +// console.log(`[OK] ${song.title} - ${song.artist}: [${tagId}]`); +// } else { +// resultsLog.failed++; +// } +// } catch (error) { +// resultsLog.failed++; +// console.error(`[ERROR] ${song.title} - ${song.artist}:`, error); +// } +// +// processedCount++; +// +// // OpenAI rate limit 대비 딜레이 +// await new Promise(resolve => setTimeout(resolve, 200)); +// } +// +// // 3. 결과 출력 +// console.log(` +// 총 ${allSongs.length}곡 중: +// - 스킵 (이미 태그됨): ${resultsLog.skipped}곡 +// - 성공: ${resultsLog.success}곡 +// - 실패: ${resultsLog.failed}곡 +// `); diff --git a/packages/crawling/src/cron/translationJpn.ts b/packages/crawling/src/cron/translationJpn.ts index 4acf47e5..feba8756 100644 --- a/packages/crawling/src/cron/translationJpn.ts +++ b/packages/crawling/src/cron/translationJpn.ts @@ -1,115 +1,118 @@ -import { artistAlias } from '@repo/constants'; - -import { getArtistKoMapDB, getJpopSongsForTranslationDB } from '@/supabase/getDB'; -import { updateSongKoTranslationDB } from '@/supabase/updateDB'; -import { translateJpnToKo } from '@/utils/translateJpnToKo'; - -const resultsLog = { - success: 0, - failed: 0, - skipped: 0, - usedAlias: 0, - usedDbArtist: 0, -}; - -// 히라가나, 카타카나, CJK 한자 범위로 일본어 포함 여부 판단 -const JAPANESE_REGEX = /[\u3040-\u309f\u30a0-\u30ff\uff00-\uff9f\u4e00-\u9faf\u3400-\u4dbf]/; - -function containsJapanese(text: string): boolean { - return JAPANESE_REGEX.test(text); -} - -// artistAlias 로부터 artist 원어 → 한국어 대표 표기(별명 배열의 0번째) 맵 생성 -const artistAliasMap = new Map( - Object.entries(artistAlias).map(([artist, aliases]) => [artist, aliases[0]]), -); - -const songs = await getJpopSongsForTranslationDB(); - -console.log('J-POP 곡 수:', songs.length); - -// DB 에 이미 번역된 artist → artist_ko 맵 (동일 아티스트 번역 일관성 유지 목적) -// DB 는 아티스트당 단일 artist_ko 로 정규화되어 있으며, 번역 성공 시 런타임에도 동기화한다 -const dbArtistKoMap = await getArtistKoMapDB(); - -let processedCount = 0; -for (const song of songs) { - if (processedCount >= 10000) break; - - // 이미 번역된 곡 스킵 - if (song.title_ko && song.artist_ko) { - resultsLog.skipped++; - continue; - } - - // 일본어가 포함되지 않은 곡 스킵 - // 영어로만 이루어진 경우 번역하지 않음 - if (!containsJapanese(song.title) && !containsJapanese(song.artist)) { - resultsLog.skipped++; - continue; - } - - try { - const result = await translateJpnToKo(song.title, song.artist); - - if (!result) { - resultsLog.failed++; - console.log(`[FAIL] ${song.title} - ${song.artist}: 번역 실패`); - continue; - } - - // artist_ko 우선순위: - // 1) artistAlias (수동 큐레이션된 고정 값) - // 2) DB 에 이미 번역된 동일 아티스트의 artist_ko (번역 일관성 유지) - // 3) AI 번역 결과 - // title_ko 는 AI 번역 결과를 그대로 사용 - const aliasArtistKo = artistAliasMap.get(song.artist); - const dbArtistKo = dbArtistKoMap.get(song.artist); - const finalArtistKo = aliasArtistKo ?? dbArtistKo ?? result.artist_ko; - - const success = await updateSongKoTranslationDB(song.id, result.title_ko, finalArtistKo); - if (!success) { - resultsLog.failed++; - continue; - } - - let logPrefix: string; - if (aliasArtistKo) { - resultsLog.usedAlias++; - logPrefix = '[ALIAS]'; - } else if (dbArtistKo) { - resultsLog.usedDbArtist++; - logPrefix = '[DB]'; - } else { - resultsLog.success++; - logPrefix = '[OK]'; - } - - // DB 업데이트 성공 시 런타임 맵도 동기화 (first-seen 원칙 — 기존 값 덮어쓰지 않음) - if (!dbArtistKoMap.has(song.artist)) { - dbArtistKoMap.set(song.artist, finalArtistKo); - } - - console.log( - `${logPrefix} ${song.title} → ${result.title_ko} / ${song.artist} → ${finalArtistKo}`, - ); - } catch (error) { - resultsLog.failed++; - console.error(`[ERROR] ${song.title} - ${song.artist}:`, error); - } - - processedCount++; - - // OpenAI rate limit 대비 딜레이 - await new Promise(resolve => setTimeout(resolve, 200)); -} - -// 결과 출력 -console.log(` - 총 ${songs.length}곡 중: - - 스킵 (이미 번역됨): ${resultsLog.skipped}곡 - - 성공 (AI 번역): ${resultsLog.success}곡 - - 성공 (artist_ko alias 적용): ${resultsLog.usedAlias}곡 - - 성공 (artist_ko DB 재사용): ${resultsLog.usedDbArtist}곡 - - 실패: ${resultsLog.failed}곡 -`); +// 태그 기능 비활성화로 전체 주석처리. 이 스크립트는 song_tags.tag_id=101(일본어)로 +// J-POP 곡을 찾는데, 태그 기능을 더 이상 쓰지 않으므로 함께 멈춘다. +// +// import { artistAlias } from '@repo/constants'; +// +// import { getArtistKoMapDB, getJpopSongsForTranslationDB } from '@/supabase/getDB'; +// import { updateSongKoTranslationDB } from '@/supabase/updateDB'; +// import { translateJpnToKo } from '@/utils/translateJpnToKo'; +// +// const resultsLog = { +// success: 0, +// failed: 0, +// skipped: 0, +// usedAlias: 0, +// usedDbArtist: 0, +// }; +// +// // 히라가나, 카타카나, CJK 한자 범위로 일본어 포함 여부 판단 +// const JAPANESE_REGEX = /[぀-ゟ゠-ヿ＀-゚一-龯㐀-䶿]/; +// +// function containsJapanese(text: string): boolean { +// return JAPANESE_REGEX.test(text); +// } +// +// // artistAlias 로부터 artist 원어 → 한국어 대표 표기(별명 배열의 0번째) 맵 생성 +// const artistAliasMap = new Map( +// Object.entries(artistAlias).map(([artist, aliases]) => [artist, aliases[0]]), +// ); +// +// const songs = await getJpopSongsForTranslationDB(); +// +// console.log('J-POP 곡 수:', songs.length); +// +// // DB 에 이미 번역된 artist → artist_ko 맵 (동일 아티스트 번역 일관성 유지 목적) +// // DB 는 아티스트당 단일 artist_ko 로 정규화되어 있으며, 번역 성공 시 런타임에도 동기화한다 +// const dbArtistKoMap = await getArtistKoMapDB(); +// +// let processedCount = 0; +// for (const song of songs) { +// if (processedCount >= 10000) break; +// +// // 이미 번역된 곡 스킵 +// if (song.title_ko && song.artist_ko) { +// resultsLog.skipped++; +// continue; +// } +// +// // 일본어가 포함되지 않은 곡 스킵 +// // 영어로만 이루어진 경우 번역하지 않음 +// if (!containsJapanese(song.title) && !containsJapanese(song.artist)) { +// resultsLog.skipped++; +// continue; +// } +// +// try { +// const result = await translateJpnToKo(song.title, song.artist); +// +// if (!result) { +// resultsLog.failed++; +// console.log(`[FAIL] ${song.title} - ${song.artist}: 번역 실패`); +// continue; +// } +// +// // artist_ko 우선순위: +// // 1) artistAlias (수동 큐레이션된 고정 값) +// // 2) DB 에 이미 번역된 동일 아티스트의 artist_ko (번역 일관성 유지) +// // 3) AI 번역 결과 +// // title_ko 는 AI 번역 결과를 그대로 사용 +// const aliasArtistKo = artistAliasMap.get(song.artist); +// const dbArtistKo = dbArtistKoMap.get(song.artist); +// const finalArtistKo = aliasArtistKo ?? dbArtistKo ?? result.artist_ko; +// +// const success = await updateSongKoTranslationDB(song.id, result.title_ko, finalArtistKo); +// if (!success) { +// resultsLog.failed++; +// continue; +// } +// +// let logPrefix: string; +// if (aliasArtistKo) { +// resultsLog.usedAlias++; +// logPrefix = '[ALIAS]'; +// } else if (dbArtistKo) { +// resultsLog.usedDbArtist++; +// logPrefix = '[DB]'; +// } else { +// resultsLog.success++; +// logPrefix = '[OK]'; +// } +// +// // DB 업데이트 성공 시 런타임 맵도 동기화 (first-seen 원칙 — 기존 값 덮어쓰지 않음) +// if (!dbArtistKoMap.has(song.artist)) { +// dbArtistKoMap.set(song.artist, finalArtistKo); +// } +// +// console.log( +// `${logPrefix} ${song.title} → ${result.title_ko} / ${song.artist} → ${finalArtistKo}`, +// ); +// } catch (error) { +// resultsLog.failed++; +// console.error(`[ERROR] ${song.title} - ${song.artist}:`, error); +// } +// +// processedCount++; +// +// // OpenAI rate limit 대비 딜레이 +// await new Promise(resolve => setTimeout(resolve, 200)); +// } +// +// // 결과 출력 +// console.log(` +// 총 ${songs.length}곡 중: +// - 스킵 (이미 번역됨): ${resultsLog.skipped}곡 +// - 성공 (AI 번역): ${resultsLog.success}곡 +// - 성공 (artist_ko alias 적용): ${resultsLog.usedAlias}곡 +// - 성공 (artist_ko DB 재사용): ${resultsLog.usedDbArtist}곡 +// - 실패: ${resultsLog.failed}곡 +// `); diff --git a/packages/crawling/src/supabase/getDB.ts b/packages/crawling/src/supabase/getDB.ts index fcdc56a2..09977d95 100644 --- a/packages/crawling/src/supabase/getDB.ts +++ b/packages/crawling/src/supabase/getDB.ts @@ -1,4 +1,6 @@ -import { TransSong } from '@/types'; +import { VoteRow } from '@repo/constants'; + +import { ArtistBackfillSongRow, ArtistImageTarget, TransSong } from '@/types'; import { containsJapanese } from '@/utils/parseString'; import { getClient } from './getClient'; @@ -152,6 +154,28 @@ export async function getArtistKoMapDB(): Promise> { return map; } +// 아티스트 백필용 조회. sinceIso가 있으면 그 시각 이후 등록되었거나 수정된 곡만 +// (월간 증분 갱신 — updated_at도 봐야 기존 곡의 artist 오타 수정 같은 걸 놓치지 않는다), +// 없으면 전체 곡을 대상으로 한다(최초 백필). +export async function getSongsForArtistBackfillDB( + sinceIso?: string, +): Promise { + const supabase = getClient(); + + let query = supabase.from('songs').select('artist, artist_ko').limit(200000); + + if (sinceIso) { + // sinceIso는 서버가 계산한 값이라 사용자 입력이 섞이지 않는다 — or() 필터 문자열 조립이 안전하다. + query = query.or(`created_at.gte.${sinceIso},updated_at.gte.${sinceIso}`); + } + + const { data, error } = await query; + + if (error) throw error; + + return data as ArtistBackfillSongRow[]; +} + export async function getSongTagSongIdsDB(): Promise> { const supabase = getClient(); @@ -187,3 +211,85 @@ export async function getSongsBadgeNullDB(limit: number = 1000, afterNumTj?: str return data; } + +/** + * 사진이 아직 비어 있는 아티스트를 이름순으로 가져온다. + * 이름순인 이유는 여러 번 나눠 돌려도 매번 같은 자리에서 이어지게 하기 위함이다. + */ +export async function getArtistsWithoutImageDB(limit: number): Promise { + const supabase = getClient(); + + const { data, error } = await supabase + .from('artists') + .select('name, name_ko') + .is('image_url', null) + .order('name', { ascending: true }) + .limit(limit); + + if (error) throw error; + + return data as ArtistImageTarget[]; +} + +/** + * 이름을 콕 집어 가져온다. 사진 백필을 특정 아티스트로 시험해 볼 때 쓴다. + * 이름순 조회는 한 글자 이름("건", "결"…)부터 걸려서 표본으로 삼기에 나쁘다. + */ +export async function getArtistsByNamesDB(names: string[]): Promise { + const supabase = getClient(); + + const { data, error } = await supabase + .from('artists') + .select('name, name_ko') + .in('name', names) + .is('image_url', null); + + if (error) throw error; + + return data as ArtistImageTarget[]; +} + +/** + * 가장 최근에 확정된 달의 상위 N명 이름을 가져온다. + * 사진이 실제로 쓰이는 자리가 시상대(1~3위)뿐이라, 월간 확정 직후 그 이름만 채우면 된다. + */ +export async function getLatestPodiumArtistNamesDB(topN: number): Promise { + const supabase = getClient(); + + const { data: latest, error: latestError } = await supabase + .from('monthly_artist_rankings') + .select('vote_month') + .order('vote_month', { ascending: false }) + .limit(1); + + if (latestError) throw latestError; + + const month = latest?.[0]?.vote_month as string | undefined; + if (!month) return []; + + const { data, error } = await supabase + .from('monthly_artist_rankings') + .select('artist') + .eq('vote_month', month) + .lte('rank', topN) + .order('rank', { ascending: true }); + + if (error) throw error; + + return (data ?? []).map(row => row.artist as string); +} + +/** 그달 투표 전체. 집계는 rankTopArtists(@repo/constants)가 한다. */ +export async function getArtistVotesByMonthDB(month: string): Promise { + const supabase = getClient(); + + const { data, error } = await supabase + .from('artist_votes') + .select('user_id, artist, amount, created_at') + .eq('vote_month', month) + .returns(); + + if (error) throw error; + + return data ?? []; +} diff --git a/packages/crawling/src/supabase/postDB.ts b/packages/crawling/src/supabase/postDB.ts index 953eb018..53cfcf4d 100644 --- a/packages/crawling/src/supabase/postDB.ts +++ b/packages/crawling/src/supabase/postDB.ts @@ -1,4 +1,4 @@ -import { LogData, Song, TjChartRankingInsert } from '@/types'; +import { ArtistUpsert, LogData, Song, TjChartRankingInsert } from '@/types'; import { getClient } from './getClient'; @@ -108,6 +108,25 @@ export async function postTjChartRankingsDB(rows: TjChartRankingInsert[]) { return true; } +export async function upsertArtistsDB(rows: ArtistUpsert[], chunkSize: number = 500) { + const supabase = getClient(); + + let upserted = 0; + for (let i = 0; i < rows.length; i += chunkSize) { + const chunk = rows.slice(i, i + chunkSize); + + const { error } = await supabase.from('artists').upsert(chunk, { onConflict: 'name' }); + + if (error) { + console.error('upsertArtistsDB error:', error); + continue; + } + upserted += chunk.length; + } + + return { upserted, failed: rows.length - upserted }; +} + export async function postInvalidKYSongsDB(song: Song) { const supabase = getClient(); @@ -126,3 +145,58 @@ export async function postInvalidKYSongsDB(song: Song) { return error; } } + +/** + * 아티스트 사진 URL을 채운다. + * 이미 채워진 행은 건드리지 않는다 — 손으로 골라 넣은 사진을 자동 백필이 덮어쓰면 안 된다. + */ +export async function updateArtistImageDB(name: string, imageUrl: string) { + const supabase = getClient(); + + const { error } = await supabase + .from('artists') + .update({ image_url: imageUrl }) + .eq('name', name) + .is('image_url', null); + + if (error) { + console.error('updateArtistImageDB error:', name, error); + return false; + } + return true; +} + +export interface MonthlyRankingInsert { + vote_month: string; + rank: number; + artist: string; + total_votes: number; + top_voter_user_id: string; + top_voter_amount: number; +} + +/** + * 그달 순위를 통째로 갈아끼운다. + * + * 지우고 넣는 사이에 트랜잭션이 없어 그 틈에 조회하면 결과가 비어 보인다. + * 이 스크립트가 하루 한 번, 단일 워크플로에서만 도는 전제라 그 틈을 감수한다 — + * 여러 곳에서 동시에 부를 수 있게 되면 그때는 RPC(단일 트랜잭션)로 바꿔야 한다. + */ +export async function replaceMonthlyRankingsDB(month: string, rows: MonthlyRankingInsert[]) { + const supabase = getClient(); + + const { error: deleteError } = await supabase + .from('monthly_artist_rankings') + .delete() + .eq('vote_month', month); + + if (deleteError) throw deleteError; + + if (rows.length === 0) return 0; + + const { error: insertError } = await supabase.from('monthly_artist_rankings').insert(rows); + + if (insertError) throw insertError; + + return rows.length; +} diff --git a/packages/crawling/src/types.ts b/packages/crawling/src/types.ts index 9b5fe7c3..c1c1badb 100644 --- a/packages/crawling/src/types.ts +++ b/packages/crawling/src/types.ts @@ -103,3 +103,19 @@ export interface TjChartRankingInsert { rank: number; song_id: string; } + +export interface ArtistBackfillSongRow { + artist: string; + artist_ko: string | null; +} + +/** 사진을 아직 못 채운 아티스트. 이미지 백필의 대상이다. */ +export interface ArtistImageTarget { + name: string; + name_ko: string | null; +} + +export interface ArtistUpsert { + name: string; + name_ko: string | null; +} diff --git a/packages/crawling/src/utils/extractPrimaryArtist.ts b/packages/crawling/src/utils/extractPrimaryArtist.ts new file mode 100644 index 00000000..8268aee5 --- /dev/null +++ b/packages/crawling/src/utils/extractPrimaryArtist.ts @@ -0,0 +1,3 @@ +// 아티스트 이름 정규화 규칙은 웹(이달의 아티스트 배지 매칭)과 공유해야 해서 +// @repo/constants로 옮겼다. 여기서는 기존 import 경로를 유지하기 위해 재수출만 한다. +export { extractPrimaryArtist, getPrimaryArtistName, isInvalidArtist } from '@repo/constants'; diff --git a/packages/crawling/src/utils/musicbrainz.ts b/packages/crawling/src/utils/musicbrainz.ts new file mode 100644 index 00000000..f918626e --- /dev/null +++ b/packages/crawling/src/utils/musicbrainz.ts @@ -0,0 +1,104 @@ +import axios from 'axios'; + +import { normalizeArtistName } from './normalizeArtistName'; + +/** + * MusicBrainz를 "이름 사전"으로만 쓴다. 사진은 여기서 가져오지 않는다. + * + * 우리 DB의 아티스트 이름은 TJ 표기(한글·원어)인데, 음원 서비스들은 같은 아티스트를 + * 로마자로 등록해 둔다("방탄소년단" ↔ "BTS", "米津玄師" ↔ "Kenshi Yonezu"). + * 이 표기 차이 때문에 이름이 정확히 일치하는 항목을 못 찾아 사진 백필이 통째로 비었다. + * MusicBrainz는 그 대응표(별칭)를 공개해 두고 있어, 여기서 이름만 받아 오면 + * 사진은 원래 쓰던 곳에서 정확한 이름으로 찾을 수 있다. + */ + +const ENDPOINT = 'https://musicbrainz.org/ws/2'; +// MusicBrainz는 앱을 식별할 수 있는 User-Agent를 요구한다. 없으면 차단당한다. +const USER_AGENT = 'singcode-crawler/1.0 (https://singcode.kr)'; +// 익명 사용자에게 허용되는 건 초당 1회다. 여유를 조금 둔다. +const REQUEST_INTERVAL_MS = 1100; +const MAX_RETRY = 4; + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +interface MbSearchArtist { + id: string; + name: string; + score: number; + aliases?: { name: string }[]; +} + +interface MbArtistDetail { + name: string; + aliases?: { name: string }[]; +} + +/** + * 서버가 붐빌 때 503을 자주 돌려준다. 재시도 간격을 늘려가며 몇 번 더 두드린다. + * 503이 아닌 에러(잘못된 질의 등)는 재시도해도 결과가 같으니 그대로 던진다. + */ +async function request(path: string, params: Record): Promise { + for (let attempt = 1; ; attempt++) { + try { + const { data } = await axios.get(`${ENDPOINT}${path}`, { + params, + headers: { 'User-Agent': USER_AGENT }, + timeout: 15000, + }); + return data; + } catch (error) { + const status = axios.isAxiosError(error) ? error.response?.status : undefined; + if (status !== 503 || attempt >= MAX_RETRY) throw error; + await sleep(2000 * attempt); + } + } +} + +export interface ArtistNameSet { + /** MusicBrainz가 쓰는 대표 표기. 보통 로마자다. */ + canonicalName: string; + /** 대표 표기 + 모든 별칭 */ + names: string[]; +} + +/** + * 이름으로 MusicBrainz 아티스트를 찾아 그 아티스트의 모든 표기를 돌려준다. + * + * 질의에 alias 필드를 함께 넣는 게 핵심이다. artist 필드만 보면 대표 표기가 로마자인 + * 아티스트("방탄소년단" → 대표 표기 BTS)는 아예 검색되지 않는다. + * + * 받아들이는 조건은 하나다 — 그 아티스트가 **우리가 넘긴 이름을 표기 중 하나로 그대로 갖고 + * 있을 것**. 점수가 높다는 이유로 받으면 동명이인·유사명이 섞여 들어오고, 그 뒤 단계는 + * 잘못된 이름으로 정확히 일치하는 사진을 찾아 오므로 아무도 오류를 눈치채지 못한다. + */ +export async function getArtistNameSet(name: string): Promise { + const target = normalizeArtistName(name); + + const search = await request<{ artists?: MbSearchArtist[] }>('/artist', { + query: `artist:"${name}" OR alias:"${name}"`, + fmt: 'json', + limit: 5, + }); + + const hit = (search.artists ?? []).find( + artist => + normalizeArtistName(artist.name) === target || + (artist.aliases ?? []).some(alias => normalizeArtistName(alias.name) === target), + ); + + if (!hit) return null; + + await sleep(REQUEST_INTERVAL_MS); + + // 검색 응답의 aliases는 질의에 걸린 것만 담겨 오는 일이 있어, 상세로 한 번 더 받는다. + const detail = await request(`/artist/${hit.id}`, { + inc: 'aliases', + fmt: 'json', + }); + + const names = [detail.name, ...(detail.aliases ?? []).map(alias => alias.name)]; + + return { canonicalName: detail.name, names: [...new Set(names)] }; +} + +export const MUSICBRAINZ_INTERVAL_MS = REQUEST_INTERVAL_MS; diff --git a/packages/crawling/src/utils/normalizeArtistName.ts b/packages/crawling/src/utils/normalizeArtistName.ts new file mode 100644 index 00000000..1d079315 --- /dev/null +++ b/packages/crawling/src/utils/normalizeArtistName.ts @@ -0,0 +1,11 @@ +/** + * 아티스트 이름 비교용 정규화. + * NFKC로 전각·반각을 한 모양으로 접고, 대소문자와 공백·구두점을 지운다. + * "BIGBANG"과 "Big Bang", "IU"와 "IU"는 같은 이름으로 봐야 하지만, + * 여기서 더 느슨하게 풀면(예: 부분 일치) 엉뚱한 사람 사진이 조용히 들어온다. + */ +export const normalizeArtistName = (name: string) => + name + .normalize('NFKC') + .toLowerCase() + .replace(/[\s.,'"·・&&!?()[\]{}-]/g, ''); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21c64c48..e55855ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -189,6 +189,9 @@ importers: react-joyride: specifier: 3.2.0 version: 3.2.0(@types/react@19.0.10)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + recharts: + specifier: ^3.10.1 + version: 3.10.1(@types/react@19.0.10)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1) serwist: specifier: ^9.5.7 version: 9.5.7(browserslist@4.28.1)(typescript@5.9.3) @@ -243,6 +246,10 @@ importers: version: 5.9.3 packages/constants: + dependencies: + date-fns: + specifier: ^4.1.0 + version: 4.1.0 devDependencies: '@types/node': specifier: ^22.13.10 @@ -1218,8 +1225,8 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@maxim_mazurok/gapi.client.androidpublisher-v3@0.5.20260811': - resolution: {integrity: sha512-5LSWplF7CXqG2KNfgka2s8QWpQkpBwiPgxTjpar7jJcPJn9PrqYrCJSZhGZPt1NayVM2ERm73cUL/IdpDtNJnQ==} + '@maxim_mazurok/gapi.client.androidpublisher-v3@0.5.20260820': + resolution: {integrity: sha512-OXrVp1H+OubRfY+Omsv8CudkO9qjIReCGqM8RxeMbTVtYKEe3ddqBheUS4iEur1q3aLGgHcQphCF5FmUT0S9Zg==} '@maxim_mazurok/gapi.client.discovery-v1@0.6.20200806': resolution: {integrity: sha512-QN6aGmIbLXcJW0SEf8tz9UWCpQTBMy2u4xSuZ04YofzMGknhWwMeUecySLntCZYAEstHzDoSgwxCf5Gb8GqIRQ==} @@ -2049,6 +2056,17 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@reduxjs/toolkit@2.12.0': + resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@resvg/resvg-js-android-arm-eabi@2.6.2': resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==} engines: {node: '>= 10'} @@ -2286,6 +2304,12 @@ packages: typescript: optional: true + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@supabase/auth-js@2.89.0': resolution: {integrity: sha512-wiWZdz8WMad8LQdJMWYDZ2SJtZP5MwMqzQq3ehtW2ngiI3UTgbKiFrvMUUS3KADiVlk4LiGfODB2mrYx7w2f8w==} engines: {node: '>=20.0.0'} @@ -2484,6 +2508,33 @@ packages: '@types/color@3.0.7': resolution: {integrity: sha512-e/ecxz2EGJhUEaoeFa9Xx6ayIkBFQ5bwKgOWzmrEtP+ApYB5ou+eOB71DasFsn3qCKzIPrbi/NKdM8lI0tnGUQ==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.2.0': + resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -2575,6 +2626,9 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/valid-url@1.0.7': resolution: {integrity: sha512-tgsWVG80dM5PVEBSbXUttPJTBCOo0IKbBh4R4z/SHsC5C81A3aaUH4fsbj+JYk7fopApU/Mao1c0EWTE592TSg==} @@ -3177,6 +3231,50 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + data-uri-to-buffer@6.0.2: resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} engines: {node: '>= 14'} @@ -3219,6 +3317,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -3380,6 +3481,9 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + es-toolkit@1.51.0: + resolution: {integrity: sha512-zC2lQGkM7QX+Gm6iM3+WIdZJzthsEd14LvRNJneSO2hzyz/zNBENR8+YXWo1cKxgPBtV6ksPYHELbcwBRzmdCw==} + esbuild@0.27.2: resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} @@ -3481,6 +3585,9 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + events-universal@1.0.1: resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} @@ -3883,6 +3990,9 @@ packages: immer@10.2.0: resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + immer@11.1.18: + resolution: {integrity: sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -3925,6 +4035,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + ip-address@10.1.0: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} @@ -4944,6 +5058,18 @@ packages: react: 16.8 - 19 react-dom: 16.8 - 19 + react-redux@9.3.0: + resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -4997,6 +5123,22 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + recharts@3.10.1: + resolution: {integrity: sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -5022,6 +5164,9 @@ packages: requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + reselect@5.2.0: + resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -5397,6 +5542,9 @@ packages: timm@1.7.1: resolution: {integrity: sha512-IjZc9KIotudix8bMaBW6QvMuq64BrJWFs1+4V0lXwWGQZwH+LnX87doAYhem4caOEusRP9/g6jVDQmZ8XOk1nw==} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinycolor2@1.6.0: resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} @@ -5687,6 +5835,9 @@ packages: resolution: {integrity: sha512-YuKoXDAhBYxY7SfOKxHBDoSyENFeW5VvIIQp2TGQuit8gpK6MnWaQelBKxso72DoxTZfZdcP3W90LqpSkgPzLQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} @@ -6655,7 +6806,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@maxim_mazurok/gapi.client.androidpublisher-v3@0.5.20260811': + '@maxim_mazurok/gapi.client.androidpublisher-v3@0.5.20260820': dependencies: '@types/gapi.client': 1.0.8 '@types/gapi.client.discovery-v1': 0.0.4 @@ -7521,6 +7672,18 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.0.10)(react@19.2.3)(redux@5.0.1))(react@19.2.3)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.18 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.2.0 + optionalDependencies: + react: 19.2.3 + react-redux: 9.3.0(@types/react@19.0.10)(react@19.2.3)(redux@5.0.1) + '@resvg/resvg-js-android-arm-eabi@2.6.2': optional: true @@ -7694,6 +7857,10 @@ snapshots: transitivePeerDependencies: - browserslist + '@standard-schema/spec@1.1.0': {} + + '@standard-schema/utils@0.3.0': {} + '@supabase/auth-js@2.89.0': dependencies: tslib: 2.8.1 @@ -7901,13 +8068,37 @@ snapshots: dependencies: '@types/color-convert': 1.9.0 + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.2.0': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/estree@1.0.8': {} '@types/extract-zip@1.6.2': {} '@types/gapi.client.androidpublisher@3.0.2': dependencies: - '@maxim_mazurok/gapi.client.androidpublisher-v3': 0.5.20260811 + '@maxim_mazurok/gapi.client.androidpublisher-v3': 0.5.20260820 '@types/gapi.client.discovery-v1@0.0.4': dependencies: @@ -7992,6 +8183,8 @@ snapshots: '@types/trusted-types@2.0.7': {} + '@types/use-sync-external-store@0.0.6': {} + '@types/valid-url@1.0.7': {} '@types/ws@8.18.1': @@ -8628,6 +8821,44 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + data-uri-to-buffer@6.0.2: {} data-view-buffer@1.0.2: @@ -8662,6 +8893,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + deep-extend@0.6.0: {} deep-is@0.1.4: {} @@ -8892,6 +9125,8 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es-toolkit@1.51.0: {} + esbuild@0.27.2: optionalDependencies: '@esbuild/aix-ppc64': 0.27.2 @@ -9045,6 +9280,8 @@ snapshots: event-target-shim@5.0.1: {} + eventemitter3@5.0.4: {} + events-universal@1.0.1: dependencies: bare-events: 2.8.2 @@ -9537,6 +9774,8 @@ snapshots: immer@10.2.0: {} + immer@11.1.18: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -9632,6 +9871,8 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + internmap@2.0.3: {} + ip-address@10.1.0: {} is-array-buffer@3.0.5: @@ -10657,6 +10898,15 @@ snapshots: transitivePeerDependencies: - '@types/react' + react-redux@9.3.0(@types/react@19.0.10)(react@19.2.3)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.3 + use-sync-external-store: 1.6.0(react@19.2.3) + optionalDependencies: + '@types/react': 19.0.10 + redux: 5.0.1 + react-remove-scroll-bar@2.3.8(@types/react@19.0.10)(react@19.2.3): dependencies: react: 19.2.3 @@ -10716,6 +10966,32 @@ snapshots: readdirp@4.1.2: {} + recharts@3.10.1(@types/react@19.0.10)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.0.10)(react@19.2.3)(redux@5.0.1))(react@19.2.3) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.51.0 + eventemitter3: 5.0.4 + immer: 11.1.18 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-is: 16.13.1 + react-redux: 9.3.0(@types/react@19.0.10)(react@19.2.3)(redux@5.0.1) + reselect: 5.2.0 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.3) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -10751,6 +11027,8 @@ snapshots: requires-port@1.0.0: {} + reselect@5.2.0: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -11238,6 +11516,8 @@ snapshots: timm@1.7.1: {} + tiny-invariant@1.3.3: {} + tinycolor2@1.6.0: {} tinyexec@0.3.2: {} @@ -11544,6 +11824,23 @@ snapshots: dependencies: builtins: 5.1.0 + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.2.0 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + wcwidth@1.0.1: dependencies: defaults: 1.0.4 diff --git a/turbo.json b/turbo.json index 35967c45..8b4fadb7 100644 --- a/turbo.json +++ b/turbo.json @@ -7,7 +7,6 @@ "NEXT_PUBLIC_SUPABASE_ANON_KEY", "SUPABASE_URL", "SUPABASE_ANON_KEY", - "SUPABASE_SERVICE_ROLE_KEY", "ADMIN_USER_ID", "KAKAO_REST_API_KEY", "KAKAO_SECRET_KEY", @@ -23,7 +22,12 @@ "TJ_CONCURRENCY", "TJ_DRY_RUN", "TJ_RESET_PROGRESS", - "DEAD_SONGS_APPLY" + "DEAD_SONGS_APPLY", + "ARTIST_BACKFILL_SINCE_DAYS", + "ARTIST_IMAGE_LIMIT", + "ARTIST_IMAGE_NAMES", + "ARTIST_IMAGE_PODIUM", + "ARTIST_IMAGE_DRY_RUN" ], "tasks": { "build": {