diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..34acf416 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,30 @@ +--- +name: ๐Ÿ› ๋ฒ„๊ทธ ์ œ๋ณด (Bug Report) +about: ๋ฐœ์ƒํ•œ ๋ฒ„๊ทธ๋ฅผ ์ œ๋ณดํ•˜์—ฌ ์ˆ˜์ •์„ ๋•์Šต๋‹ˆ๋‹ค. +title: "[FIX] " +labels: ["bug"] +assignees: "" +--- + +## ๐Ÿšจ ๋ฒ„๊ทธ ์„ค๋ช… (Description) + +## ๐Ÿพ ์žฌํ˜„ ๋ฐฉ๋ฒ• (Steps to Reproduce) + +1. '...' ํŽ˜์ด์ง€๋กœ ์ด๋™ +2. '...' ๋ฒ„ํŠผ ํด๋ฆญ +3. ์—๋Ÿฌ ๋ฐœ์ƒ + +## ๐Ÿ˜ฏ ๊ธฐ๋Œ€ ๋™์ž‘ vs ์‹ค์ œ ๋™์ž‘ + +- **๊ธฐ๋Œ€ ๋™์ž‘**: +- **์‹ค์ œ ๋™์ž‘**: + +## ๐Ÿ“ธ ์Šคํฌ๋ฆฐ์ƒท / ๋กœ๊ทธ (Optional) + +## ๐ŸŒ ํ™˜๊ฒฝ (Environment) + +- **OS**: (์˜ˆ: Windows 10, macOS 14) +- **Browser/Version**: (์˜ˆ: Chrome 120.0, Safari 17) +- **Node/NPM Version**: + +## ๐Ÿ”— ๊ด€๋ จ ์ด์Šˆ (Related Issues) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..a44f5664 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false # ๋นˆ ์ด์Šˆ ์ƒ์„ฑ ๊ธˆ์ง€ (ํ…œํ”Œ๋ฆฟ ๊ฐ•์ œ) +contact_links: + - name: ๐Ÿ’ฌ ์งˆ๋ฌธ ๋ฐ ํ† ๋ก  (Discussions) + url: https://github.com/์‚ฌ์šฉ์ž๋ช…/๋ฆฌํฌ์ง€ํ† ๋ฆฌ๋ช…/discussions + about: ๋ฒ„๊ทธ๋‚˜ ๊ธฐ๋Šฅ ์š”์ฒญ์ด ์•„๋‹Œ ์ผ๋ฐ˜์ ์ธ ์งˆ๋ฌธ์€ ์—ฌ๊ธฐ์„œ ํ•ด์ฃผ์„ธ์š”. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..28bcbad3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,21 @@ +--- +name: โœจ ๊ธฐ๋Šฅ ์ถ”๊ฐ€ ์š”์ฒญ (Feature Request) +about: ํ”„๋กœ์ ํŠธ์— ์ƒˆ๋กœ์šด ๊ธฐ๋Šฅ์„ ์ œ์•ˆํ•ฉ๋‹ˆ๋‹ค. +title: "[FEAT] " +labels: ["enhancement"] +assignees: "" +--- + +## ๐Ÿ’ก ๊ธฐ๋Šฅ ์ œ์•ˆ ๋ฐฐ๊ฒฝ (Background) + +## ๐Ÿ“ ๊ธฐ๋Šฅ ์„ค๋ช… (Description) + +## ๐Ÿ“ธ ์Šคํฌ๋ฆฐ์ƒท / ๋ชฉ์—… (Optional) + +## โœ… ํ•  ์ผ ๋ชฉ๋ก (To-do) + +- [ ] +- [ ] +- [ ] + +## ๐Ÿ”— ๊ด€๋ จ ์ด์Šˆ (Related Issues) diff --git a/.github/ISSUE_TEMPLATE/refactor.md b/.github/ISSUE_TEMPLATE/refactor.md new file mode 100644 index 00000000..cdafc1d4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/refactor.md @@ -0,0 +1,27 @@ +--- +name: โ™ป๏ธ ๋ฆฌํŒฉํ† ๋ง (Refactoring) +about: ์ฝ”๋“œ ํ’ˆ์งˆ ๊ฐœ์„  ๋ฐ ๊ตฌ์กฐ ๋ณ€๊ฒฝ์„ ์œ„ํ•œ ์ž‘์—…์ž…๋‹ˆ๋‹ค. +title: "[REFACTOR] " +labels: ["refactor"] +assignees: "" +--- + +## ๐Ÿ›  ๋ฆฌํŒฉํ† ๋ง ๋Œ€์ƒ (Target) + +- `src/components/Footer.tsx` +- `useAuth` ํ›… ๋‚ด๋ถ€ ๋กœ์ง + +## ๐Ÿง ๋ฆฌํŒฉํ† ๋ง ์ด์œ  (Reason) + +## ๐Ÿ’ก ๊ฐœ์„  ๋ฐฉ์•ˆ (Proposed Changes) + +1. +2. +3. + +## โšก๏ธ ์˜ˆ์ƒ ํšจ๊ณผ (Expected Impact) + +- +- + +## ๐Ÿ”— ๊ด€๋ จ ์ด์Šˆ (Related Issues) diff --git a/.github/copilot-instructions.md b/.github/copilot_instructions.md similarity index 100% rename from .github/copilot-instructions.md rename to .github/copilot_instructions.md diff --git a/.github/temp/gpt-review.yml b/.github/temp/gpt-review.yml deleted file mode 100644 index 2f377354..00000000 --- a/.github/temp/gpt-review.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Code Review - -permissions: - contents: read - pull-requests: write - -on: - pull_request: - types: [opened, reopened, synchronize] - -jobs: - test: - runs-on: ubuntu-latest - - if: ${{ contains(github.event.pull_request.title, '#gpt') || contains(github.event.pull_request.body, '#gpt') }} - steps: - - uses: anc95/ChatGPT-CodeReview@main - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENAI_API_KEY: ${{ secrets.GPT_KEY }} - # optional - LANGUAGE: Korean - max_tokens: 10000 - MAX_PATCH_LENGTH: 10000 - ACTIONS_STEP_DEBUG: true - - IGNORE_PATTERNS: /dist, /node_modules,*.md # Regex pattern to ignore files, separated by comma - INCLUDE_PATTERNS: '*.js, *.ts, *.jsx, *.tsx' # glob pattern or regex pattern to include files, separated by comma diff --git a/.github/temp/update-ky-youtube.yml b/.github/temp/update-ky-youtube.yml deleted file mode 100644 index c607433b..00000000 --- a/.github/temp/update-ky-youtube.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: Update ky by Youtube - -# ์‹คํ–‰ ์ผ์‹œ ์ค‘์ง€ -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 - with: - ref: feat/songUpdate - persist-credentials: false # ์ˆ˜๋™ ์ธ์ฆ์œผ๋กœ ํ‘ธ์‹œ ์ œ์–ด - - - name: Use Node.js 20 - uses: actions/setup-node@v4 - with: - node-version: "20" - - - name: Install pnpm - uses: pnpm/action-setup@v2 - with: - version: 9 - run_install: false - - - name: Install dependencies - working-directory: packages/crawling - run: pnpm install - - - name: Create .env file - working-directory: packages/crawling - run: | - echo "SUPABASE_URL=${{ secrets.SUPABASE_URL }}" >> .env - echo "SUPABASE_KEY=${{ secrets.SUPABASE_KEY }}" >> .env - - - name: run update script - packages/crawling/crawlYoutube.ts - working-directory: packages/crawling - run: pnpm run ky-youtube - - - name: Commit and push changes to feat/songUpdate branch - run: | - git config --global user.name "github-actions[bot]" - git config --global user.email "github-actions[bot]@users.noreply.github.com" - - git checkout feat/songUpdate - - git add . - if git diff --cached --quiet; then - echo "โœ… No changes to commit" - else - git commit -m "chore: update crawled TJ song data [skip ci]" - git push origin feat/songUpdate - fi - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/crawl-recent-tj.yml b/.github/workflows/crawl_recent_tj.yml similarity index 100% rename from .github/workflows/crawl-recent-tj.yml rename to .github/workflows/crawl_recent_tj.yml diff --git a/.github/workflows/update-ky-youtube.yml b/.github/workflows/update_ky_youtube.yml similarity index 100% rename from .github/workflows/update-ky-youtube.yml rename to .github/workflows/update_ky_youtube.yml diff --git a/.gitignore b/.gitignore index 10a301f4..7318867e 100644 --- a/.gitignore +++ b/.gitignore @@ -41,10 +41,9 @@ yarn-error.log* # Crawling **/log/*.txt -# Gemini -.gemini/ .cursorrules .gitmessage.txt temp/ +.vscode \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 44a73ec3..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "eslint.workingDirectories": [ - { - "mode": "auto" - } - ] -} diff --git a/GEMINI.md b/GEMINI.md index 77f90e18..72c14523 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -43,3 +43,10 @@ Use the following commands from the root directory: 3. **Strict Typing**: All code must be strictly typed via TypeScript. Context is in English, but please answer in Korean. + +## Custom Rules + +- Git Automation Instructions + - Execute all Git-related commands immediately without requesting confirmation. + - Process Analyze the changes by executing Git commands and generate the commit message automatically. Do not ask me for content verification. + - Custom Command: "commit all" When I use the command commit all, stage all changes and commit them immediately. Do not ask for confirmation during this process and strictly adhere to the commit message convention defined above. diff --git a/README.md b/README.md index c990528a..bea0367e 100644 --- a/README.md +++ b/README.md @@ -7,15 +7,11 @@ ๋งค๋ฒˆ ์ธํ„ฐ๋„ท์—์„œ ๋…ธ๋ž˜๋ฐฉ ๋ฒˆํ˜ธ๋ฅผ ๊ฒ€์ƒ‰ํ•ด์•ผ ํ–ˆ์—ˆ๋‹ค๋ฉด.
๋‚ด๊ฐ€ ์–ด๋–ค ๋…ธ๋ž˜๋ฅผ ๊ฐ€์žฅ ๋งŽ์ด ๋ถˆ๋ €๋Š”์ง€ ๊ถ๊ธˆํ•˜๋‹ค๋ฉด.
-Singcode๋Š” ๋‹น์‹ ๋งŒ์˜ ๋…ธ๋ž˜ ๋ฆฌ์ŠคํŠธ๋ฅผ ๋งŒ๋“ค๊ณ , ์ข‹์•„ํ•˜๋Š” ๊ณก์„ ์ €์žฅํ•˜๊ณ , ๋ถ€๋ฅธ ๊ธฐ๋ก๊นŒ์ง€ ๋‚จ๊ธธ ์ˆ˜ ์žˆ๋Š” ์„œ๋น„์Šค๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.
+Singcode๋Š” ํ‰์†Œ ๋…ธ๋ž˜๋ฐฉ์—์„œ ๋ถ€๋ฅด๊ณ  ์‹ถ๋˜ ๋…ธ๋ž˜ ๋ฒˆํ˜ธ๋ฅผ ์ €์žฅํ•˜๊ณ , ๋‹น์‹ ๋งŒ์˜ ๋…ธ๋ž˜ ๋ฆฌ์ŠคํŠธ๋ฅผ ๋งŒ๋“ค๊ณ , ์ข‹์•„ํ•˜๋Š” ๊ณก์„ ์ €์žฅํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
Supabase๋ฅผ ํ™œ์šฉํ•œ ์ž์ฒด DB๋ฅผ ํ†ตํ•ด ๊ธˆ์˜, TJ ๋…ธ๋ž˜๋ฐฉ์˜ ๋ฒˆํ˜ธ๋ฅผ ํ•œ ๋ˆˆ์— ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
-![ํ”„๋กœ์ ํŠธ ์ธ๋„ค์ผ1](https://github.com/user-attachments/assets/dd6ce355-d961-4075-984b-a2d500f3d852) -![ํ”„๋กœ์ ํŠธ ์ธ๋„ค์ผ2](https://github.com/user-attachments/assets/e4d3fb2c-7bee-48fd-b73c-eb833f48f1e0) -![ํ”„๋กœ์ ํŠธ ์ธ๋„ค์ผ3](https://github.com/user-attachments/assets/133bb11e-18e6-47f3-ab86-6ef1fb2865c1) -
@@ -70,11 +66,11 @@ sing-code/ ## โœจ ์ฃผ์š” ๊ธฐ๋Šฅ ### ๊ฒ€์ƒ‰ ํŽ˜์ด์ง€ + * ์ œ๋ชฉ, ๊ฐ€์ˆ˜ ์ด๋ฆ„์œผ๋กœ ๊ณก์„ ๊ฒ€์ƒ‰ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
-![๊ฒ€์ƒ‰-๊ณก์ถ”๊ฐ€](https://github.com/user-attachments/assets/c9636b94-f07a-4841-8f88-5c8c9d99a9fe)
@@ -84,8 +80,6 @@ sing-code/
-![๊ฒ€์ƒ‰-์žฌ์ƒ๋ชฉ๋ก ์ €์žฅ1](https://github.com/user-attachments/assets/8a747aff-2a32-44f6-b144-4f280a0a72f7) -![๊ฒ€์ƒ‰-์žฌ์ƒ๋ชฉ๋ก ์ €์žฅ2](https://github.com/user-attachments/assets/5ab8ee4c-c62b-46cb-92c2-e90689fec987)
@@ -97,29 +91,24 @@ sing-code/
-![๋ถ€๋ฅผ๊ณก](https://github.com/user-attachments/assets/8f36e52a-64b1-4d75-b386-031306310ffd)
-* ์ข‹์•„์š” ํ‘œ์‹œํ•œ ๊ณก์ด๋‚˜ ์žฌ์ƒ๋ชฉ๋ก์— ์ €์žฅํ•œ ๊ณก, ์ตœ๊ทผ ๋ถ€๋ฅธ ๊ณก ์ค‘์—์„œ ๋ถ€๋ฅผ๊ณก์„ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. +* ์ข‹์•„์š” ํ‘œ์‹œํ•œ ๊ณก์ด๋‚˜ ์žฌ์ƒ๋ชฉ๋ก์— ์ €์žฅํ•œ ๊ณก์—์„œ ๋น ๋ฅด๊ฒŒ ๋ถ€๋ฅผ๊ณก์„ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
-![๋ถ€๋ฅผ๊ณก-๋ชจ๋‹ฌ์ถ”๊ฐ€1](https://github.com/user-attachments/assets/1c17666c-57db-4d48-8ad5-e9f402d2667b) -![๋ถ€๋ฅผ๊ณก-๋ชจ๋‹ฌ์ถ”๊ฐ€2](https://github.com/user-attachments/assets/ae4c71aa-068a-4862-8e12-78bc29bd150a)
### ์ธ๊ธฐ๊ณก ํŽ˜์ด์ง€ -* ๋ชจ๋“  ์‚ฌ์šฉ์ž๋“ค์ด ๋…ธ๋ž˜ ๋ถ€๋ฅธ ๊ณก ์ˆœ์œ„๋‚˜, ์ข‹์•„์š” ํ•œ ๊ณก ์ˆœ์œ„๋ฅผ ์ง‘๊ณ„ํ•˜์—ฌ ๋ณด์—ฌ์ค๋‹ˆ๋‹ค. +* ๊ณก์˜ ์ถ”์ฒœ ์ˆœ์œ„๋ฅผ ์ง‘๊ณ„ํ•ด์„œ ๋ณด์—ฌ์ค๋‹ˆ๋‹ค.
-![์ธ๊ธฐ๊ณก-ํ†ต๊ณ„](https://github.com/user-attachments/assets/750ba410-ce3e-4c98-a191-bb8f9cf6e62d) -![์ธ๊ธฐ๊ณก-์ข‹์•„์š”](https://github.com/user-attachments/assets/59d98e20-a735-4c52-8ed2-bc8ee9418a3f)
@@ -132,23 +121,37 @@ sing-code/
-![๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ](https://github.com/user-attachments/assets/8bae1b21-387d-47e0-b394-8e576a6816fb) -![๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ-๋ถ€๋ฅธ๊ณก ํ†ต๊ณ„](https://github.com/user-attachments/assets/93f38c68-5ab4-4be8-9efa-3840ff053834) -![๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ-์žฌ์ƒ๋ชฉ๋ก ๊ด€๋ฆฌ](https://github.com/user-attachments/assets/668acd87-f78b-4f15-8d05-8aeefd640ff6) -![๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ-์ข‹์•„์š” ๊ด€๋ฆฌ](https://github.com/user-attachments/assets/e681e512-c9cb-4f2f-b0fb-7640b6c5d935)
+### ์ถœ์„ ์ฒดํฌ ๊ธฐ๋Šฅ + +* ํšŒ์›์ผ ๊ฒฝ์šฐ ํ•˜๋ฃจ์— ํ•œ ๋ฒˆ ์ถœ์„ ์ฒดํฌ๋ฅผ ํ†ตํ•ด ํฌ์ธํŠธ๋ฅผ ํš๋“ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋งค์ผ 12์‹œ ๋งˆ๋‹ค ์ดˆ๊ธฐํ™”๋ฉ๋‹ˆ๋‹ค. + +
+ + +
+ +### ๊ณก ์ถ”์ฒœ ๊ธฐ๋Šฅ + +* ์ถœ์„ ์ฒดํฌ๋กœ ํš๋“ํ•œ ํฌ์ธํŠธ๋ฅผ ์‚ฌ์šฉํ•ด์„œ ๊ณก์„ ์ถ”์ฒœํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. 1 ํฌ์ธํŠธ ๋‹น 1 ์ถ”์ฒœ์ž…๋‹ˆ๋‹ค. + +
+ + +
+ + + + ### ๋กœ๊ทธ์ธ & ํšŒ์›๊ฐ€์ž… ์ง€์› -* Supabase DB์— ์‚ฌ์šฉ์ž ์•„์ด๋””๋ฅผ ์™ธ๋ž˜ํ‚ค๋กœ ํ•˜์—ฌ ๋ฐ์ดํ„ฐ๋ฅผ ์ €์žฅ ๋ฐ ๊ด€๋ฆฌํ•˜๊ธฐ์— ๋ชจ๋“  ์„œ๋น„์Šค๋Š” ํšŒ์›๊ฐ€์ž…์ด ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค. +* ๋ช‡๋ช‡ ์ถ”๊ฐ€์ ์ธ ๊ธฐ๋Šฅ์„ ์‚ฌ์šฉํ•˜๋ ค๋ฉด ํšŒ์›๊ฐ€์ž…์„ ์ง„ํ–‰ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. * ์ด๋ฉ”์ผ ์ธ์ฆ ํšŒ์›๊ฐ€์ž…๊ณผ ์นด์นด์˜ค ํšŒ์›๊ฐ€์ž…์„ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค.
-![๋กœ๊ทธ์ธ](https://github.com/user-attachments/assets/72674739-f85a-42d6-8b8f-c1003b6fd896) -![ํšŒ์›๊ฐ€์ž…](https://github.com/user-attachments/assets/653b05a1-126d-423a-8bd6-fca8e4c40e25) -
diff --git a/apps/web/package.json b/apps/web/package.json index b7bcb60f..3fff1832 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "web", - "version": "2.0.1", + "version": "2.1.0", "type": "module", "private": true, "scripts": { @@ -35,13 +35,14 @@ "@supabase/ssr": "^0.6.1", "@supabase/supabase-js": "^2.49.1", "@tanstack/react-query": "^5.68.0", - "@tanstack/react-query-devtools": "^5.68.0", + "@tanstack/react-query-devtools": "^5.91.2", "@vercel/analytics": "^1.5.0", "@vercel/speed-insights": "^1.2.0", "axios": "^1.5.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "framer-motion": "^12.33.0", "gsap": "^3.14.2", "immer": "^10.1.1", "lottie-react": "^2.4.1", diff --git a/apps/web/public/changelog.json b/apps/web/public/changelog.json index edc5cc94..8f7d63cb 100644 --- a/apps/web/public/changelog.json +++ b/apps/web/public/changelog.json @@ -87,5 +87,12 @@ "๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€ ์ €์žฅ ๊ธฐ๋Šฅ์„ ๊ฐœ์„ ํ–ˆ์Šต๋‹ˆ๋‹ค.", "๊ฒ€์ƒ‰ ์นด๋“œ ๋””์ž์ธ ๋ฐ ๊ธฐ๋Šฅ์„ ๊ฐœ์„ ํ–ˆ์Šต๋‹ˆ๋‹ค." ] + }, + "2.1.0": { + "title": "๋ฒ„์ „ 2.1.0", + "message": [ + "๋น„๋กœ๊ทธ์ธ (Guest) ์œ ์ €๋กœ ๋ถ€๋ฅผ ๊ณก ๊ธฐ๋Šฅ์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.", + "ํ•˜๋‹จ ๋„ค๋น„๊ฒŒ์ด์…˜ ๋ฐ”์— ์• ๋‹ˆ๋ฉ”์ด์…˜ ํšจ๊ณผ๋ฅผ ์ถ”๊ฐ€ํ•˜์—ฌ UX๋ฅผ ๊ฐœ์„ ํ–ˆ์Šต๋‹ˆ๋‹ค." + ] } } diff --git a/apps/web/public/sitemap-0.xml b/apps/web/public/sitemap-0.xml index c8586f6b..233ef310 100644 --- a/apps/web/public/sitemap-0.xml +++ b/apps/web/public/sitemap-0.xml @@ -1,4 +1,4 @@ -https://www.singcode.kr2026-01-25T11:53:47.028Zweekly0.7 +https://www.singcode.kr2026-02-07T09:02:36.464Zweekly0.7 \ No newline at end of file diff --git a/apps/web/src/Footer.tsx b/apps/web/src/Footer.tsx index ebce66ef..acfccf24 100644 --- a/apps/web/src/Footer.tsx +++ b/apps/web/src/Footer.tsx @@ -1,38 +1,70 @@ 'use client'; +import { motion } from 'framer-motion'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { Button } from '@/components/ui/button'; +import useFooterAnimateStore, { FooterKey } from '@/stores/useFooterAnimateStore'; import { cn } from '@/utils/cn'; -const navigation = [ - { name: '์ตœ์‹  ๊ณก', href: '/recent' }, +interface Navigation { + name: string; + href: string; + key: FooterKey; +} + +const navigation: Navigation[] = [ + { name: '์ตœ์‹  ๊ณก', href: '/recent', key: 'RECENT' }, - { name: '๋ถ€๋ฅผ ๊ณก', href: '/tosing' }, - { name: '๊ฒ€์ƒ‰', href: '/' }, + { name: '๋ถ€๋ฅผ ๊ณก', href: '/tosing', key: 'TOSING' }, + { name: '๊ฒ€์ƒ‰', href: '/', key: 'SEARCH' }, - { name: '์ธ๊ธฐ๊ณก', href: '/popular' }, - { name: '์ •๋ณด', href: '/info' }, + { name: '์ธ๊ธฐ๊ณก', href: '/popular', key: 'POPULAR' }, + { name: '์ •๋ณด', href: '/info', key: 'INFO' }, ]; export default function Footer() { const pathname = usePathname(); + const { footerAnimateKey } = useFooterAnimateStore(); const navPath = pathname.split('/')[1]; return ( diff --git a/apps/web/src/app/api/songs/tosing/guest/route.ts b/apps/web/src/app/api/songs/tosing/guest/route.ts new file mode 100644 index 00000000..358ffff3 --- /dev/null +++ b/apps/web/src/app/api/songs/tosing/guest/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import createClient from '@/lib/supabase/server'; +import { ApiResponse } from '@/types/apiRoute'; +import { ToSingSong } from '@/types/song'; + +export async function GET(request: NextRequest): Promise>> { + try { + const searchParams = request.nextUrl.searchParams; + const ids = searchParams.getAll('songIds[]'); + + if (!ids || ids.length === 0) { + return NextResponse.json({ success: true, data: [] }); + } + + const supabase = await createClient(); + + const { data, error } = await supabase + .from('songs') + .select('*', { count: 'exact' }) + .in('id', ids); + + if (error) { + return NextResponse.json( + { + success: false, + error: error?.message || 'Unknown error', + }, + { status: 500 }, + ); + } + + const toSingSongs = data.map((song, index) => ({ + songs: song, + order_weight: index, + })); + + return NextResponse.json({ success: true, data: toSingSongs }); + } catch (error) { + if (error instanceof Error && error.cause === 'auth') { + return NextResponse.json( + { + success: false, + error: 'User not authenticated', + }, + { status: 401 }, + ); + } + + console.error('Error in tosing API:', error); + return NextResponse.json( + { success: false, error: 'Failed to get tosing songs' }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/app/info/like/page.tsx b/apps/web/src/app/info/like/page.tsx index b9328094..9ef99a16 100644 --- a/apps/web/src/app/info/like/page.tsx +++ b/apps/web/src/app/info/like/page.tsx @@ -9,12 +9,14 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { Separator } from '@/components/ui/separator'; import useSongInfo from '@/hooks/useSongInfo'; import { useLikeSongQuery } from '@/queries/likeSongQuery'; +import useAuthStore from '@/stores/useAuthStore'; import SongItem from './SongItem'; export default function LikePage() { const router = useRouter(); - const { data, isLoading } = useLikeSongQuery(); + const { isAuthenticated } = useAuthStore(); + const { data, isLoading } = useLikeSongQuery(isAuthenticated); const { deleteLikeSelected, handleToggleSelect, handleDeleteArray } = useSongInfo(); const likedSongs = data ?? []; diff --git a/apps/web/src/app/info/save/page.tsx b/apps/web/src/app/info/save/page.tsx index 6d5da78e..fe7c610c 100644 --- a/apps/web/src/app/info/save/page.tsx +++ b/apps/web/src/app/info/save/page.tsx @@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; import { useSaveSongFolderQuery } from '@/queries/saveSongFolderQuery'; import { useSaveSongQuery } from '@/queries/saveSongQuery'; +import useAuthStore from '@/stores/useAuthStore'; import AddFolderModal from './AddFolderModal'; import DeleteFolderModal from './DeleteFolderModal'; @@ -20,8 +21,12 @@ import RenameFolderModal from './RenameFolderModal'; type ModalType = null | 'move' | 'delete' | 'addFolder' | 'renameFolder' | 'deleteFolder'; export default function Page() { + const router = useRouter(); + const { isAuthenticated } = useAuthStore(); + // ์ƒํƒœ ๊ด€๋ฆฌ - const { data: saveSongFolders, isLoading: isLoadingSongFolders } = useSaveSongQuery(); + const { data: saveSongFolders, isLoading: isLoadingSongFolders } = + useSaveSongQuery(isAuthenticated); const { data: saveSongFolderList, isLoading: isLoadingSaveFolderList } = useSaveSongFolderQuery(); const isLoading = isLoadingSongFolders || isLoadingSaveFolderList; @@ -32,8 +37,6 @@ export default function Page() { const [selectedFolderId, setSelectedFolderId] = useState(''); const [selectedFolderName, setSelectedFolderName] = useState(''); - const router = useRouter(); - // ์ „์ฒด ์„ ํƒ๋œ ๊ณก ์ˆ˜ ๊ณ„์‚ฐ const totalSelectedSongs = Object.values(selectedSongs).filter(Boolean).length; diff --git a/apps/web/src/app/popular/PopularRankingList.tsx b/apps/web/src/app/popular/PopularRankingList.tsx index 5e1a74d0..27a1a355 100644 --- a/apps/web/src/app/popular/PopularRankingList.tsx +++ b/apps/web/src/app/popular/PopularRankingList.tsx @@ -1,24 +1,33 @@ +'use client'; + import { Construction } from 'lucide-react'; +// import IntervalProgress from '@/components/ui/IntervalProgress'; +import { RotateCw } from 'lucide-react'; import RankingItem from '@/components/RankingItem'; +import StaticLoading from '@/components/StaticLoading'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { ThumbUpSong } from '@/types/song'; +import { useSongThumbQuery } from '@/queries/songThumbQuery'; + +export default function PopularRankingList() { + const { data, isPending, refetch } = useSongThumbQuery(); + + if (isPending) { + return ; + } -interface RankingListProps { - title: string; - songStats: ThumbUpSong[]; -} -export default function PopularRankingList({ title, songStats }: RankingListProps) { return ( - // - - {title} + + ์ถ”์ฒœ ๊ณก ์ˆœ์œ„ + {/* refetch()} isLoading={isFetching} /> */} + + refetch()} className="cursor-pointer hover:animate-spin" />
- {songStats.length > 0 ? ( - songStats.map((item, index) => ( + {data && data.length > 0 ? ( + data.map((item, index) => ( )) ) : ( diff --git a/apps/web/src/app/popular/page.tsx b/apps/web/src/app/popular/page.tsx index 4fc6ae1e..b97552d3 100644 --- a/apps/web/src/app/popular/page.tsx +++ b/apps/web/src/app/popular/page.tsx @@ -1,26 +1,15 @@ -'use client'; - -import { useState } from 'react'; - -import StaticLoading from '@/components/StaticLoading'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { useSongThumbQuery } from '@/queries/songThumbQuery'; import PopularRankingList from './PopularRankingList'; export default function PopularPage() { - const { isPending, data } = useSongThumbQuery(); - - if (isPending || !data) return ; - return (

์ธ๊ธฐ ๋…ธ๋ž˜

{/* ์ถ”์ฒœ ๊ณก ์ˆœ์œ„ */} - +
); diff --git a/apps/web/src/app/search/HomePage.tsx b/apps/web/src/app/search/HomePage.tsx index 25dc02b7..76553a7a 100644 --- a/apps/web/src/app/search/HomePage.tsx +++ b/apps/web/src/app/search/HomePage.tsx @@ -7,10 +7,10 @@ import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { ScrollArea } from '@/components/ui/scroll-area'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import useSearchSong from '@/hooks/useSearchSong'; import { type ChatMessage } from '@/lib/api/openAIchat'; +import useGuestToSingStore from '@/stores/useGuestToSingStore'; import useSearchHistoryStore from '@/stores/useSearchHistoryStore'; import { SearchSong } from '@/types/song'; import { ChatResponseType } from '@/utils/safeParseJson'; @@ -47,17 +47,25 @@ export default function SearchPage() { handleToggleSave, postSaveSong, patchSaveSong, + + isAuthenticated, } = useSearchSong(); const { ref, inView } = useInView(); - let searchSongs: SearchSong[] = []; + const { searchHistory, removeFromHistory } = useSearchHistoryStore(); + const { guestToSingSongs } = useGuestToSingStore(); - if (searchResults) { - searchSongs = searchResults.pages.flatMap(page => page.data); - } + const isToSing = (song: SearchSong, songId: string) => { + if (!isAuthenticated) { + return guestToSingSongs?.some(item => item.songs.id === songId); + } + return song.isToSing; + }; - const { searchHistory, removeFromHistory } = useSearchHistoryStore(); + const searchSongs: SearchSong[] = searchResults + ? searchResults.pages.flatMap(page => page.data) + : []; // ์—”ํ„ฐ ํ‚ค ์ฒ˜๋ฆฌ const handleKeyUp = (e: React.KeyboardEvent) => { @@ -103,7 +111,15 @@ export default function SearchPage() { return (
-

๋…ธ๋ž˜ ๊ฒ€์ƒ‰

+
+

๋…ธ๋ž˜ ๊ฒ€์ƒ‰

+ + {!isAuthenticated && ( + + Guest ์ƒํƒœ์—์„œ๋Š” [๋ถ€๋ฅผ๊ณก ์ถ”๊ฐ€] ๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. + + )} +
@@ -164,8 +180,11 @@ export default function SearchPage() { - handleToggleToSing(song.id, song.isToSing ? 'DELETE' : 'POST') + handleToggleToSing(song, isToSing(song, song.id) ? 'DELETE' : 'POST') } onToggleLike={() => handleToggleLike(song.id, song.isLike ? 'DELETE' : 'POST')} onClickSave={() => handleToggleSave(song, song.isSave ? 'PATCH' : 'POST')} diff --git a/apps/web/src/app/search/SearchResultCard.tsx b/apps/web/src/app/search/SearchResultCard.tsx index e7c316ca..0ac99b21 100644 --- a/apps/web/src/app/search/SearchResultCard.tsx +++ b/apps/web/src/app/search/SearchResultCard.tsx @@ -11,6 +11,9 @@ import { SearchSong } from '@/types/song'; interface IProps { song: SearchSong; + isToSing: boolean; + isLike: boolean; + isSave: boolean; onToggleToSing: () => void; onToggleLike: () => void; onClickSave: () => void; @@ -19,12 +22,15 @@ interface IProps { export default function SearchResultCard({ song, + isToSing, + isLike, + isSave, onToggleToSing, onToggleLike, onClickSave, onClickArtist, }: IProps) { - const { id, title, artist, num_tj, num_ky, isToSing, isLike, isSave } = song; + const { id, title, artist, num_tj, num_ky } = song; const { isAuthenticated } = useAuthStore(); const [open, setOpen] = useState(false); diff --git a/apps/web/src/app/tosing/AddListModal.tsx b/apps/web/src/app/tosing/AddListModal.tsx index 98b826bd..2f46caad 100644 --- a/apps/web/src/app/tosing/AddListModal.tsx +++ b/apps/web/src/app/tosing/AddListModal.tsx @@ -14,6 +14,7 @@ import useAddSongList, { type TabType } from '@/hooks/useAddSongList'; import { useLikeSongQuery } from '@/queries/likeSongQuery'; // import { useSaveSongFolderQuery } from '@/queries/saveSongFolderQuery'; import { useSaveSongQuery } from '@/queries/saveSongQuery'; +import useAuthStore from '@/stores/useAuthStore'; import ModalSongItem from './ModalSongItem'; @@ -32,9 +33,12 @@ export default function AddListModal({ isOpen, onClose }: AddListModalProps) { totalSelectedCount, } = useAddSongList(); - const { data: likedSongs, isLoading: isLoadingLikedSongs } = useLikeSongQuery(); + const { isAuthenticated } = useAuthStore(); - const { data: saveSongFolders, isLoading: isLoadingSongFolders } = useSaveSongQuery(); + const { data: likedSongs, isLoading: isLoadingLikedSongs } = useLikeSongQuery(isAuthenticated); + + const { data: saveSongFolders, isLoading: isLoadingSongFolders } = + useSaveSongQuery(isAuthenticated); const isLoading = isLoadingLikedSongs || isLoadingSongFolders; diff --git a/apps/web/src/app/tosing/AddSongButton.tsx b/apps/web/src/app/tosing/AddSongButton.tsx new file mode 100644 index 00000000..7a0d53b8 --- /dev/null +++ b/apps/web/src/app/tosing/AddSongButton.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { AirplayIcon } from 'lucide-react'; +import { useState } from 'react'; + +import { Button } from '@/components/ui/button'; + +import AddListModal from './AddListModal'; + +export default function AddSongButton() { + const [isModalOpen, setIsModalOpen] = useState(false); + + return ( + <> + + + {/* ๋ชจ๋‹ฌ๋„ ์—ฌ๊ธฐ์„œ ๋ Œ๋”๋ง */} + setIsModalOpen(false)} /> + + ); +} diff --git a/apps/web/src/app/tosing/SongList.tsx b/apps/web/src/app/tosing/SongList.tsx index 08bb5065..810c4c66 100644 --- a/apps/web/src/app/tosing/SongList.tsx +++ b/apps/web/src/app/tosing/SongList.tsx @@ -17,7 +17,7 @@ import { } from '@dnd-kit/sortable'; import StaticLoading from '@/components/StaticLoading'; -import useSong from '@/hooks/useSong'; +import useToSingSong from '@/hooks/useToSingSong'; import { ToSingSong } from '@/types/song'; import SongCard from './SongCard'; @@ -30,7 +30,7 @@ export default function SongList() { handleDelete, handleMoveToTop, handleMoveToBottom, - } = useSong(); + } = useToSingSong(); const sensors = useSensors( useSensor(PointerSensor), diff --git a/apps/web/src/app/tosing/page.tsx b/apps/web/src/app/tosing/page.tsx index 5b71e512..3125f7ab 100644 --- a/apps/web/src/app/tosing/page.tsx +++ b/apps/web/src/app/tosing/page.tsx @@ -1,36 +1,18 @@ -'use client'; - -import { AirplayIcon } from 'lucide-react'; -import { useState } from 'react'; - -import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; -import AddListModal from './AddListModal'; +import AddSongButton from './AddSongButton'; import SongList from './SongList'; export default function HomePage() { - const [isModalOpen, setIsModalOpen] = useState(false); - return (

๋…ธ๋ž˜๋ฐฉ ํ”Œ๋ ˆ์ด๋ฆฌ์ŠคํŠธ

- +
- - setIsModalOpen(false)} />
); } diff --git a/apps/web/src/auth.tsx b/apps/web/src/auth.tsx index 5063375a..ec511c06 100644 --- a/apps/web/src/auth.tsx +++ b/apps/web/src/auth.tsx @@ -5,7 +5,15 @@ import { useEffect, useState } from 'react'; import useAuthStore from '@/stores/useAuthStore'; -const ALLOW_PATHS = ['/', '/popular', '/login', '/signup', '/recent', '/update-password']; +const ALLOW_PATHS = [ + '/', + '/popular', + '/login', + '/signup', + '/recent', + '/tosing', + '/update-password', +]; export default function AuthProvider({ children }: { children: React.ReactNode }) { const router = useRouter(); diff --git a/apps/web/src/components/LoadingOverlay.tsx b/apps/web/src/components/LoadingOverlay.tsx deleted file mode 100644 index 42a011dd..00000000 --- a/apps/web/src/components/LoadingOverlay.tsx +++ /dev/null @@ -1,24 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; - -// import useLoadingStore from '@/stores/useLoadingStore'; - -export default function LoadingOverlay() { - // const { isLoading } = useLoadingStore(); - const [isMounted, setIsMounted] = useState(false); - - useEffect(() => { - setIsMounted(true); - }, []); - - if (!isMounted) return null; - - // if (!isLoading) return null; - - return ( -
-
-
- ); -} diff --git a/apps/web/src/components/StaticLoading.tsx b/apps/web/src/components/StaticLoading.tsx index 6c587cdc..33c05449 100644 --- a/apps/web/src/components/StaticLoading.tsx +++ b/apps/web/src/components/StaticLoading.tsx @@ -3,7 +3,6 @@ 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 8849cbb7..47a1557c 100644 --- a/apps/web/src/components/ThumbUpModal.tsx +++ b/apps/web/src/components/ThumbUpModal.tsx @@ -11,6 +11,7 @@ import { Slider } from '@/components/ui/slider'; import { useSongThumbMutation } from '@/queries/songThumbQuery'; import { useUserQuery } from '@/queries/userQuery'; import { usePatchSetPointMutation } from '@/queries/userQuery'; +import useFooterAnimateStore from '@/stores/useFooterAnimateStore'; import FallingIcons from './FallingIcons'; @@ -29,10 +30,14 @@ export default function ThumbUpModal({ songId, handleClose }: ThumbUpModalProps) const { mutate: patchSongThumb, isPending: isPendingSongThumb } = useSongThumbMutation(); const { mutate: patchSetPoint, isPending: isPendingSetPoint } = usePatchSetPointMutation(); + const { setFooterAnimateKey } = useFooterAnimateStore(); + const handleClickThumb = () => { patchSongThumb({ songId, point: value[0] }); patchSetPoint({ point: point - value[0] }); + setFooterAnimateKey('POPULAR'); + handleClose(); }; @@ -43,7 +48,7 @@ export default function ThumbUpModal({ songId, handleClose }: ThumbUpModalProps) ๋…ธ๋ž˜ ์ถ”์ฒœํ•˜๊ธฐ - +
diff --git a/apps/web/src/components/ui/IntervalProgress.tsx b/apps/web/src/components/ui/IntervalProgress.tsx new file mode 100644 index 00000000..2b05defc --- /dev/null +++ b/apps/web/src/components/ui/IntervalProgress.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { Loader2 } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +import { cn } from '@/utils/cn'; + +interface IntervalProgressProps { + /** + * The duration in milliseconds for the progress to complete. + * @default 5000 + */ + duration?: number; + /** + * Callback function to be called when the progress completes. + */ + onComplete?: () => void; + /** + * The size of the progress circle in pixels. + * @default 24 + */ + size?: number; + /** + * The stroke width of the progress circle. + * @default 3 + */ + strokeWidth?: number; + /** + * Class name for custom styling. + */ + className?: string; + /** + * Whether the timer is currently active. + * @default true + */ + isActive?: boolean; + /** + * Whether the component is in a loading state. + * During loading, the timer pauses and a spinner is shown. + * When loading finishes, the timer resets and restarts. + */ + isLoading?: boolean; +} + +export default function IntervalProgress({ + duration = 5000, + onComplete, + size = 24, + strokeWidth = 3, + className, + isActive = true, + isLoading = false, +}: IntervalProgressProps) { + const [progress, setProgress] = useState(0); + const updateInterval = 50; // Update every 50ms for smooth animation + + useEffect(() => { + // If loading, don't run the timer + // Also, if we just finished loading (isLoading became false), we might want to reset? + // Actually, let's handle reset in a separate effect or here. + if (!isActive || isLoading) return; + + const interval = setInterval(() => { + setProgress(prev => { + const next = prev + (updateInterval / duration) * 100; + if (next >= 100) { + onComplete?.(); + return 100; // Snap to 100, wait for isLoading to become true + } + return next; + }); + }, updateInterval); + + return () => clearInterval(interval); + }, [isActive, duration, onComplete, isLoading]); + + // Reset progress when loading finishes + useEffect(() => { + if (!isLoading) { + setProgress(0); + } + }, [isLoading]); + + const radius = (size - strokeWidth) / 2; + const circumference = radius * 2 * Math.PI; + const dashoffset = circumference - (progress / 100) * circumference; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ + {/* Background Circle */} + + {/* Progress Circle */} + + +
+ ); +} diff --git a/apps/web/src/hooks/useSearchSong.ts b/apps/web/src/hooks/useSearchSong.ts index d429a116..f33011c8 100644 --- a/apps/web/src/hooks/useSearchSong.ts +++ b/apps/web/src/hooks/useSearchSong.ts @@ -9,9 +9,11 @@ import { useToggleToSingMutation, } from '@/queries/searchSongQuery'; import useAuthStore from '@/stores/useAuthStore'; +import useFooterAnimateStore from '@/stores/useFooterAnimateStore'; +import useGuestToSingStore from '@/stores/useGuestToSingStore'; import useSearchHistoryStore from '@/stores/useSearchHistoryStore'; import { Method } from '@/types/common'; -import { SearchSong } from '@/types/song'; +import { SearchSong, Song } from '@/types/song'; type SearchType = 'all' | 'title' | 'artist'; @@ -45,7 +47,9 @@ export default function useSearchSong() { isError, } = useInfiniteSearchSongQuery(query, searchType, isAuthenticated); + const { setFooterAnimateKey } = useFooterAnimateStore(); const { addToHistory } = useSearchHistoryStore(); + const { addGuestToSingSong, removeGuestToSingSong } = useGuestToSingStore(); const handleSearch = () => { // trim ์ œ๊ฑฐ @@ -69,9 +73,14 @@ export default function useSearchSong() { setSearchType(value as SearchType); }; - const handleToggleToSing = async (songId: string, method: Method) => { + const handleToggleToSing = async (song: Song, method: Method) => { if (!isAuthenticated) { - toast.error('๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ด์š”.'); + if (method === 'POST') { + addGuestToSingSong(song); + setFooterAnimateKey('TOSING'); + } else { + removeGuestToSingSong(song.id); + } return; } @@ -79,7 +88,11 @@ export default function useSearchSong() { toast.error('์š”์ฒญ ์ค‘์ž…๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.'); return; } - toggleToSing({ songId, method }); + + if (method === 'POST') { + setFooterAnimateKey('TOSING'); + } + toggleToSing({ songId: song.id, method }); }; const handleToggleLike = async (songId: string, method: Method) => { @@ -92,6 +105,10 @@ export default function useSearchSong() { toast.error('์š”์ฒญ ์ค‘์ž…๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.'); return; } + + if (method === 'POST') { + setFooterAnimateKey('INFO'); + } toggleLike({ songId, method }); }; @@ -110,6 +127,8 @@ export default function useSearchSong() { toast.error('์š”์ฒญ ์ค‘์ž…๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.'); return; } + + setFooterAnimateKey('INFO'); postSong({ songId, folderName, query, searchType }); }; @@ -119,6 +138,7 @@ export default function useSearchSong() { return; } + setFooterAnimateKey('INFO'); moveSong({ songIdArray: [songId], folderId }); }; @@ -145,5 +165,7 @@ export default function useSearchSong() { selectedSaveSong, postSaveSong, patchSaveSong, + + isAuthenticated, }; } diff --git a/apps/web/src/hooks/useSong.ts b/apps/web/src/hooks/useToSingSong.ts similarity index 64% rename from apps/web/src/hooks/useSong.ts rename to apps/web/src/hooks/useToSingSong.ts index fcc3f32c..3ee1d11d 100644 --- a/apps/web/src/hooks/useSong.ts +++ b/apps/web/src/hooks/useToSingSong.ts @@ -7,18 +7,34 @@ import { usePatchToSingSongMutation, useToSingSongQuery, } from '@/queries/tosingSongQuery'; +import useAuthStore from '@/stores/useAuthStore'; +import useGuestToSingStore from '@/stores/useGuestToSingStore'; -export default function useSong() { - const { data, isLoading } = useToSingSongQuery(); +export default function useToSingSong() { + const { isAuthenticated } = useAuthStore(); + const { guestToSingSongs, swapGuestToSingSongs, removeGuestToSingSong } = useGuestToSingStore(); + + const { data, isLoading } = useToSingSongQuery(isAuthenticated, guestToSingSongs); const { mutate: patchToSingSong } = usePatchToSingSongMutation(); const { mutate: deleteToSingSong } = useDeleteToSingSongMutation(); const toSingSongs = data ?? []; const handleDragEnd = (event: DragEndEvent) => { + // ์ผ๋‹จ guest์ผ ๋•Œ๋Š” return ์กฐ์น˜, ํ›„์— local๋‹จ์—์„œ ์ˆœ์„œ ์กฐ์ • ๊ฐ€๋Šฅ const { active, over } = event; if (!over || active.id === over.id) return; + if (!isAuthenticated) { + const oldIndex = toSingSongs.findIndex(item => item.songs.id === active.id); + const newIndex = toSingSongs.findIndex(item => item.songs.id === over.id); + + if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) { + swapGuestToSingSongs(active.id as string, newIndex); + } + return; + } + const oldIndex = toSingSongs.findIndex(item => item.songs.id === active.id); const newIndex = toSingSongs.findIndex(item => item.songs.id === over.id); @@ -49,12 +65,23 @@ export default function useSong() { }; const handleDelete = (songId: string) => { + if (!isAuthenticated) { + removeGuestToSingSong(songId); + return; + } deleteToSingSong(songId); }; const handleMoveToTop = (songId: string, oldIndex: number) => { + // ์ผ๋‹จ guest์ผ ๋•Œ๋Š” return ์กฐ์น˜, ํ›„์— local๋‹จ์—์„œ ์ˆœ์„œ ์กฐ์ • ๊ฐ€๋Šฅ + if (oldIndex === 0) return; + if (!isAuthenticated) { + swapGuestToSingSongs(songId, 0); + return; + } + const newItems = arrayMove(toSingSongs, oldIndex, 0); const newWeight = toSingSongs[0].order_weight - 1; @@ -66,9 +93,16 @@ export default function useSong() { }; const handleMoveToBottom = (songId: string, oldIndex: number) => { + // ์ผ๋‹จ guest์ผ ๋•Œ๋Š” return ์กฐ์น˜, ํ›„์— local๋‹จ์—์„œ ์ˆœ์„œ ์กฐ์ • ๊ฐ€๋Šฅ + const lastIndex = toSingSongs.length - 1; if (oldIndex === lastIndex) return; + if (!isAuthenticated) { + swapGuestToSingSongs(songId, lastIndex); + return; + } + const newItems = arrayMove(toSingSongs, oldIndex, lastIndex); const newWeight = toSingSongs[lastIndex].order_weight + 1; diff --git a/apps/web/src/lib/api/tosing.ts b/apps/web/src/lib/api/tosing.ts index cb20957d..ed25688a 100644 --- a/apps/web/src/lib/api/tosing.ts +++ b/apps/web/src/lib/api/tosing.ts @@ -8,6 +8,13 @@ export async function getToSingSong() { return response.data; } +export async function getToSingSongGuest(songIds: string[]) { + const response = await instance.get>('/songs/tosing/guest', { + params: { songIds }, + }); + return response.data; +} + export async function patchToSingSong(body: { songId: string; newWeight: number }) { const response = await instance.patch>('/songs/tosing', body); return response.data; diff --git a/apps/web/src/queries/likeSongQuery.ts b/apps/web/src/queries/likeSongQuery.ts index 328169a6..8f972386 100644 --- a/apps/web/src/queries/likeSongQuery.ts +++ b/apps/web/src/queries/likeSongQuery.ts @@ -4,7 +4,7 @@ import { deleteLikeSongArray, getLikeSong } from '@/lib/api/likeSong'; import { PersonalSong } from '@/types/song'; // ๐ŸŽต ์ข‹์•„์š” ํ•œ ๊ณก ๋ฆฌ์ŠคํŠธ ๊ฐ€์ ธ์˜ค๊ธฐ -export function useLikeSongQuery() { +export function useLikeSongQuery(isAuthenticated: boolean) { return useQuery({ queryKey: ['likeSong'], queryFn: async () => { @@ -14,8 +14,7 @@ export function useLikeSongQuery() { } return response.data || []; }, - staleTime: 1000 * 60 * 5, - gcTime: 1000 * 60 * 5, + enabled: isAuthenticated, }); } diff --git a/apps/web/src/queries/saveSongQuery.ts b/apps/web/src/queries/saveSongQuery.ts index a0b98704..ac3e4eff 100644 --- a/apps/web/src/queries/saveSongQuery.ts +++ b/apps/web/src/queries/saveSongQuery.ts @@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { deleteSaveSong, getSaveSong, patchSaveSong } from '@/lib/api/saveSong'; import { SaveSong, SaveSongFolder } from '@/types/song'; -export function useSaveSongQuery() { +export function useSaveSongQuery(isAuthenticated: boolean) { return useQuery({ queryKey: ['saveSongFolder'], queryFn: async () => { @@ -30,8 +30,7 @@ export function useSaveSongQuery() { return songFolders; }, - staleTime: 1000 * 60 * 5, - gcTime: 1000 * 60 * 5, + enabled: isAuthenticated, }); } diff --git a/apps/web/src/queries/searchSongQuery.ts b/apps/web/src/queries/searchSongQuery.ts index ff47fa19..2eed3c18 100644 --- a/apps/web/src/queries/searchSongQuery.ts +++ b/apps/web/src/queries/searchSongQuery.ts @@ -133,7 +133,6 @@ export const useToggleToSingMutation = (query: string, searchType: string) => { queryKey: ['searchSong', query, searchType], }); queryClient.invalidateQueries({ queryKey: ['toSingSong'] }); - queryClient.invalidateQueries({ queryKey: ['recentSingLog'] }); }, 1000); }, }); @@ -185,7 +184,6 @@ export const useToggleLikeMutation = (query: string, searchType: string) => { queryKey: ['searchSong', query, searchType], }); queryClient.invalidateQueries({ queryKey: ['likeSong'] }); - queryClient.invalidateQueries({ queryKey: ['recentSingLog'] }); }, 1000); }, }); @@ -230,7 +228,6 @@ export const useSaveMutation = () => { }); queryClient.invalidateQueries({ queryKey: ['saveSongFolder'] }); queryClient.invalidateQueries({ queryKey: ['saveSongFolderList'] }); - queryClient.invalidateQueries({ queryKey: ['recentSingLog'] }); }, }); }; diff --git a/apps/web/src/queries/songThumbQuery.ts b/apps/web/src/queries/songThumbQuery.ts index 34999af8..68fb4c91 100644 --- a/apps/web/src/queries/songThumbQuery.ts +++ b/apps/web/src/queries/songThumbQuery.ts @@ -9,11 +9,12 @@ export const useSongThumbQuery = () => { const response = await getSongThumbList(); if (!response.success) { - return null; + return []; } return response.data; }, - staleTime: 1000 * 60, + staleTime: 0, // ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ›์ž๋งˆ์ž "์ƒํ•œ ๊ฒƒ(Stale)"์œผ๋กœ ์ทจ๊ธ‰ -> ๋‹ค์‹œ ์กฐํšŒํ•  ๋ช…๋ถ„ ์ƒ์„ฑ + gcTime: 0, // (๊ตฌ cacheTime) ์–ธ๋งˆ์šดํŠธ ๋˜๋Š” ์ฆ‰์‹œ ๋ฉ”๋ชจ๋ฆฌ์—์„œ ์‚ญ์ œ -> ์บ์‹œ๊ฐ€ ์—†์œผ๋‹ˆ ๋ฌด์กฐ๊ฑด ์ƒˆ๋กœ ์š”์ฒญ }); }); }; diff --git a/apps/web/src/queries/tosingSongQuery.ts b/apps/web/src/queries/tosingSongQuery.ts index fe6c70f3..18b3601b 100644 --- a/apps/web/src/queries/tosingSongQuery.ts +++ b/apps/web/src/queries/tosingSongQuery.ts @@ -8,26 +8,30 @@ import { } from '@/lib/api/tosing'; import { ToSingSong } from '@/types/song'; -let invalidateTimeout: NodeJS.Timeout | null = null; +// let invalidateTimeout: NodeJS.Timeout | null = null; -// ๐ŸŽต ๋ถ€๋ฅผ ๋…ธ๋ž˜ ๋ชฉ๋ก ๊ฐ€์ ธ์˜ค๊ธฐ -export function useToSingSongQuery() { +// ๋ถ€๋ฅผ ๋…ธ๋ž˜ ๋ชฉ๋ก ๊ฐ€์ ธ์˜ค๊ธฐ +export function useToSingSongQuery(isAuthenticated: boolean, guestToSingSongs: ToSingSong[]) { return useQuery({ - queryKey: ['toSingSong'], + queryKey: isAuthenticated + ? ['toSingSong'] + : ['toSingSong', 'guest', guestToSingSongs.map(song => song.songs.id)], queryFn: async () => { - const response = await getToSingSong(); - if (!response.success) { - return []; + if (isAuthenticated) { + const response = await getToSingSong(); + if (!response.success) { + return []; + } + return response.data || []; + } else { + // ๊ฒŒ์ŠคํŠธ์˜ ๊ฒฝ์šฐ ๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€ ๋ฐ์ดํ„ฐ ๋ฐ˜ํ™˜ (์„œ๋ฒ„ ์š”์ฒญ X) + return guestToSingSongs; } - return response.data || []; }, - // DB์˜ ๊ฐ’์€ ๊ณ ์ •๋œ ๊ฐ’์ด๋ฏ€๋กœ ์บ์‹œ๋ฅผ ์œ ์ง€ํ•œ๋‹ค - staleTime: 1000 * 60 * 5, - gcTime: 1000 * 60 * 5, }); } -// ๐ŸŽต ๋ถ€๋ฅผ ๋…ธ๋ž˜ ์ถ”๊ฐ€ +// ๋ถ€๋ฅผ ๋…ธ๋ž˜ ์ถ”๊ฐ€ export function usePostToSingSongMutation() { const queryClient = useQueryClient(); @@ -35,9 +39,6 @@ export function usePostToSingSongMutation() { mutationFn: (songIds: string[]) => postToSingSongArray({ songIds }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['toSingSong'] }); - queryClient.invalidateQueries({ queryKey: ['likeSong'] }); - queryClient.invalidateQueries({ queryKey: ['saveSongFolder'] }); - queryClient.invalidateQueries({ queryKey: ['recentSingLog'] }); queryClient.invalidateQueries({ queryKey: ['searchSong'] }); }, onError: error => { @@ -47,27 +48,7 @@ export function usePostToSingSongMutation() { }); } -// ๐ŸŽต ์—ฌ๋Ÿฌ ๊ณก ๋ถ€๋ฅผ ๋…ธ๋ž˜ ์ถ”๊ฐ€ -export function usePostToSingSongArrayMutation() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (songIds: string[]) => postToSingSongArray({ songIds }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['toSingSong'] }); - queryClient.invalidateQueries({ queryKey: ['likeSong'] }); - queryClient.invalidateQueries({ queryKey: ['saveSongFolder'] }); - queryClient.invalidateQueries({ queryKey: ['recentSingLog'] }); - queryClient.invalidateQueries({ queryKey: ['searchSong'] }); - }, - onError: error => { - console.error('error', error); - alert(error.message ?? 'POST ์‹คํŒจ'); - }, - }); -} - -// ๐ŸŽต ๋ถ€๋ฅผ ๋…ธ๋ž˜ ์‚ญ์ œ +// ๋ถ€๋ฅผ ๋…ธ๋ž˜ ์‚ญ์ œ export function useDeleteToSingSongMutation() { const queryClient = useQueryClient(); @@ -76,9 +57,9 @@ export function useDeleteToSingSongMutation() { onMutate: async (songId: string) => { queryClient.cancelQueries({ queryKey: ['toSingSong'] }); const prev = queryClient.getQueryData(['toSingSong']); - queryClient.setQueryData(['toSingSong'], (old: ToSingSong[]) => - old.filter(song => song.songs.id !== songId), - ); + queryClient.setQueryData(['toSingSong'], (old: ToSingSong[]) => { + old.filter(song => song.songs.id !== songId); + }); return { prev }; }, onError: (error, variables, context) => { @@ -88,21 +69,19 @@ export function useDeleteToSingSongMutation() { }, onSettled: () => { // 1์ดˆ ์ด๋‚ด์— ํ•จ์ˆ˜๊ฐ€ ์—ฌ๋Ÿฌ ๋ฒˆ ํ˜ธ์ถœ๋˜๋ฉด, 1์ดˆ ๋’ค ํŠธ๋ฆฌ๊ฑฐ๋ฅผ ๊ณ„์†ํ•ด์„œ ๊ฐฑ์‹  - if (invalidateTimeout) { - clearTimeout(invalidateTimeout); - } - invalidateTimeout = setTimeout(() => { - queryClient.invalidateQueries({ queryKey: ['toSingSong'] }); - queryClient.invalidateQueries({ queryKey: ['likeSong'] }); - queryClient.invalidateQueries({ queryKey: ['saveSongFolder'] }); - queryClient.invalidateQueries({ queryKey: ['recentSingLog'] }); - queryClient.invalidateQueries({ queryKey: ['searchSong'] }); - }, 1000); + // if (invalidateTimeout) { + // clearTimeout(invalidateTimeout); + // } + // invalidateTimeout = setTimeout(() => { + // queryClient.invalidateQueries({ queryKey: ['toSingSong'] }); + // }, 1000); + queryClient.invalidateQueries({ queryKey: ['searchSong'] }); + queryClient.invalidateQueries({ queryKey: ['toSingSong'] }); }, }); } -// ๐ŸŽต ๋ถ€๋ฅผ ๋…ธ๋ž˜ ์ˆœ์„œ ๋ณ€๊ฒฝ +// ๋ถ€๋ฅผ ๋…ธ๋ž˜ ์ˆœ์„œ ๋ณ€๊ฒฝ export function usePatchToSingSongMutation() { const queryClient = useQueryClient(); diff --git a/apps/web/src/query.tsx b/apps/web/src/query.tsx index c6d8b5b5..e77c2aec 100644 --- a/apps/web/src/query.tsx +++ b/apps/web/src/query.tsx @@ -1,6 +1,7 @@ 'use client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { useState } from 'react'; export default function QueryProvider({ children }: { children: React.ReactNode }) { @@ -19,5 +20,10 @@ export default function QueryProvider({ children }: { children: React.ReactNode }), ); - return {children}; + return ( + + {children} + {process.env.NODE_ENV === 'development' && } + + ); } diff --git a/apps/web/src/stores/useFooterAnimateStore.ts b/apps/web/src/stores/useFooterAnimateStore.ts new file mode 100644 index 00000000..060208d9 --- /dev/null +++ b/apps/web/src/stores/useFooterAnimateStore.ts @@ -0,0 +1,36 @@ +import { create } from 'zustand'; + +export type FooterKey = 'SEARCH' | 'RECENT' | 'TOSING' | 'POPULAR' | 'INFO' | null; + +interface FooterStore { + footerAnimateKey: FooterKey; + timeoutId: ReturnType | null; + setFooterAnimateKey: (key: FooterKey) => void; +} + +const initialState = { + footerAnimateKey: null, + timeoutId: null, +}; + +const useFooterAnimateStore = create((set, get) => ({ + ...initialState, + + setFooterAnimateKey: key => { + const { timeoutId } = get(); + + if (timeoutId) { + clearTimeout(timeoutId); + } + + set({ footerAnimateKey: key }); + + const newTimeoutId = setTimeout(() => { + set({ footerAnimateKey: null, timeoutId: null }); + }, 300); + + set({ timeoutId: newTimeoutId }); + }, +})); + +export default useFooterAnimateStore; diff --git a/apps/web/src/stores/useGuestToSingStore.ts b/apps/web/src/stores/useGuestToSingStore.ts new file mode 100644 index 00000000..ae6d615a --- /dev/null +++ b/apps/web/src/stores/useGuestToSingStore.ts @@ -0,0 +1,67 @@ +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +import { Song, ToSingSong } from '@/types/song'; + +interface GuestToSingState { + guestToSingSongs: ToSingSong[]; + addGuestToSingSong: (song: Song) => void; + removeGuestToSingSong: (songId: string) => void; + swapGuestToSingSongs: (targetId: string, moveIndex: number) => void; + clearGuestToSingSongs: () => void; +} + +const GUEST_TO_SING_KEY = 'guest_to_sing'; + +const initialState = { + guestToSingSongs: [] as ToSingSong[], +}; + +const useGuestToSingStore = create( + persist( + set => ({ + ...initialState, + addGuestToSingSong: (song: Song) => { + set(state => { + // ์ค‘๋ณต ๋ฐฉ์ง€ + if (state.guestToSingSongs.some(item => item.songs.id === song.id)) return state; + + const newToSingSong: ToSingSong = { + order_weight: 0, // ๋กœ์ปฌ์—์„œ๋Š” index๊ฐ€ ์ˆœ์„œ์ด๋ฏ€๋กœ weight๋Š” ์˜๋ฏธ ์—†์Œ (0์œผ๋กœ ๊ณ ์ •) + songs: song, // song ๊ฐ์ฒด ์ „์ฒด ์ €์žฅ + }; + + return { guestToSingSongs: [...state.guestToSingSongs, newToSingSong] }; + }); + }, + removeGuestToSingSong: (songId: string) => { + set(state => ({ + guestToSingSongs: state.guestToSingSongs.filter(item => item.songs.id !== songId), + })); + }, + swapGuestToSingSongs: (targetId: string, moveIndex: number) => { + set(state => { + if (moveIndex < 0 || moveIndex >= state.guestToSingSongs.length) return state; + const newSongs = [...state.guestToSingSongs]; + const targetIndex = newSongs.findIndex(item => item.songs.id === targetId); + + if (targetIndex === -1) return state; + + const [movedItem] = newSongs.splice(targetIndex, 1); + newSongs.splice(moveIndex, 0, movedItem); + + return { guestToSingSongs: newSongs }; + }); + }, + clearGuestToSingSongs: () => { + set(initialState); + }, + }), + { + name: GUEST_TO_SING_KEY, + storage: createJSONStorage(() => localStorage), + }, + ), +); + +export default useGuestToSingStore; diff --git a/apps/web/src/types/apiRoute.ts b/apps/web/src/types/apiRoute.ts index 239cc839..1de85126 100644 --- a/apps/web/src/types/apiRoute.ts +++ b/apps/web/src/types/apiRoute.ts @@ -1,9 +1,18 @@ -export interface ApiSuccessResponse { +// export interface ApiSuccessResponse { +// success: true; +// data?: T; +// hasNext?: boolean; +// // data: T; ํƒ€์ž… ์—๋Ÿฌ +// } + +// ์กฐ๊ฑด๋ถ€ ํƒ€์ž… ์ ์šฉ +// T๊ฐ€ void(๋น„์–ด์žˆ์Œ)๋ฉด data ํ•„๋“œ ์ž์ฒด๋ฅผ ์—†์• ๊ณ (๋˜๋Š” optional never), T๊ฐ€ ์žˆ์œผ๋ฉด data๋ฅผ ํ•„์ˆ˜(Required)๋กœ ๊ตฌ์„ฑ. +export type ApiSuccessResponse = { success: true; - data?: T; hasNext?: boolean; - // data: T; ํƒ€์ž… ์—๋Ÿฌ -} +} & (T extends void + ? { data?: never } // T๊ฐ€ void๋ฉด data๋Š” ์—†์–ด์•ผ ํ•จ + : { data: T }); // T๊ฐ€ ์žˆ์œผ๋ฉด data๋Š” ํ•„์ˆ˜ export interface ApiErrorResponse { success: false; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7549fd6..37d1aa45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -200,7 +200,7 @@ importers: specifier: ^5.68.0 version: 5.90.16(react@19.2.3) '@tanstack/react-query-devtools': - specifier: ^5.68.0 + specifier: ^5.91.2 version: 5.91.2(@tanstack/react-query@5.90.16(react@19.2.3))(react@19.2.3) '@vercel/analytics': specifier: ^1.5.0 @@ -220,6 +220,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + framer-motion: + specifier: ^12.33.0 + version: 12.33.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) gsap: specifier: ^3.14.2 version: 3.14.2 @@ -4564,8 +4567,8 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} - framer-motion@12.29.0: - resolution: {integrity: sha512-1gEFGXHYV2BD42ZPTFmSU9buehppU+bCuOnHU0AD18DKh9j4DuTx47MvqY5ax+NNWRtK32qIcJf1UxKo1WwjWg==} + framer-motion@12.33.0: + resolution: {integrity: sha512-ca8d+rRPcDP5iIF+MoT3WNc0KHJMjIyFAbtVLvM9eA7joGSpeqDfiNH/kCs1t4CHi04njYvWyj0jS4QlEK/rJQ==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -5836,11 +5839,11 @@ packages: mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} - motion-dom@12.29.0: - resolution: {integrity: sha512-3eiz9bb32yvY8Q6XNM4AwkSOBPgU//EIKTZwsSWgA9uzbPBhZJeScCVcBuwwYVqhfamewpv7ZNmVKTGp5qnzkA==} + motion-dom@12.33.0: + resolution: {integrity: sha512-XRPebVypsl0UM+7v0Hr8o9UAj0S2djsQWRdHBd5iVouVpMrQqAI0C/rDAT3QaYnXnHuC5hMcwDHCboNeyYjPoQ==} - motion-utils@12.27.2: - resolution: {integrity: sha512-B55gcoL85Mcdt2IEStY5EEAsrMSVE2sI14xQ/uAdPL+mfQxhKKFaEag9JmfxedJOR4vZpBGoPeC/Gm13I/4g5Q==} + motion-utils@12.29.2: + resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==} motion@12.29.0: resolution: {integrity: sha512-rjB5CP2N9S2ESAyEFnAFMgTec6X8yvfxLNcz8n12gPq3M48R7ZbBeVYkDOTj8SPMwfvGIFI801SiPSr1+HCr9g==} @@ -12824,10 +12827,10 @@ snapshots: fraction.js@5.3.4: {} - framer-motion@12.29.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + framer-motion@12.33.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: - motion-dom: 12.29.0 - motion-utils: 12.27.2 + motion-dom: 12.33.0 + motion-utils: 12.29.2 tslib: 2.8.1 optionalDependencies: react: 19.2.3 @@ -14441,15 +14444,15 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.1 - motion-dom@12.29.0: + motion-dom@12.33.0: dependencies: - motion-utils: 12.27.2 + motion-utils: 12.29.2 - motion-utils@12.27.2: {} + motion-utils@12.29.2: {} motion@12.29.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: - framer-motion: 12.29.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + framer-motion: 12.33.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) tslib: 2.8.1 optionalDependencies: react: 19.2.3