From 6fc0949a6416c850d7fe77ffd81a3089d10745a9 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Fri, 27 Mar 2026 23:31:59 +0900 Subject: [PATCH 01/10] =?UTF-8?q?refactor=20:=20=ED=81=AC=EB=A1=A4?= =?UTF-8?q?=EB=A7=81=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20crawling=20?= =?UTF-8?q?=E2=86=92=20cron=20=EB=94=94=EB=A0=89=ED=86=A0=EB=A6=AC=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20(#173)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/crawling/package.json | 7 ++++--- packages/crawling/src/{crawling => cron}/crawlRecentTJ.ts | 0 packages/crawling/src/{crawling => cron}/crawlYoutube.ts | 2 +- .../crawling/src/{crawling => cron}/crawlYoutubeVerify.ts | 5 ++--- 4 files changed, 7 insertions(+), 7 deletions(-) rename packages/crawling/src/{crawling => cron}/crawlRecentTJ.ts (100%) rename packages/crawling/src/{crawling => cron}/crawlYoutube.ts (98%) rename packages/crawling/src/{crawling => cron}/crawlYoutubeVerify.ts (90%) diff --git a/packages/crawling/package.json b/packages/crawling/package.json index e81f2ffc..48a19071 100644 --- a/packages/crawling/package.json +++ b/packages/crawling/package.json @@ -8,10 +8,11 @@ }, "scripts": { "ky-open": "tsx src/findKYByOpen.ts", - "ky-youtube": "tsx src/crawling/crawlYoutube.ts", - "ky-verify": "tsx src/crawling/crawlYoutubeVerify.ts", + "ky-youtube": "tsx src/cron/crawlYoutube.ts", + "ky-verify": "tsx src/cron/crawlYoutubeVerify.ts", "ky-update": "pnpm run ky-youtube & pnpm run ky-verify", - "recent-tj": "tsx src/crawling/crawlRecentTJ.ts", + "recent-tj": "tsx src/cron/crawlRecentTJ.ts", + "tag-songs": "tsx src/cron/taggingSongs.ts", "lint": "eslint .", "test": "vitest run", "format": "prettier --write \"**/*.{ts,tsx,md}\"" diff --git a/packages/crawling/src/crawling/crawlRecentTJ.ts b/packages/crawling/src/cron/crawlRecentTJ.ts similarity index 100% rename from packages/crawling/src/crawling/crawlRecentTJ.ts rename to packages/crawling/src/cron/crawlRecentTJ.ts diff --git a/packages/crawling/src/crawling/crawlYoutube.ts b/packages/crawling/src/cron/crawlYoutube.ts similarity index 98% rename from packages/crawling/src/crawling/crawlYoutube.ts rename to packages/crawling/src/cron/crawlYoutube.ts index 9ce215ef..39d8d979 100644 --- a/packages/crawling/src/crawling/crawlYoutube.ts +++ b/packages/crawling/src/cron/crawlYoutube.ts @@ -6,7 +6,7 @@ import { postInvalidKYSongsDB } from '@/supabase/postDB'; import { updateSongsKyDB } from '@/supabase/updateDB'; import { Song } from '@/types'; -import { isValidKYExistNumber } from './isValidKYExistNumber'; +import { isValidKYExistNumber } from '../crawling/isValidKYExistNumber'; // --- Constants --- const BASE_YOUTUBE_SEARCH_URL = 'https://www.youtube.com/@KARAOKEKY/search'; diff --git a/packages/crawling/src/crawling/crawlYoutubeVerify.ts b/packages/crawling/src/cron/crawlYoutubeVerify.ts similarity index 90% rename from packages/crawling/src/crawling/crawlYoutubeVerify.ts rename to packages/crawling/src/cron/crawlYoutubeVerify.ts index d4c6ea94..6dc0ee68 100644 --- a/packages/crawling/src/crawling/crawlYoutubeVerify.ts +++ b/packages/crawling/src/cron/crawlYoutubeVerify.ts @@ -4,7 +4,7 @@ import { getSongsKyNotNullDB, getVerifyKySongsDB } from '@/supabase/getDB'; import { postVerifyKySongsDB } from '@/supabase/postDB'; import { updateSongsKyDB } from '@/supabase/updateDB'; -import { isValidKYExistNumber } from './isValidKYExistNumber'; +import { isValidKYExistNumber } from '../crawling/isValidKYExistNumber'; // 기존에 등록된 KY 노래방 번호가 실제로 KY 노래방과 일치하는지 검증 // 유효한 곡은 verify_ky_songs 테이블에 insert @@ -44,9 +44,8 @@ for (const song of data) { } index++; - console.log('crawlYoutubeVerify : ', index); - if (index >= 2000) break; + if (index >= 5000) break; } browser.close(); From 466cc2f5b1951608bdacd09cd963b7a6e5c000c7 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Fri, 27 Mar 2026 23:32:25 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat=20:=20AI=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EA=B3=A1=20=ED=83=9C=EA=B7=B8=20=EC=9E=90=EB=8F=99=20=EC=B6=94?= =?UTF-8?q?=EC=B6=9C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20(#173)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/tagging_song.yml | 43 ++++++++++ packages/crawling/src/cron/taggingSongs.ts | 59 ++++++++++++++ packages/crawling/src/supabase/getDB.ts | 24 ++++++ packages/crawling/src/supabase/postDB.ts | 12 +++ packages/crawling/src/utils/getSongTag.ts | 92 ++++++++++++++++++++++ 5 files changed, 230 insertions(+) create mode 100644 .github/workflows/tagging_song.yml create mode 100644 packages/crawling/src/cron/taggingSongs.ts create mode 100644 packages/crawling/src/utils/getSongTag.ts diff --git a/.github/workflows/tagging_song.yml b/.github/workflows/tagging_song.yml new file mode 100644 index 00000000..10b51111 --- /dev/null +++ b/.github/workflows/tagging_song.yml @@ -0,0 +1,43 @@ +name: Tagging Songs + +on: + schedule: + - cron: "0 14 * * *" # 한국 시간 23:00 실행 (UTC+9 → UTC 14:00) + workflow_dispatch: + +permissions: + contents: write # push 권한을 위해 필요 + +jobs: + run-npm-task: + runs-on: ubuntu-latest + + steps: + - name: Checkout branch + 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 + echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}" >> .env + + - name: run tagging script - taggingSongs.ts + working-directory: packages/crawling + run: pnpm run tag-songs diff --git a/packages/crawling/src/cron/taggingSongs.ts b/packages/crawling/src/cron/taggingSongs.ts new file mode 100644 index 00000000..2eeb7f5d --- /dev/null +++ b/packages/crawling/src/cron/taggingSongs.ts @@ -0,0 +1,59 @@ +import { getSongTagSongIdsDB, getSongsAllDB } from '@/supabase/getDB'; +import { postSongTagsDB } from '@/supabase/postDB'; +import { autoTagSong } from '@/utils/getSongTag'; + +const resultsLog = { + success: 0, + failed: 0, + skipped: 0, +}; + +// 1. 전체 곡 조회 + 이미 태그된 곡 ID 로드 +const [allSongs, taggedSongIds] = await Promise.all([getSongsAllDB(), getSongTagSongIdsDB()]); + +console.log('전체 곡 수:', allSongs.length); +console.log('이미 태그된 곡 수:', taggedSongIds.size); + +// 2. 순차 순회 (테스트: 5회만 실행) +let processedCount = 0; +for (const song of allSongs) { + if (processedCount >= 5000) break; + if (taggedSongIds.has(song.id)) { + resultsLog.skipped++; + continue; + } + + try { + const tagIds = await autoTagSong(song.title, song.artist); + + if (tagIds.length === 0) { + resultsLog.failed++; + console.log(`[FAIL] ${song.title} - ${song.artist}: 태그 없음`); + continue; + } + + const success = await postSongTagsDB(song.id, tagIds); + if (success) { + resultsLog.success++; + console.log(`[OK] ${song.title} - ${song.artist}: [${tagIds.join(', ')}]`); + } 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/supabase/getDB.ts b/packages/crawling/src/supabase/getDB.ts index 81c7a3c2..2cb56555 100644 --- a/packages/crawling/src/supabase/getDB.ts +++ b/packages/crawling/src/supabase/getDB.ts @@ -84,3 +84,27 @@ export async function getVerifyKySongsDB(): Promise> { return new Set(data.map(row => row.id)); } + +export async function getSongsAllDB(max: number = 50000) { + const supabase = getClient(); + + const { data, error } = await supabase + .from('songs') + .select('id, title, artist') + .order('created_at', { ascending: false }) + .limit(max); + + if (error) throw error; + + return data; +} + +export async function getSongTagSongIdsDB(): Promise> { + const supabase = getClient(); + + const { data, error } = await supabase.from('song_tags').select('song_id').limit(50000); + + if (error) throw error; + + return new Set(data.map(row => row.song_id)); +} diff --git a/packages/crawling/src/supabase/postDB.ts b/packages/crawling/src/supabase/postDB.ts index d53f82d2..23417aec 100644 --- a/packages/crawling/src/supabase/postDB.ts +++ b/packages/crawling/src/supabase/postDB.ts @@ -52,6 +52,18 @@ export async function postVerifyKySongsDB(song: Song) { } } +export async function postSongTagsDB(songId: string, tagIds: number[]) { + const supabase = getClient(); + const rows = tagIds.map(tagId => ({ song_id: songId, tag_id: tagId })); + + const { error } = await supabase.from('song_tags').insert(rows); + if (error) { + console.error('postSongTagsDB error:', error); + return false; + } + return true; +} + export async function postInvalidKYSongsDB(song: Song) { const supabase = getClient(); diff --git a/packages/crawling/src/utils/getSongTag.ts b/packages/crawling/src/utils/getSongTag.ts new file mode 100644 index 00000000..49998464 --- /dev/null +++ b/packages/crawling/src/utils/getSongTag.ts @@ -0,0 +1,92 @@ +import OpenAI from 'openai'; +import dotenv from 'dotenv'; + +import { getClient } from '@/supabase/getClient'; + +dotenv.config(); + +const client = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY, +}); + +// 태그 정보를 담을 타입 정의 +interface Tag { + id: number; + name: string; + category: string; +} + +let cachedTagsPrompt: string | null = null; + +/** + * DB에서 전체 태그 목록을 읽어와 AI 프롬프트용 텍스트로 변환한다. + */ +const getTagsForPrompt = async (): Promise => { + if (cachedTagsPrompt) return cachedTagsPrompt; + + const supabase = getClient(); + const { data: tags, error } = await supabase + .from('tags') + .select('id, name, category') + .order('id'); + + if (error) { + console.error('Error fetching tags:', error); + return ''; + } + + // AI가 읽기 편하게 "ID: 이름 (카테고리)" 형식으로 변환 + cachedTagsPrompt = tags.map((tag: Tag) => `${tag.id}: ${tag.name} (${tag.category})`).join('\n'); + return cachedTagsPrompt; +}; + +/** + * AI를 활용해 노래에 적절한 태그 ID들을 추출한다. + */ +export const autoTagSong = async (title: string, artist: string): Promise => { + try { + // 1단계: 프롬프트용 태그 리스트 준비 + const tagsPrompt = await getTagsForPrompt(); + if (!tagsPrompt) return []; + + // 2단계: OpenAI API 호출 + const response = await client.chat.completions.create({ + model: 'gpt-4o-mini', // 가성비가 좋은 모델 사용 + messages: [ + { + role: 'system', + content: ` + You are a music database expert. Based on the song title and artist, categorize the song by selecting appropriate tag IDs from the provided list. + + Guidelines: + 1. Select at least one tag, but no more than 4. + 2. Prioritize Language (100s), then Genre (200s), then Origin (300s). + 3. If it's Japanese music, ALWAYS include 101 (J-POP). + 4. Be precise. If it's from an Anime, use 302 (애니메이션). + 5. Return only JSON: {"tag_ids": [number, number, ...]} + + Allowed Tags List: + ${tagsPrompt} + `, + }, + { + role: 'user', + content: `Title: "${title}", Artist: "${artist}"`, + }, + ], + response_format: { type: 'json_object' }, + temperature: 0, // 결과의 일관성을 위해 0으로 설정 + max_tokens: 50, // 결과가 짧으므로 토큰 제한 + }); + + const content = response.choices[0].message.content; + if (!content) return []; + + // 3단계: 결과 파싱 및 반환 + const result: { tag_ids: number[] } = JSON.parse(content); + return result.tag_ids; + } catch (error) { + console.error('Error auto-tagging song:', error); + return []; + } +}; From 3023c139028b431bd5605cef102d969386bbddec Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Fri, 27 Mar 2026 23:32:50 +0900 Subject: [PATCH 03/10] =?UTF-8?q?chore=20:=20=EC=9B=8C=ED=81=AC=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=EC=9A=B0=20step=20name=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EB=B0=8F=20CLAUDE.md=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?(#173)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/crawl_recent_tj.yml | 2 +- .github/workflows/update_ky_youtube.yml | 2 +- .github/workflows/verify_ky_youtube.yml | 2 +- CLAUDE.md | 2 +- apps/web/public/sitemap-0.xml | 2 +- apps/web/src/app/api/search/route.ts | 8 +++--- apps/web/src/app/api/songs/thumb-up/route.ts | 4 +-- packages/crawling/CLAUDE.md | 27 ++++++++++++++++++++ 8 files changed, 38 insertions(+), 11 deletions(-) diff --git a/.github/workflows/crawl_recent_tj.yml b/.github/workflows/crawl_recent_tj.yml index 8e612639..94ce3c06 100644 --- a/.github/workflows/crawl_recent_tj.yml +++ b/.github/workflows/crawl_recent_tj.yml @@ -33,6 +33,6 @@ jobs: echo "SUPABASE_URL=${{ secrets.SUPABASE_URL }}" >> .env echo "SUPABASE_KEY=${{ secrets.SUPABASE_KEY }}" >> .env - - name: run crawl script + - name: run crawl script - crawlRecentTJ.ts working-directory: packages/crawling run: pnpm run recent-tj diff --git a/.github/workflows/update_ky_youtube.yml b/.github/workflows/update_ky_youtube.yml index 83ddbe76..4354cfbf 100644 --- a/.github/workflows/update_ky_youtube.yml +++ b/.github/workflows/update_ky_youtube.yml @@ -38,6 +38,6 @@ jobs: echo "SUPABASE_KEY=${{ secrets.SUPABASE_KEY }}" >> .env echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}" >> .env - - name: run update script - packages/crawling/crawlYoutube.ts + - name: run update script - crawlYoutube.ts working-directory: packages/crawling run: pnpm run ky-youtube diff --git a/.github/workflows/verify_ky_youtube.yml b/.github/workflows/verify_ky_youtube.yml index 2d9f4fe2..e65f7e86 100644 --- a/.github/workflows/verify_ky_youtube.yml +++ b/.github/workflows/verify_ky_youtube.yml @@ -38,6 +38,6 @@ jobs: echo "SUPABASE_KEY=${{ secrets.SUPABASE_KEY }}" >> .env echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}" >> .env - - name: run verify script - packages/crawling + - name: run verify script - crawlYoutubeVerify.ts working-directory: packages/crawling run: pnpm run ky-verify diff --git a/CLAUDE.md b/CLAUDE.md index ad72a5af..c5e5abb1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,7 @@ packages/ eslint-config/ — Shared ESLint config (@repo/eslint-config) format-config/ — Shared Prettier config (@repo/format-config) typescript-config/ — Shared tsconfig bases - crawling/ — One-off data crawling scripts (not a published package) + crawling/ — Data crawling & tagging scripts (see packages/crawling/CLAUDE.md) ``` ## Web App Architecture diff --git a/apps/web/public/sitemap-0.xml b/apps/web/public/sitemap-0.xml index 73fd5f7b..c905ea80 100644 --- a/apps/web/public/sitemap-0.xml +++ b/apps/web/public/sitemap-0.xml @@ -1,4 +1,4 @@ -https://www.singcode.kr2026-03-25T14:32:28.966Zweekly0.7 +https://www.singcode.kr2026-03-27T14:29:45.638Zweekly0.7 \ No newline at end of file diff --git a/apps/web/src/app/api/search/route.ts b/apps/web/src/app/api/search/route.ts index 7b86bd36..1cb66f5e 100644 --- a/apps/web/src/app/api/search/route.ts +++ b/apps/web/src/app/api/search/route.ts @@ -6,9 +6,11 @@ import { SearchSong, Song } from '@/types/song'; import { getAuthenticatedUser } from '@/utils/getAuthenticatedUser'; interface DBSong extends Song { - thumb_logs: { - thumb_count: number; - }[] | null; + thumb_logs: + | { + thumb_count: number; + }[] + | null; tosings: { user_id: string; }[]; diff --git a/apps/web/src/app/api/songs/thumb-up/route.ts b/apps/web/src/app/api/songs/thumb-up/route.ts index d8a20614..db5d6a67 100644 --- a/apps/web/src/app/api/songs/thumb-up/route.ts +++ b/apps/web/src/app/api/songs/thumb-up/route.ts @@ -30,9 +30,7 @@ export async function GET(): Promise>> { } // 3) 상위 50개 song_id 추출 - const sorted = [...thumbMap.entries()] - .sort((a, b) => b[1] - a[1]) - .slice(0, 50); + const sorted = [...thumbMap.entries()].sort((a, b) => b[1] - a[1]).slice(0, 50); const songIds = sorted.map(([songId]) => songId); diff --git a/packages/crawling/CLAUDE.md b/packages/crawling/CLAUDE.md index 7136ffce..3bdf7d00 100644 --- a/packages/crawling/CLAUDE.md +++ b/packages/crawling/CLAUDE.md @@ -13,6 +13,8 @@ pnpm ky-open # Open API(금영)로 KY 번호 수집 pnpm ky-youtube # YouTube 크롤링으로 KY 번호 수집 + AI 검증 pnpm ky-verify # 기존 KY 번호의 실제 존재 여부 재검증 (체크포인트 지원) pnpm ky-update # ky-youtube + ky-verify 병렬 실행 +pnpm recent-tj # TJ 최신곡 크롤링 +pnpm tag-songs # AI 기반 곡 자동 태깅 pnpm test # vitest 실행 pnpm lint # ESLint ``` @@ -94,8 +96,33 @@ findKYByOpen.ts | ------------------ | -------------------------------- | | `songs` | 메인 곡 데이터 (TJ/KY 번호 포함) | | `invalid_ky_songs` | KY 번호 수집 실패 목록 | +| `tags` | 태그 마스터 (id, name, category) | +| `song_tags` | 곡-태그 매핑 (song_id, tag_id) | +| `verify_ky_songs` | KY 번호 검증 완료 목록 | ### AI 유틸 - `utils/validateSongMatch.ts` — `gpt-4o-mini`로 두 (제목, 아티스트) 쌍이 같은 곡인지 판단. `temperature: 0`, `max_tokens: 20`, 완전 일치 시 API 호출 생략. - `utils/transChatGPT.ts` — `gpt-4-turbo`로 일본어 → 한국어 번역. +- `utils/getSongTag.ts` — `gpt-4o-mini`로 곡에 적절한 태그 ID 자동 할당. DB의 `tags` 테이블에서 태그 목록을 캐싱하여 프롬프트에 포함. + +### 곡 태깅 파이프라인 + +``` +taggingSongs.ts + └─ getSongsAllDB() # 전체 곡 조회 + └─ getSongTagSongIdsDB() # 이미 태그된 곡 ID Set 로드 (스킵 처리) + └─ autoTagSong(title, artist) # AI로 태그 ID 추출 (1~4개) + └─ postSongTagsDB(songId, tagIds) # song_tags 테이블에 insert +``` + +### GitHub Actions 워크플로우 + +| 워크플로우 파일 | 스케줄 (UTC) | 실행 스크립트 | +| ----------------------- | ------------ | -------------------- | +| `crawl_recent_tj.yml` | 매일 14:00 | `pnpm recent-tj` | +| `tagging_song.yml` | 매일 14:00 | `pnpm tag-songs` | +| `update_ky_youtube.yml` | 수동 | `pnpm ky-youtube` | +| `verify_ky_youtube.yml` | 수동 | `pnpm ky-verify` | + +모든 워크플로우는 `workflow_dispatch`로 수동 실행도 가능하다. From 0531dd233919b785935a7dc86512fc30b138011f Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Tue, 31 Mar 2026 01:48:58 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat=20:=20=EA=B2=80=EC=83=89=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20API=20=EB=B0=8F=20=EC=9D=B8=EA=B8=B0=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=EC=96=B4=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#174)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- apps/web/src/app/api/search/log/route.ts | 69 +++++++++++++++++++ apps/web/src/app/search/HomePage.tsx | 21 +++--- .../src/app/search/PopularSearchHistory.tsx | 41 +++++++++++ apps/web/src/app/search/SearchHistory.tsx | 48 +++++++------ apps/web/src/hooks/useSearchSong.ts | 9 +++ apps/web/src/lib/api/searchLog.ts | 18 +++++ apps/web/src/queries/searchLogQuery.ts | 27 ++++++++ 7 files changed, 202 insertions(+), 31 deletions(-) create mode 100644 apps/web/src/app/api/search/log/route.ts create mode 100644 apps/web/src/app/search/PopularSearchHistory.tsx create mode 100644 apps/web/src/lib/api/searchLog.ts create mode 100644 apps/web/src/queries/searchLogQuery.ts diff --git a/apps/web/src/app/api/search/log/route.ts b/apps/web/src/app/api/search/log/route.ts new file mode 100644 index 00000000..27526586 --- /dev/null +++ b/apps/web/src/app/api/search/log/route.ts @@ -0,0 +1,69 @@ +import { NextResponse } from 'next/server'; + +import createClient from '@/lib/supabase/server'; +import { ApiResponse } from '@/types/apiRoute'; + +interface SearchLogCount { + text: string; + count: number; +} + +export async function GET(): Promise>> { + try { + const supabase = await createClient(); + const { data, error } = await supabase.from('search_logs').select('text'); + + if (error) throw error; + + const countMap = new Map(); + for (const row of data) { + countMap.set(row.text, (countMap.get(row.text) ?? 0) + 1); + } + + const result: SearchLogCount[] = Array.from(countMap, ([text, count]) => ({ + text, + count, + })).sort((a, b) => b.count - a.count); + + return NextResponse.json({ success: true, data: result }); + } catch (error) { + if (error instanceof Error && error.cause === 'auth') { + return NextResponse.json( + { success: false, error: 'User not authenticated' }, + { status: 401 }, + ); + } + + console.error('Error in search log GET API:', error); + return NextResponse.json( + { success: false, error: 'Failed to get search logs' }, + { status: 500 }, + ); + } +} + +export async function POST(request: Request): Promise>> { + try { + const { text } = await request.json(); + + const supabase = await createClient(); + const { error } = await supabase.from('search_logs').insert({ text }); + + if (error) throw error; + + 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 search log POST API:', error); + return NextResponse.json( + { success: false, error: 'Failed to post search log' }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/app/search/HomePage.tsx b/apps/web/src/app/search/HomePage.tsx index 53df31a2..5c7a63d3 100644 --- a/apps/web/src/app/search/HomePage.tsx +++ b/apps/web/src/app/search/HomePage.tsx @@ -3,7 +3,6 @@ import { Loader2, Search, SearchX } from 'lucide-react'; import { useEffect, useState } from 'react'; import { useInView } from 'react-intersection-observer'; -import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; // import { Checkbox } from '@/components/ui/checkbox'; @@ -18,6 +17,7 @@ import { SearchSong } from '@/types/song'; import AddFolderModal from './AddFolderModal'; // import ChatBot from './ChatBot'; import JpnArtistList from './JpnArtistList'; +import PopularSearchHistory from './PopularSearchHistory'; import SearchAutocomplete from './SearchAutocomplete'; import SearchHistory from './SearchHistory'; import SearchResultCard from './SearchResultCard'; @@ -96,11 +96,6 @@ export default function SearchPage() { }; const handleSearchClick = () => { - if (!search.trim()) { - toast.error('검색어를 입력해주세요.'); - return; - } - handleSearch(); setIsFocusAuto(false); }; @@ -206,10 +201,8 @@ export default function SearchPage() { {isPendingSearch ? : '검색'} - {/* 검색 기록 */} - -
+
{searchSongs.length > 0 && (
{searchSongs.map((song, index) => ( @@ -247,11 +240,19 @@ export default function SearchPage() {

검색 결과가 없습니다.

)} - {searchSongs.length === 0 && !query && ( + + {/* {searchSongs.length === 0 && !query && (

노래 제목이나 가수를 검색해보세요

+ )} */} + + {searchSongs.length === 0 && !query && ( +
+ + +
)}
diff --git a/apps/web/src/app/search/PopularSearchHistory.tsx b/apps/web/src/app/search/PopularSearchHistory.tsx new file mode 100644 index 00000000..27524802 --- /dev/null +++ b/apps/web/src/app/search/PopularSearchHistory.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { ChartNoAxesCombined } from 'lucide-react'; + +import { useSearchLogQuery } from '@/queries/searchLogQuery'; + +interface PopularSearchHistoryProps { + onHistoryClick: (term: string) => void; +} + +export default function PopularSearchHistory({ onHistoryClick }: PopularSearchHistoryProps) { + const { data: searchLogs } = useSearchLogQuery(); + + if (!searchLogs || searchLogs.length === 0) return null; + + return ( +
+
+ +

인기 검색어

+
+
+ {searchLogs.slice(0, 10).map((log, index) => ( +
+ {index + 1} + onHistoryClick(log.text)} + > + {log.text} + + {log.count} +
+ ))} +
+
+ ); +} diff --git a/apps/web/src/app/search/SearchHistory.tsx b/apps/web/src/app/search/SearchHistory.tsx index 1bbd9296..bc72ec27 100644 --- a/apps/web/src/app/search/SearchHistory.tsx +++ b/apps/web/src/app/search/SearchHistory.tsx @@ -1,4 +1,4 @@ -import { X } from 'lucide-react'; +import { Clock, X } from 'lucide-react'; import useSearchHistoryStore from '@/stores/useSearchHistoryStore'; @@ -12,27 +12,33 @@ export default function SearchHistory({ onHistoryClick }: SearchHistoryProps) { if (searchHistory.length === 0) return null; return ( -
- {searchHistory.map((term, index) => ( -
- onHistoryClick(term)} +
+
+ +

최근 검색어

+
+
+ {searchHistory.slice(10).map((term, index) => ( +
- {term} - - removeFromHistory(term)} - title="검색 기록 삭제" - > - - -
- ))} + onHistoryClick(term)} + > + {term} + + removeFromHistory(term)} + title="검색 기록 삭제" + > + + +
+ ))} +
); } diff --git a/apps/web/src/hooks/useSearchSong.ts b/apps/web/src/hooks/useSearchSong.ts index ed314d03..a699de15 100644 --- a/apps/web/src/hooks/useSearchSong.ts +++ b/apps/web/src/hooks/useSearchSong.ts @@ -1,6 +1,7 @@ import { useDeferredValue, useMemo, useState } from 'react'; import { toast } from 'sonner'; +import { usePostSearchLogMutation } from '@/queries/searchLogQuery'; import { useInfiniteSearchSongQuery, useToggleLikeMutation, @@ -42,6 +43,8 @@ export default function useSearchSong() { isError, } = useInfiniteSearchSongQuery(query, queryType, isAuthenticated); + const { mutate: postSearchLog } = usePostSearchLogMutation(); + const { setFooterAnimateKey } = useFooterAnimateStore(); const { addToHistory } = useSearchHistoryStore(); const { addGuestToSingSong, removeGuestToSingSong } = useGuestToSingStore(); @@ -57,6 +60,11 @@ export default function useSearchSong() { // trim 제거 const trimSearch = search.trim(); + if (!trimSearch) { + setQuery(''); + return; + } + let parsedSearch = trimSearch; if (autoCompleteList.length === 1) { @@ -77,6 +85,7 @@ export default function useSearchSong() { setSearch(parsedSearch); setQueryType(searchType); addToHistory(parsedSearch); + postSearchLog(parsedSearch); } }; diff --git a/apps/web/src/lib/api/searchLog.ts b/apps/web/src/lib/api/searchLog.ts new file mode 100644 index 00000000..c52f8e30 --- /dev/null +++ b/apps/web/src/lib/api/searchLog.ts @@ -0,0 +1,18 @@ +import { ApiResponse } from '@/types/apiRoute'; + +import { instance } from './client'; + +interface SearchLogCount { + text: string; + count: number; +} + +export async function getSearchLog() { + const response = await instance.get>('/search/log'); + return response.data; +} + +export async function postSearchLog(body: { text: string }) { + const response = await instance.post>('/search/log', body); + return response.data; +} diff --git a/apps/web/src/queries/searchLogQuery.ts b/apps/web/src/queries/searchLogQuery.ts new file mode 100644 index 00000000..fdf37229 --- /dev/null +++ b/apps/web/src/queries/searchLogQuery.ts @@ -0,0 +1,27 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { getSearchLog, postSearchLog } from '@/lib/api/searchLog'; + +export function useSearchLogQuery() { + return useQuery({ + queryKey: ['searchLog'], + queryFn: async () => { + const response = await getSearchLog(); + if (!response.success) { + return []; + } + return response.data || []; + }, + }); +} + +export function usePostSearchLogMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (text: string) => postSearchLog({ text }), + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ['searchLog'] }); + }, + }); +} From 8fb1b2212706e6b9998bdaf0f4a1759ebae6ef53 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Tue, 31 Mar 2026 01:49:12 +0900 Subject: [PATCH 05/10] =?UTF-8?q?refactor=20:=20=EC=A2=8B=EC=95=84?= =?UTF-8?q?=EC=9A=94=EB=A5=BC=20=EC=A6=90=EA=B2=A8=EC=B0=BE=EA=B8=B0?= =?UTF-8?q?=EB=A1=9C=20=EC=9A=A9=EC=96=B4=20=EB=B0=8F=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EC=BD=98=20=EB=B3=80=EA=B2=BD=20(#174)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- apps/web/src/app/info/like/page.tsx | 2 +- apps/web/src/app/info/page.tsx | 6 +++--- apps/web/src/app/search/SearchResultCard.tsx | 8 ++++---- apps/web/src/app/tosing/AddListModal.tsx | 2 +- apps/web/src/queries/likeSongQuery.ts | 4 ++-- apps/web/src/types/song.ts | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/web/src/app/info/like/page.tsx b/apps/web/src/app/info/like/page.tsx index df07604c..7d60a4bd 100644 --- a/apps/web/src/app/info/like/page.tsx +++ b/apps/web/src/app/info/like/page.tsx @@ -27,7 +27,7 @@ export default function LikePage() { -

좋아요 곡 관리

+

즐겨찾기 곡 관리

diff --git a/apps/web/src/app/info/page.tsx b/apps/web/src/app/info/page.tsx index cd5850ff..3529ed62 100644 --- a/apps/web/src/app/info/page.tsx +++ b/apps/web/src/app/info/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { CircleDollarSign, Folder, Heart } from 'lucide-react'; +import { CircleDollarSign, Folder, Star } from 'lucide-react'; import { useRouter } from 'next/navigation'; import CountUp from '@/components/reactBits/CountUp'; @@ -11,9 +11,9 @@ import { useUserQuery } from '@/queries/userQuery'; const menuItems = [ { id: 'like', - title: '좋아요 곡 관리', + title: '즐겨찾기 곡 관리', description: '좋아요를 누른 노래를 관리합니다', - icon: , + icon: , }, { diff --git a/apps/web/src/app/search/SearchResultCard.tsx b/apps/web/src/app/search/SearchResultCard.tsx index a05cca0d..08748837 100644 --- a/apps/web/src/app/search/SearchResultCard.tsx +++ b/apps/web/src/app/search/SearchResultCard.tsx @@ -1,11 +1,11 @@ import { AnimatePresence, motion } from 'framer-motion'; import { ChevronDown, - Heart, ListPlus, ListRestart, MinusCircle, PlusCircle, + Star, ThumbsUp, } from 'lucide-react'; import { useState } from 'react'; @@ -158,11 +158,11 @@ export default function SearchResultCard({ variant="ghost" size="icon" className={`h-13 flex-1 flex-col items-center justify-center`} - aria-label={isLike ? '좋아요 취소' : '좋아요'} + aria-label={isLike ? '즐겨찾기 취소' : '즐겨찾기'} onClick={onToggleLike} > - - {isLike ? '좋아요 취소' : '좋아요'} + + {isLike ? '즐겨찾기 취소' : '즐겨찾기'} + ); +} + export default function Header() { const [open, setOpen] = useState(false); + const [expanded, setExpanded] = useState(null); + const { theme, setTheme } = useTheme(); + const headerRef = useRef(null); const router = useRouter(); - const { data: user, isLoading, error } = useUserQuery(); + const { data: user, isLoading } = useUserQuery(); const lastCheckIn = user?.last_check_in ?? new Date(); @@ -30,23 +69,58 @@ export default function Header() { setOpen(false); }; + const handleExpandableClick = (key: ExpandedButton, action: () => void) => { + if (expanded === key) { + action(); + setExpanded(null); + } else { + setExpanded(key); + } + }; + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (headerRef.current && !headerRef.current.contains(e.target as Node)) { + setExpanded(null); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + return ( -
+
router.push('/')} > SINGCODE
-
- - - - +
+ + + + + } + onClick={() => + handleExpandableClick('theme', () => setTheme(theme === 'dark' ? 'light' : 'dark')) + } + /> + } + disabled={isLoading} + onClick={() => handleExpandableClick('checkin', () => setOpen(true))} + /> + - + } + onClick={() => handleExpandableClick('contact', handleClickContact)} + />
diff --git a/apps/web/src/Sidebar.tsx b/apps/web/src/Sidebar.tsx index fa230b81..3b9b3df1 100644 --- a/apps/web/src/Sidebar.tsx +++ b/apps/web/src/Sidebar.tsx @@ -139,7 +139,11 @@ export default function Sidebar() { {isAuthenticated ? ( <> - diff --git a/apps/web/src/app/info/save/DeleteModal.tsx b/apps/web/src/app/info/save/DeleteModal.tsx index 228f3f29..93c2c642 100644 --- a/apps/web/src/app/info/save/DeleteModal.tsx +++ b/apps/web/src/app/info/save/DeleteModal.tsx @@ -58,7 +58,7 @@ export default function DeleteModal({
-

+

{songIdArray.length}개의 노래를 재생목록에서 삭제하시겠습니까?
이 작업은 되돌릴 수 없습니다.

diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index bcfd93d1..fd1f39ff 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,6 +1,7 @@ import { Analytics } from '@vercel/analytics/react'; import { SpeedInsights } from '@vercel/speed-insights/next'; import type { Metadata } from 'next'; +import { ThemeProvider } from 'next-themes'; import Script from 'next/script'; import { Toaster } from 'sonner'; @@ -102,7 +103,7 @@ export default function RootLayout({
-
{children}
+
{children}
@@ -123,24 +124,20 @@ export default function RootLayout({ ); return ( - + + - - - - {/* {isDevelopment ? ( + + + - ) : ( - - - - )} */} - - + + + ); diff --git a/apps/web/src/app/search/HomePage.tsx b/apps/web/src/app/search/HomePage.tsx index 5c7a63d3..ab260baa 100644 --- a/apps/web/src/app/search/HomePage.tsx +++ b/apps/web/src/app/search/HomePage.tsx @@ -169,10 +169,22 @@ export default function SearchPage() {
- - 전체 - 제목 - 가수 + + {( + [ + ['all', '전체'], + ['title', '제목'], + ['artist', '가수'], + ] as const + ).map(([value, label]) => ( + + {label} + + ))} diff --git a/apps/web/src/app/search/JpnArtistList.tsx b/apps/web/src/app/search/JpnArtistList.tsx index e35a8de4..df7b0a5c 100644 --- a/apps/web/src/app/search/JpnArtistList.tsx +++ b/apps/web/src/app/search/JpnArtistList.tsx @@ -38,7 +38,10 @@ export default function JpnArtistList({ return ( - diff --git a/apps/web/src/app/search/MusicCard.tsx b/apps/web/src/app/search/MusicCard.tsx index cabb0797..0416bf08 100644 --- a/apps/web/src/app/search/MusicCard.tsx +++ b/apps/web/src/app/search/MusicCard.tsx @@ -9,18 +9,18 @@ type MusicCardProps = { export function MusicCard({ title, artist, reason, onClick }: MusicCardProps) { return ( -
+
diff --git a/apps/web/src/components/CheckInModal.tsx b/apps/web/src/components/CheckInModal.tsx index 6bd53883..5f2c03fa 100644 --- a/apps/web/src/components/CheckInModal.tsx +++ b/apps/web/src/components/CheckInModal.tsx @@ -59,7 +59,7 @@ export default function CheckInModal({ idleView={trigger => (

오늘 출석하시겠어요?

-
+
Current Points @@ -77,7 +77,7 @@ export default function CheckInModal({
diff --git a/apps/web/src/components/MessageDialog.tsx b/apps/web/src/components/MessageDialog.tsx index 76edb10b..ed2f09f1 100644 --- a/apps/web/src/components/MessageDialog.tsx +++ b/apps/web/src/components/MessageDialog.tsx @@ -64,7 +64,7 @@ export default function MessageDialog() { variant === 'success' && 'text-green-500', variant === 'error' && 'text-red-500', variant === 'warning' && 'text-yellow-500', - variant === 'info' && 'text-blue-500', + variant === 'info' && 'text-accent', )} /> {title && {title}} @@ -82,7 +82,7 @@ export default function MessageDialog() { variant === 'success' && 'bg-green-500 hover:bg-green-600', variant === 'error' && 'bg-red-500 hover:bg-red-600', variant === 'warning' && 'bg-yellow-500 hover:bg-yellow-600', - variant === 'info' && 'bg-blue-500 hover:bg-blue-600', + variant === 'info' && 'bg-accent hover:bg-accent/90 text-accent-foreground', )} > {buttonText || '확인'} diff --git a/apps/web/src/components/StaticLoading.tsx b/apps/web/src/components/StaticLoading.tsx index 33c05449..d41df3ca 100644 --- a/apps/web/src/components/StaticLoading.tsx +++ b/apps/web/src/components/StaticLoading.tsx @@ -2,7 +2,7 @@ import { Loader2 } from 'lucide-react'; export default function StaticLoading() { return ( -
+
); diff --git a/apps/web/src/components/ThumbUpModal.tsx b/apps/web/src/components/ThumbUpModal.tsx index fe8bbf83..f2ada66c 100644 --- a/apps/web/src/components/ThumbUpModal.tsx +++ b/apps/web/src/components/ThumbUpModal.tsx @@ -64,7 +64,7 @@ export default function ThumbUpModal({
{/* 레이블 추가로 가독성 향상 */} - + Total Points diff --git a/apps/web/src/globals.css b/apps/web/src/globals.css index 1b30b827..b0567857 100644 --- a/apps/web/src/globals.css +++ b/apps/web/src/globals.css @@ -73,7 +73,8 @@ :root { --radius: 0.625rem; - --background: oklch(1 0 0); + /* 라이트 모드 — 네온 초록 accent */ + --background: oklch(0.985 0 0); --foreground: oklch(0.145 0 0); --card: oklch(1 0 0); --card-foreground: oklch(0.145 0 0); @@ -81,75 +82,72 @@ --popover-foreground: oklch(0.145 0 0); --primary: oklch(0.205 0 0); --primary-foreground: oklch(0.985 0 0); - /* --primary: oklch(0.726 0.202 145.92); - --primary-foreground: oklch(0.985 0 0); */ --secondary: oklch(0.97 0 0); --secondary-foreground: oklch(0.205 0 0); --muted: oklch(0.97 0 0); --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.65 0.15 250); /* 부드러운 파란색 */ - --accent-foreground: oklch(0.985 0 0); /* 흰색 */ - /* --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); */ + --accent: oklch(0.75 0.2 145); /* 네온 초록 */ + --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.577 0.245 27.325); --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); + --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.769 0.188 70.08); + --chart-5: oklch(0.627 0.265 303.9); --sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary: oklch(0.75 0.2 145); --sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-accent: oklch(0.97 0 0); --sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); - /* oklch로는 표현 ff4a00 */ + --sidebar-ring: oklch(0.75 0.2 145); --brand-tj: oklch(66.48% 0.226 36.37); --brand-ky: oklch(61.23% 0.159 288.46); --check: oklch(0.72 0.2 145); + --glow-accent: 0 0 8px oklch(0.75 0.2 145 / 0.3); + --glow-secondary: 0 0 8px oklch(0.65 0.2 350 / 0.3); } .dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - /* --primary: oklch(0.726 0.202 145.92); - --primary-foreground: oklch(0.985 0 0); */ - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); + /* 네온 나이트 테마 — 다크 모드 */ + --background: oklch(0.15 0.01 260); + --foreground: oklch(0.9 0 0); + --card: oklch(0.2 0.01 260); + --card-foreground: oklch(0.9 0 0); + --popover: oklch(0.2 0.01 260); + --popover-foreground: oklch(0.9 0 0); + --primary: oklch(0.9 0 0); + --primary-foreground: oklch(0.15 0.01 260); + --secondary: oklch(0.65 0.2 350); /* 네온 핑크 */ + --secondary-foreground: oklch(0.95 0 0); + --muted: oklch(0.25 0.01 260); + --muted-foreground: oklch(0.6 0 0); + --accent: oklch(0.75 0.2 145); /* 네온 초록 */ + --accent-foreground: oklch(0.15 0 0); --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); - + --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); + --sidebar: oklch(0.18 0.01 260); + --sidebar-foreground: oklch(0.9 0 0); + --sidebar-primary: oklch(0.75 0.2 145); + --sidebar-primary-foreground: oklch(0.15 0 0); + --sidebar-accent: oklch(0.25 0.01 260); + --sidebar-accent-foreground: oklch(0.9 0 0); + --sidebar-border: oklch(0.3 0.01 260); + --sidebar-ring: oklch(0.75 0.2 145); --check: oklch(0.6 0.18 145); + --glow-accent: 0 0 8px oklch(0.75 0.2 145 / 0.3); + --glow-secondary: 0 0 8px oklch(0.65 0.2 350 / 0.3); } button { From 2bd1ed0cec00e1487692ffb05b71177c83223282 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Mon, 6 Apr 2026 00:45:56 +0900 Subject: [PATCH 09/10] =?UTF-8?q?chore=20:=20=EB=B2=84=EC=A0=84=202.4.0=20?= =?UTF-8?q?=EB=B0=8F=20changelog=20=EC=B6=94=EA=B0=80=20(#179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/package.json | 2 +- apps/web/public/changelog.json | 4 ++++ apps/web/public/sitemap-0.xml | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index e34844e5..41145400 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "web", - "version": "2.3.0", + "version": "2.4.0", "type": "module", "private": true, "scripts": { diff --git a/apps/web/public/changelog.json b/apps/web/public/changelog.json index 0c741719..0ec847a5 100644 --- a/apps/web/public/changelog.json +++ b/apps/web/public/changelog.json @@ -108,5 +108,9 @@ "인기곡 페이지의 UI를 개선했습니다. 이제 인기곡 페이지에서 곡을 추천할 수 있습니다.", "로그인 시 제공되는 코인을 30개로 변경했습니다." ] + }, + "2.4.0": { + "title": "버전 2.4.0", + "message": ["네온 나이트 다크 테마를 추가했습니다.", "전체적인 UI를 개선했습니다."] } } diff --git a/apps/web/public/sitemap-0.xml b/apps/web/public/sitemap-0.xml index fa9b0e30..87729978 100644 --- a/apps/web/public/sitemap-0.xml +++ b/apps/web/public/sitemap-0.xml @@ -1,4 +1,4 @@ -https://www.singcode.kr2026-03-30T15:15:37.869Zweekly0.7 +https://www.singcode.kr2026-04-05T15:20:24.064Zweekly0.7 \ No newline at end of file From d5ea79e7646d0d6e8c39c18a94ef82e2ce8ab5bf Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Mon, 6 Apr 2026 01:15:47 +0900 Subject: [PATCH 10/10] =?UTF-8?q?chore=20:=20tag=20=ED=81=AC=EB=A1=A0=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=EC=9D=BC=EC=8B=9C=20=EC=A0=95=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/tagging_song.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tagging_song.yml b/.github/workflows/tagging_song.yml index 10b51111..35879223 100644 --- a/.github/workflows/tagging_song.yml +++ b/.github/workflows/tagging_song.yml @@ -1,9 +1,9 @@ name: Tagging Songs on: - schedule: - - cron: "0 14 * * *" # 한국 시간 23:00 실행 (UTC+9 → UTC 14:00) - workflow_dispatch: + # schedule: + # - cron: "0 14 * * *" # 한국 시간 23:00 실행 (UTC+9 → UTC 14:00) + # workflow_dispatch: permissions: contents: write # push 권한을 위해 필요