diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..35d03c3 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,84 @@ +name: Deploy to AWS S3 + CloudFront + +on: + push: + branches: + - prod + pull_request: + branches: + - prod + types: [closed] + workflow_dispatch: # 수동 실행 가능 + +env: + AWS_REGION: ap-northeast-2 + S3_BUCKET: ${{ secrets.S3_BUCKET_NAME }} + CLOUDFRONT_DISTRIBUTION_ID: ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + # PR이 머지되었을 때만 실행 (closed + merged) + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.merged == true) + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build project + run: npm run build + env: + VITE_API_URL: ${{ secrets.VITE_API_URL }} + VITE_WS_URL: ${{ secrets.VITE_WS_URL }} + VITE_GRAMMAR_WS_URL: ${{ secrets.VITE_GRAMMAR_WS_URL }} + VITE_AWS_REGION: ${{ secrets.VITE_AWS_REGION }} + VITE_COGNITO_REGION: ${{ secrets.VITE_AWS_REGION }} + VITE_USER_POOL_ID: ${{ secrets.VITE_USER_POOL_ID }} + VITE_USER_POOL_CLIENT_ID: ${{ secrets.VITE_USER_POOL_CLIENT_ID }} + VITE_COGNITO_POOL_ID: ${{ secrets.VITE_USER_POOL_ID }} + VITE_COGNITO_CLIENT_ID: ${{ secrets.VITE_USER_POOL_CLIENT_ID }} + VITE_APP_ENV: production + VITE_USE_MOCK: false + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Deploy to S3 + run: | + aws s3 sync dist/ s3://${{ env.S3_BUCKET }} \ + --delete \ + --cache-control "public, max-age=31536000" \ + --exclude "index.html" \ + --exclude "*.json" + + # index.html과 JSON은 캐시하지 않음 + aws s3 cp dist/index.html s3://${{ env.S3_BUCKET }}/index.html \ + --cache-control "no-cache, no-store, must-revalidate" + + # manifest.json 등 + aws s3 sync dist/ s3://${{ env.S3_BUCKET }} \ + --exclude "*" \ + --include "*.json" \ + --cache-control "no-cache" + + - name: Invalidate CloudFront cache + run: | + aws cloudfront create-invalidation \ + --distribution-id ${{ env.CLOUDFRONT_DISTRIBUTION_ID }} \ + --paths "/*" + + - name: Deployment complete + run: echo "Deployment to S3 and CloudFront completed successfully!" diff --git a/.github/workflows/github-jira-issue-sync.yml b/.github/workflows/github-jira-issue-sync.yml index 21d30e9..ddca7c2 100644 --- a/.github/workflows/github-jira-issue-sync.yml +++ b/.github/workflows/github-jira-issue-sync.yml @@ -2,8 +2,8 @@ name: Issue-Jira Sync on: issues: - types: [opened, reopened, edited, closed ] - + types: [ opened, reopened, edited, closed ] + workflow_dispatch: inputs: process_all_open_issues: @@ -126,34 +126,51 @@ jobs: if: steps.check-jira.outputs.result == 'false' id: description uses: actions/github-script@v7 + env: + TEMPLATE_TYPE: ${{ steps.parse.outputs.template_type }} + ISSUE_URL: ${{ github.event.issue.html_url }} + ISSUE_AUTHOR: ${{ github.event.issue.user.login }} + FIELD_GOAL: ${{ steps.parse.outputs.goal }} + FIELD_SCOPE: ${{ steps.parse.outputs.scope }} + FIELD_BREAKDOWN: ${{ steps.parse.outputs.breakdown }} + FIELD_BACKGROUND: ${{ steps.parse.outputs.background }} + FIELD_AC: ${{ steps.parse.outputs.ac }} + FIELD_DESIGN: ${{ steps.parse.outputs.design }} + FIELD_PARENT: ${{ steps.parse.outputs.parent }} + FIELD_CHANGE: ${{ steps.parse.outputs.change }} + FIELD_IMPACT: ${{ steps.parse.outputs.impact }} + FIELD_TIMEBOX: ${{ steps.parse.outputs.timebox }} + FIELD_QUESTIONS: ${{ steps.parse.outputs.questions }} with: script: | - const templateType = '${{ steps.parse.outputs.template_type }}'; + const templateType = process.env.TEMPLATE_TYPE || 'task'; + const issueUrl = process.env.ISSUE_URL || ''; + const author = process.env.ISSUE_AUTHOR || ''; - let desc = `h3. GitHub Issue\n${{ github.event.issue.html_url }}\n\nh3. Author\n${{ github.event.issue.user.login }}\n\n`; + let desc = 'h3. GitHub Issue\n' + issueUrl + '\n\nh3. Author\n' + author + '\n\n'; switch (templateType) { case 'epic': - desc += `h3. 목표\n${{ steps.parse.outputs.goal }}\n\n`; - desc += `h3. 범위\n${{ steps.parse.outputs.scope }}\n\n`; - desc += `h3. 하위 스토리\n${{ steps.parse.outputs.breakdown }}\n`; + desc += 'h3. 목표\n' + (process.env.FIELD_GOAL || '-') + '\n\n'; + desc += 'h3. 범위\n' + (process.env.FIELD_SCOPE || '-') + '\n\n'; + desc += 'h3. 하위 스토리\n' + (process.env.FIELD_BREAKDOWN || '-') + '\n'; break; case 'story': - desc += `h3. 배경\n${{ steps.parse.outputs.background }}\n\n`; - desc += `h3. 수용 기준(AC)\n${{ steps.parse.outputs.ac }}\n\n`; - desc += `h3. 디자인\n${{ steps.parse.outputs.design }}\n`; + desc += 'h3. 배경\n' + (process.env.FIELD_BACKGROUND || '-') + '\n\n'; + desc += 'h3. 수용 기준(AC)\n' + (process.env.FIELD_AC || '-') + '\n\n'; + desc += 'h3. 디자인\n' + (process.env.FIELD_DESIGN || '-') + '\n'; break; case 'cr': - desc += `h3. 제안 변경 사항\n${{ steps.parse.outputs.change }}\n\n`; - desc += `h3. 영향도\n${{ steps.parse.outputs.impact }}\n`; + desc += 'h3. 제안 변경 사항\n' + (process.env.FIELD_CHANGE || '-') + '\n\n'; + desc += 'h3. 영향도\n' + (process.env.FIELD_IMPACT || '-') + '\n'; break; case 'spike': - desc += `h3. 타임박스\n${{ steps.parse.outputs.timebox }}\n\n`; - desc += `h3. 핵심 질문\n${{ steps.parse.outputs.questions }}\n`; + desc += 'h3. 타임박스\n' + (process.env.FIELD_TIMEBOX || '-') + '\n\n'; + desc += 'h3. 핵심 질문\n' + (process.env.FIELD_QUESTIONS || '-') + '\n'; break; - default: // task - desc += `h3. 연결된 Story/Epic\n${{ steps.parse.outputs.parent }}\n\n`; - desc += `h3. 작업 범위\n${{ steps.parse.outputs.scope }}\n`; + default: + desc += 'h3. 연결된 Story/Epic\n' + (process.env.FIELD_PARENT || '-') + '\n\n'; + desc += 'h3. 작업 범위\n' + (process.env.FIELD_SCOPE || '-') + '\n'; } core.setOutput('content', desc); @@ -289,11 +306,12 @@ jobs: JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + DESC_JSON: ${{ steps.parse.outputs.description }} with: script: | const jiraKey = '${{ steps.get-jira-key.outputs.jira_key }}'; const issue = context.payload.issue; - const descContent = ${{ steps.parse.outputs.description }}; + const descContent = JSON.parse(process.env.DESC_JSON); const response = await fetch( `${process.env.JIRA_BASE_URL}/rest/api/3/issue/${jiraKey}`, @@ -456,7 +474,7 @@ jobs: }) } ); - + # 기존 이슈 일괄 처리 sync-all-open-issues: if: github.event_name == 'workflow_dispatch' @@ -486,45 +504,45 @@ jobs: repo: context.repo.repo, issue_number: issue.number }); - + const hasJiraLink = comments.data.some(c => c.body.includes('Jira:')); if (hasJiraLink) { console.log(`Issue #${issue.number} already has Jira link, skipping`); continue; } - + console.log(`Processing Issue #${issue.number}: ${issue.title}`); - + const labels = issue.labels.map(l => l.name); const title = issue.title; const body = issue.body || ''; - + // Jira Type 결정 let jiraType = 'Task'; if (labels.includes('epic') || /^\[EPIC\]/i.test(title)) jiraType = 'Epic'; else if (labels.includes('story') || /^\[STORY\]/i.test(title)) jiraType = 'Story'; else if (labels.includes('bug') || /^\[BUG\]/i.test(title)) jiraType = 'Bug'; - + // 템플릿 타입 판단 let templateType = 'task'; if (labels.includes('epic') || /^\[EPIC\]/i.test(title)) templateType = 'epic'; else if (labels.includes('story') || /^\[STORY\]/i.test(title)) templateType = 'story'; else if (labels.includes('change-request') || /^\[CR\]/i.test(title)) templateType = 'cr'; else if (labels.includes('spike') || /^\[SPIKE\]/i.test(title)) templateType = 'spike'; - + // 섹션 파싱 const parseSection = (label) => { const regex = new RegExp(`### ${label}\\s*\\n([\\s\\S]*?)(?=###|$)`); const match = body.match(regex); return match ? match[1].trim() : '-'; }; - + // description 구성 const descContent = [ { type: 'paragraph', content: [{ type: 'text', text: `GitHub Issue: ${issue.html_url}` }] }, { type: 'paragraph', content: [{ type: 'text', text: `Author: ${issue.user.login}` }] } ]; - + switch (templateType) { case 'epic': descContent.push( @@ -566,7 +584,7 @@ jobs: { type: 'paragraph', content: [{ type: 'text', text: parseSection('작업 범위') }] } ); } - + const jiraResponse = await fetch( `${process.env.JIRA_BASE_URL}/rest/api/3/issue`, { @@ -589,9 +607,9 @@ jobs: }) } ); - + const jiraData = await jiraResponse.json(); - + if (jiraData.key) { await github.rest.issues.createComment({ owner: context.repo.owner, @@ -603,7 +621,6 @@ jobs: } else { console.log(`Failed to create Jira for Issue #${issue.number}:`, jiraData); } - + await new Promise(resolve => setTimeout(resolve, 1000)); } - \ No newline at end of file diff --git a/.github/workflows/github-jira-pr-sync.yml b/.github/workflows/github-jira-pr-sync.yml index f1d3aea..d99c58e 100644 --- a/.github/workflows/github-jira-pr-sync.yml +++ b/.github/workflows/github-jira-pr-sync.yml @@ -204,11 +204,13 @@ jobs: JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + DESC_JSON: ${{ steps.parse.outputs.description }} + with: script: | const jiraKey = '${{ steps.get-jira-key.outputs.jira_key }}'; const pr = context.payload.pull_request; - const descContent = ${{ steps.parse.outputs.description }}; + const descContent = JSON.parse(process.env.DESC_JSON); const response = await fetch( `${process.env.JIRA_BASE_URL}/rest/api/3/issue/${jiraKey}`, @@ -486,4 +488,4 @@ jobs: } await new Promise(resolve => setTimeout(resolve, 1000)); - } \ No newline at end of file + } diff --git a/.gitignore b/.gitignore index 884f4e4..f1bde73 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,9 @@ workbox-*.js .claude/ CLAUDE.md .claudeignore + +# Terraform +infra/.terraform/ +infra/*.tfstate +infra/*.tfstate.backup +infra/*.tfvars diff --git a/docs/ISSUE_GUIDE.md b/docs/ISSUE_GUIDE.md deleted file mode 100644 index c5bef69..0000000 --- a/docs/ISSUE_GUIDE.md +++ /dev/null @@ -1,378 +0,0 @@ -# Issue & Branch Guide - -프론트엔드 이슈 발행 및 브랜치 관리 가이드 - ---- - -## 이슈 계층 구조 - -``` -Epic (에픽) - └── Story (스토리) - └── Task (태스크) -``` - -| 계층 | 설명 | 예시 | -|-----------|----------------------|--------------------------| -| **Epic** | 대규모 기능 단위 (1~2주 이상) | 메인 레이아웃 구현, 인증 시스템 구축 | -| **Story** | 사용자 관점의 기능 단위 (2~5일) | Header 컴포넌트 개발, 로그인 폼 구현 | -| **Task** | 개발자 작업 단위 (1일 이내) | 로고 배치, 네비게이션 메뉴 구현 | - ---- - -## 이슈 라벨 - -### 계층 라벨 (필수) - -| 라벨 | 색상 | 설명 | -|---------|----------------|--------| -| `epic` | `#6B21A8` (보라) | 에픽 이슈 | -| `story` | `#2563EB` (파랑) | 스토리 이슈 | -| `task` | `#16A34A` (초록) | 태스크 이슈 | - -### 타입 라벨 - -| 라벨 | 설명 | -|---------------|--------| -| `feature` | 새 기능 | -| `bug` | 버그 수정 | -| `enhancement` | 개선 | -| `docs` | 문서 | -| `refactor` | 리팩토링 | -| `style` | UI/스타일 | - -### 도메인 라벨 - -| 라벨 | 설명 | -|--------------------|------| -| `domain:auth` | 인증 | -| `domain:user` | 회원 | -| `domain:profile` | 프로필 | -| `domain:chat` | 채팅 | -| `domain:interview` | 면접 | -| `domain:opic` | OPIC | -| `domain:freetalk` | 프리토킹 | -| `domain:writing` | 작문 | -| `domain:report` | 리포트 | -| `domain:layout` | 레이아웃 | - ---- - -## 이슈 템플릿 - -### Epic 템플릿 - -```markdown -## Epic: [에픽 제목] - -### 목표 -[이 에픽을 통해 달성하려는 목표] - -### 범위 -- [포함되는 기능 1] -- [포함되는 기능 2] - -### Stories -- [ ] #스토리이슈번호 - 스토리 제목 -- [ ] #스토리이슈번호 - 스토리 제목 - -### 완료 조건 -- [ ] 모든 스토리 완료 -- [ ] 코드 리뷰 완료 -- [ ] 테스트 통과 -``` - -### Story 템플릿 - -```markdown -## Story: [스토리 제목] - -### 상위 Epic -#에픽이슈번호 - -### 사용자 스토리 -[역할]로서, [기능]을 원한다. 그래서 [가치]를 얻을 수 있다. - -### 상세 설명 -[구현해야 할 내용 상세 설명] - -### Tasks -- [ ] #태스크이슈번호 - 태스크 제목 -- [ ] #태스크이슈번호 - 태스크 제목 - -### 완료 조건 -- [ ] 기능 구현 완료 -- [ ] 반응형 대응 -- [ ] 코드 리뷰 완료 -``` - -### Task 템플릿 - -```markdown -## Task: [태스크 제목] - -### 상위 Story -#스토리이슈번호 - -### 작업 내용 -[구체적인 작업 내용] - -### 체크리스트 -- [ ] 구현 -- [ ] 테스트 -- [ ] 코드 정리 - -### 관련 파일 -- `src/path/to/file.jsx` -``` - ---- - -## 이슈 발행 CLI 명령어 - -### 라벨 생성 (최초 1회) - -```bash -# 계층 라벨 -gh label create "epic" --color "6B21A8" --description "에픽 이슈" -gh label create "story" --color "2563EB" --description "스토리 이슈" -gh label create "task" --color "16A34A" --description "태스크 이슈" - -# 타입 라벨 -gh label create "feature" --color "1D76DB" --description "새 기능" -gh label create "bug" --color "D73A4A" --description "버그 수정" -gh label create "enhancement" --color "A2EEEF" --description "개선" - -# 도메인 라벨 -gh label create "domain:layout" --color "FEF3C7" --description "레이아웃" -gh label create "domain:auth" --color "FECACA" --description "인증" -gh label create "domain:chat" --color "BBF7D0" --description "채팅" -``` - -### Epic 발행 - -```bash -gh issue create \ - --title "[Epic] 메인 레이아웃 구현" \ - --label "epic,feature,domain:layout" \ - --body "## Epic: 메인 레이아웃 구현 - -### 목표 -전체 앱의 기본 레이아웃 구조 구축 - -### 범위 -- Header 영역 -- Sidebar 영역 -- Main Content 영역 -- Footer 영역 - -### Stories -- [ ] Header 컴포넌트 개발 -- [ ] Sidebar 컴포넌트 개발 -- [ ] Footer 컴포넌트 개발 - -### 완료 조건 -- [ ] 모든 스토리 완료 -- [ ] 반응형 대응 완료" -``` - -### Story 발행 - -```bash -gh issue create \ - --title "[Story] Header 컴포넌트 개발" \ - --label "story,feature,domain:layout" \ - --body "## Story: Header 컴포넌트 개발 - -### 상위 Epic -#1 - -### 사용자 스토리 -사용자로서, 상단 헤더를 통해 주요 메뉴에 접근하고 싶다. - -### Tasks -- [ ] 로고 및 타이틀 배치 -- [ ] 네비게이션 메뉴 구현 -- [ ] 사용자 프로필 드롭다운 - -### 완료 조건 -- [ ] 반응형 대응 -- [ ] 다크모드 지원" -``` - -### Task 발행 - -```bash -gh issue create \ - --title "[Task] 네비게이션 메뉴 구현" \ - --label "task,feature,domain:layout" \ - --body "## Task: 네비게이션 메뉴 구현 - -### 상위 Story -#2 - -### 작업 내용 -- 메인 네비게이션 메뉴 구현 -- 현재 페이지 하이라이트 -- 호버 효과 - -### 관련 파일 -- src/layouts/MainLayout/Header.jsx -- src/layouts/MainLayout/Navigation.jsx" -``` - ---- - -## 브랜치 전략 - -### 브랜치 네이밍 규칙 - -``` -feature/{에픽번호}/{스토리번호}/{태스크번호}/{브랜치명} -``` - -### 예시 - -``` -main - └── develop - └── feature/1/2/3/navigation-menu - │ - │ Epic #1: 메인 레이아웃 구현 - │ Story #2: Header 컴포넌트 개발 - │ Task #3: 네비게이션 메뉴 구현 - │ - └── 브랜치명: navigation-menu -``` - -### 브랜치 생성 명령어 - -```bash -# develop 브랜치에서 시작 -git checkout develop -git pull origin develop - -# 태스크 브랜치 생성 -# feature/{에픽}/{스토리}/{태스크}/{이름} -git checkout -b feature/1/2/3/navigation-menu - -# 작업 후 커밋 -git add . -git commit -m "feat(layout): 네비게이션 메뉴 구현 (#3)" - -# 푸시 -git push origin feature/1/2/3/navigation-menu - -# PR 생성 -gh pr create \ - --base develop \ - --title "feat(layout): 네비게이션 메뉴 구현" \ - --body "Closes #3 - -## 작업 내용 -- 메인 네비게이션 메뉴 구현 -- 현재 페이지 하이라이트 - -## 스크린샷 -[스크린샷 첨부]" -``` - ---- - -## 커밋 메시지 컨벤션 - -### 형식 - -``` -{타입}({스코프}): {제목} (#{이슈번호}) - -{본문} -``` - -### 타입 - -| 타입 | 설명 | -|------------|-------------| -| `feat` | 새 기능 | -| `fix` | 버그 수정 | -| `docs` | 문서 수정 | -| `style` | 포맷팅, 세미콜론 등 | -| `refactor` | 리팩토링 | -| `test` | 테스트 추가 | -| `chore` | 빌드, 설정 등 | - -### 스코프 (도메인) - -`layout`, `auth`, `user`, `profile`, `chat`, `interview`, `opic`, `freetalk`, `writing`, `report` - -### 예시 - -```bash -# 기능 추가 -git commit -m "feat(layout): Header 컴포넌트 구현 (#3)" - -# 버그 수정 -git commit -m "fix(auth): 로그인 토큰 만료 처리 (#15)" - -# 리팩토링 -git commit -m "refactor(chat): WebSocket 연결 로직 개선 (#22)" -``` - ---- - -## 워크플로우 요약 - -``` -1. Epic 이슈 생성 - └── gh issue create --title "[Epic] ..." - -2. Story 이슈 생성 (Epic 연결) - └── gh issue create --title "[Story] ..." - -3. Task 이슈 생성 (Story 연결) - └── gh issue create --title "[Task] ..." - -4. 브랜치 생성 - └── git checkout -b feature/{에픽}/{스토리}/{태스크}/{이름} - -5. 작업 & 커밋 - └── git commit -m "feat(scope): 메시지 (#태스크번호)" - -6. PR 생성 - └── gh pr create --base develop - -7. 코드 리뷰 & 머지 - -8. 이슈 자동 종료 (Closes #번호) -``` - ---- - -## 이슈 계층 예시 (메인 레이아웃) - -``` -#1 [Epic] 메인 레이아웃 구현 -│ -├── #2 [Story] Header 컴포넌트 개발 -│ ├── #5 [Task] 로고 및 타이틀 배치 -│ ├── #6 [Task] 네비게이션 메뉴 구현 -│ └── #7 [Task] 사용자 프로필 드롭다운 -│ -├── #3 [Story] Sidebar 컴포넌트 개발 -│ ├── #8 [Task] 메뉴 리스트 구현 -│ └── #9 [Task] 접기/펼치기 기능 -│ -└── #4 [Story] Footer 컴포넌트 개발 - └── #10 [Task] 푸터 레이아웃 구현 -``` - -### 대응 브랜치 - -``` -feature/1/2/5/logo-title -feature/1/2/6/navigation-menu -feature/1/2/7/user-dropdown -feature/1/3/8/menu-list -feature/1/3/9/sidebar-toggle -feature/1/4/10/footer-layout -``` diff --git a/docs/ReadMe.md b/docs/ReadMe.md deleted file mode 100644 index e69de29..0000000 diff --git a/infra/.terraform.lock.hcl b/infra/.terraform.lock.hcl new file mode 100644 index 0000000..cdc1668 --- /dev/null +++ b/infra/.terraform.lock.hcl @@ -0,0 +1,25 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.100.0" + constraints = "~> 5.0" + hashes = [ + "h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=", + "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644", + "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2", + "zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274", + "zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b", + "zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862", + "zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93", + "zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2", + "zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e", + "zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421", + "zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4", + "zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9", + "zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9", + "zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70", + ] +} diff --git a/infra/main.tf b/infra/main.tf new file mode 100644 index 0000000..6ab1e66 --- /dev/null +++ b/infra/main.tf @@ -0,0 +1,171 @@ +# S3 Bucket for static website hosting +resource "aws_s3_bucket" "frontend" { + bucket = "${var.project_name}-${var.environment}" + + tags = { + Name = "${var.project_name}-${var.environment}" + Environment = var.environment + } +} + +# S3 Bucket public access settings +resource "aws_s3_bucket_public_access_block" "frontend" { + bucket = aws_s3_bucket.frontend.id + + block_public_acls = false + block_public_policy = false + ignore_public_acls = false + restrict_public_buckets = false +} + +# S3 Bucket policy for public read access +resource "aws_s3_bucket_policy" "frontend" { + bucket = aws_s3_bucket.frontend.id + + depends_on = [aws_s3_bucket_public_access_block.frontend] + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "PublicReadGetObject" + Effect = "Allow" + Principal = "*" + Action = "s3:GetObject" + Resource = "${aws_s3_bucket.frontend.arn}/*" + } + ] + }) +} + +# S3 Website configuration +resource "aws_s3_bucket_website_configuration" "frontend" { + bucket = aws_s3_bucket.frontend.id + + index_document { + suffix = "index.html" + } + + error_document { + key = "index.html" + } +} + +# CloudFront Origin Access Control +resource "aws_cloudfront_origin_access_control" "frontend" { + name = "${var.project_name}-${var.environment}-oac" + description = "OAC for ${var.project_name}" + origin_access_control_origin_type = "s3" + signing_behavior = "always" + signing_protocol = "sigv4" +} + +# CloudFront Distribution +resource "aws_cloudfront_distribution" "frontend" { + enabled = true + is_ipv6_enabled = true + default_root_object = "index.html" + comment = "${var.project_name} ${var.environment} distribution" + + origin { + domain_name = aws_s3_bucket.frontend.bucket_regional_domain_name + origin_id = "S3-${aws_s3_bucket.frontend.id}" + origin_access_control_id = aws_cloudfront_origin_access_control.frontend.id + } + + default_cache_behavior { + allowed_methods = ["GET", "HEAD", "OPTIONS"] + cached_methods = ["GET", "HEAD"] + target_origin_id = "S3-${aws_s3_bucket.frontend.id}" + + forwarded_values { + query_string = false + cookies { + forward = "none" + } + } + + viewer_protocol_policy = "redirect-to-https" + min_ttl = 0 + default_ttl = 3600 + max_ttl = 86400 + compress = true + } + + # SPA routing - return index.html for 403/404 errors + custom_error_response { + error_code = 403 + response_code = 200 + response_page_path = "/index.html" + } + + custom_error_response { + error_code = 404 + response_code = 200 + response_page_path = "/index.html" + } + + restrictions { + geo_restriction { + restriction_type = "none" + } + } + + viewer_certificate { + cloudfront_default_certificate = true + } + + tags = { + Name = "${var.project_name}-${var.environment}" + Environment = var.environment + } +} + +# IAM User for GitHub Actions +resource "aws_iam_user" "github_actions" { + name = "${var.project_name}-github-actions" + + tags = { + Name = "${var.project_name}-github-actions" + Environment = var.environment + } +} + +# IAM Access Key for GitHub Actions +resource "aws_iam_access_key" "github_actions" { + user = aws_iam_user.github_actions.name +} + +# IAM Policy for S3 and CloudFront access +resource "aws_iam_user_policy" "github_actions" { + name = "${var.project_name}-deploy-policy" + user = aws_iam_user.github_actions.name + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "s3:PutObject", + "s3:GetObject", + "s3:DeleteObject", + "s3:ListBucket" + ] + Resource = [ + aws_s3_bucket.frontend.arn, + "${aws_s3_bucket.frontend.arn}/*" + ] + }, + { + Effect = "Allow" + Action = [ + "cloudfront:CreateInvalidation", + "cloudfront:GetInvalidation", + "cloudfront:ListInvalidations" + ] + Resource = aws_cloudfront_distribution.frontend.arn + } + ] + }) +} diff --git a/infra/outputs.tf b/infra/outputs.tf new file mode 100644 index 0000000..ac5874c --- /dev/null +++ b/infra/outputs.tf @@ -0,0 +1,27 @@ +# GitHub Secrets에 필요한 값들 +output "s3_bucket_name" { + description = "S3 bucket name (GitHub Secret: S3_BUCKET_NAME)" + value = aws_s3_bucket.frontend.id +} + +output "cloudfront_distribution_id" { + description = "CloudFront distribution ID (GitHub Secret: CLOUDFRONT_DISTRIBUTION_ID)" + value = aws_cloudfront_distribution.frontend.id +} + +output "cloudfront_domain_name" { + description = "CloudFront domain name (웹사이트 URL)" + value = aws_cloudfront_distribution.frontend.domain_name +} + +output "aws_access_key_id" { + description = "AWS Access Key ID (GitHub Secret: AWS_ACCESS_KEY_ID)" + value = aws_iam_access_key.github_actions.id + sensitive = true +} + +output "aws_secret_access_key" { + description = "AWS Secret Access Key (GitHub Secret: AWS_SECRET_ACCESS_KEY)" + value = aws_iam_access_key.github_actions.secret + sensitive = true +} diff --git a/infra/provider.tf b/infra/provider.tf new file mode 100644 index 0000000..3c10695 --- /dev/null +++ b/infra/provider.tf @@ -0,0 +1,15 @@ +terraform { + required_version = ">= 1.0.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = var.aws_region + profile = "mzc" +} diff --git a/infra/variables.tf b/infra/variables.tf new file mode 100644 index 0000000..9959c61 --- /dev/null +++ b/infra/variables.tf @@ -0,0 +1,17 @@ +variable "aws_region" { + description = "AWS region" + type = string + default = "ap-northeast-2" +} + +variable "project_name" { + description = "Project name for resource naming" + type = string + default = "group2-englishstudy-fe" +} + +variable "environment" { + description = "Environment (prod, staging, dev)" + type = string + default = "prod" +} diff --git a/package-lock.json b/package-lock.json index 0f07aed..f9e95b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,17 +9,18 @@ "version": "0.0.0", "dependencies": { "@emotion/react": "^11.14.0", - "@emotion/styled": "^11.14.0", - "@mui/icons-material": "^7.0.0", - "@mui/material": "^7.0.0", + "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^7.3.7", + "@mui/material": "^7.3.7", "@mui/x-date-pickers": "^8.0.0", "@reduxjs/toolkit": "^2.0.0", - "axios": "^1.7.0", + "aws-amplify": "^6.15.9", + "axios": "^1.13.2", "date-fns": "^4.1.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-redux": "^9.0.0", - "react-router-dom": "^7.0.0" + "react-router-dom": "^7.12.0" }, "devDependencies": { "@eslint/js": "^9.0.0", @@ -33,1533 +34,1831 @@ "vite": "^6.0.0" } }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "license": "MIT", + "node_modules/@aws-amplify/analytics": { + "version": "7.0.90", + "resolved": "https://registry.npmjs.org/@aws-amplify/analytics/-/analytics-7.0.90.tgz", + "integrity": "sha512-Z/YA2dtYNu2ybs+MFc+ceQexCIYJmK9yUm5E6mLYPR2H4fCvINaUE9V0my6A4+1zwlmvRMWbnZO6Z1XKx02m4A==", + "license": "Apache-2.0", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" + "@aws-sdk/client-firehose": "3.621.0", + "@aws-sdk/client-kinesis": "3.621.0", + "@aws-sdk/client-personalize-events": "3.621.0", + "@smithy/util-utf8": "2.0.0", + "tslib": "^2.5.0" }, - "engines": { - "node": ">=6.9.0" + "peerDependencies": { + "@aws-amplify/core": "^6.1.0" } }, - "node_modules/@babel/compat-data": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", - "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" + "node_modules/@aws-amplify/api": { + "version": "6.3.21", + "resolved": "https://registry.npmjs.org/@aws-amplify/api/-/api-6.3.21.tgz", + "integrity": "sha512-oo3y1gCYlVBEco0d8INtBugPP4p3RRKE3XAAXazC26Xp2Reb0QWpwRjWPWn8qNH1a9LgnpMktwWXWx+fN99KJQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-amplify/api-graphql": "4.8.2", + "@aws-amplify/api-rest": "4.6.0", + "@aws-amplify/data-schema": "^1.7.0", + "rxjs": "^7.8.1", + "tslib": "^2.5.0" + }, + "peerDependencies": { + "@aws-amplify/core": "^6.1.0" } }, - "node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", - "dev": true, - "license": "MIT", + "node_modules/@aws-amplify/api-graphql": { + "version": "4.8.2", + "resolved": "https://registry.npmjs.org/@aws-amplify/api-graphql/-/api-graphql-4.8.2.tgz", + "integrity": "sha512-Gpem7cf0+YAhla7s/Qmc0eApIAc9Ufo5LHTqm8Hdlr6G0T1H5fUrCk1PzDroIfbDjSw8gqdeCTTGPZPpFezGyw==", + "license": "Apache-2.0", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" + "@aws-amplify/api-rest": "4.6.0", + "@aws-amplify/core": "6.15.0", + "@aws-amplify/data-schema": "^1.7.0", + "@aws-sdk/types": "3.387.0", + "graphql": "15.8.0", + "rxjs": "^7.8.1", + "tslib": "^2.5.0", + "uuid": "^11.0.0" + } + }, + "node_modules/@aws-amplify/api-rest": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@aws-amplify/api-rest/-/api-rest-4.6.0.tgz", + "integrity": "sha512-BUOgnw6cZ9ufCaG72QDt9KAtGLhJOj2DAFl2WBm7jPu45RnyUVFBEiCNcluVKkaoAJjXAA+ee9DZ5Y/ITZyQlg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.5.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" + "peerDependencies": { + "@aws-amplify/core": "^6.1.0" } }, - "node_modules/@babel/core/node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" + "node_modules/@aws-amplify/auth": { + "version": "6.17.1", + "resolved": "https://registry.npmjs.org/@aws-amplify/auth/-/auth-6.17.1.tgz", + "integrity": "sha512-1zDSmX8fzhDFEUroPMyxuqkUFN2lEKPqSFcstl50/stvxVPiymTEpH6FWVTga9mTjK/Hifd7ye4m5gM6gF/Y4A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "5.2.0", + "@smithy/types": "^3.3.0", + "tslib": "^2.5.0" + }, + "peerDependencies": { + "@aws-amplify/core": "^6.1.0", + "@aws-amplify/react-native": "^1.1.10" + }, + "peerDependenciesMeta": { + "@aws-amplify/react-native": { + "optional": true + } + } }, - "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", - "license": "MIT", + "node_modules/@aws-amplify/core": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@aws-amplify/core/-/core-6.15.0.tgz", + "integrity": "sha512-D08O7GaF6bDcwhKBkSURus5vJGdaNrTeIEZk9OQiDe3jiR1HOiNq7djIZXUWLc3FuiJ1Yhf2aHTD9/Gj6fqQxw==", + "license": "Apache-2.0", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/types": "3.398.0", + "@smithy/util-hex-encoding": "2.0.0", + "@types/uuid": "^9.0.0", + "js-cookie": "^3.0.5", + "rxjs": "^7.8.1", + "tslib": "^2.5.0", + "uuid": "^11.0.0" + } + }, + "node_modules/@aws-amplify/core/node_modules/@aws-sdk/types": { + "version": "3.398.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.398.0.tgz", + "integrity": "sha512-r44fkS+vsEgKCuEuTV+TIk0t0m5ZlXHNjSDYEUvzLStbbfUFiNus/YG4UCa0wOk9R7VuQI67badsvvPeVPCGDQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^2.2.2", + "tslib": "^2.5.0" }, "engines": { - "node": ">=6.9.0" + "node": ">=14.0.0" } }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", - "dev": true, - "license": "MIT", + "node_modules/@aws-amplify/core/node_modules/@smithy/types": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-2.12.0.tgz", + "integrity": "sha512-QwYgloJ0sVNBeBuBs65cIkTbfzV/Q6ZNPCJ99EICFEdJYG50nGIY/uYXp+TbsdJReIuPr0a0kXmCvren3MbRRw==", + "license": "Apache-2.0", "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=14.0.0" } }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" + "node_modules/@aws-amplify/data-schema": { + "version": "1.22.2", + "resolved": "https://registry.npmjs.org/@aws-amplify/data-schema/-/data-schema-1.22.2.tgz", + "integrity": "sha512-zzRaT81HZwXs/+Q1VVcwE5ZPo5+k8WckFHIXWZz/Nacn1sVtllOba5GfsGVoXreIRkt7nIh/HYdXlQRWXhHfiQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-amplify/data-schema-types": "*", + "@smithy/util-base64": "^3.0.0", + "@types/aws-lambda": "^8.10.134", + "@types/json-schema": "^7.0.15", + "rxjs": "^7.8.1" } }, - "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "license": "MIT", + "node_modules/@aws-amplify/data-schema-types": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@aws-amplify/data-schema-types/-/data-schema-types-1.2.1.tgz", + "integrity": "sha512-SuYVcy9Hg8Ox9P0QCXEPwqHxX5zVPgVo2YvNBOm5TpkZr4UK6ir3USame7dELZsk5/9f6KoP70QAYhTvp/j1Og==", + "license": "Apache-2.0", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" + "graphql": "15.8.0", + "rxjs": "^7.8.1" } }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", - "dev": true, - "license": "MIT", + "node_modules/@aws-amplify/datastore": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@aws-amplify/datastore/-/datastore-5.1.2.tgz", + "integrity": "sha512-ELIjTLzvw6EP8SCqsaDs0hKtAkiBkN7Z4jHqBiK08z5XY9LhWGL6/s5yZ95TKbwZbXC2t9gdef2uF8jnjHpvFQ==", + "license": "Apache-2.0", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" - }, - "engines": { - "node": ">=6.9.0" + "@aws-amplify/api": "6.3.21", + "@aws-amplify/api-graphql": "4.8.2", + "buffer": "4.9.2", + "idb": "5.0.6", + "immer": "9.0.6", + "rxjs": "^7.8.1", + "ulid": "^2.3.0" }, "peerDependencies": { - "@babel/core": "^7.0.0" + "@aws-amplify/core": "^6.1.0" } }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "dev": true, + "node_modules/@aws-amplify/datastore/node_modules/immer": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.6.tgz", + "integrity": "sha512-G95ivKpy+EvVAnAab4fVa4YGYn24J1SpEktnJX7JJ45Bd7xqME/SCplFzYFmTbrkwZbQ4xJK1xMTUYBkN6pWsQ==", "license": "MIT", - "engines": { - "node": ">=6.9.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" } }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" + "node_modules/@aws-amplify/notifications": { + "version": "2.0.90", + "resolved": "https://registry.npmjs.org/@aws-amplify/notifications/-/notifications-2.0.90.tgz", + "integrity": "sha512-mJGVpR9JbCO+WELIE98QIglndSc5vbMumCDEj/gbNCfXAhqplOu4KQp6lgwnj9XY18nnrQYrSUTfWAdkZUUigg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.398.0", + "lodash": "^4.17.21", + "tslib": "^2.5.0" + }, + "peerDependencies": { + "@aws-amplify/core": "^6.1.0" } }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "license": "MIT", + "node_modules/@aws-amplify/notifications/node_modules/@aws-sdk/types": { + "version": "3.398.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.398.0.tgz", + "integrity": "sha512-r44fkS+vsEgKCuEuTV+TIk0t0m5ZlXHNjSDYEUvzLStbbfUFiNus/YG4UCa0wOk9R7VuQI67badsvvPeVPCGDQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^2.2.2", + "tslib": "^2.5.0" + }, "engines": { - "node": ">=6.9.0" + "node": ">=14.0.0" } }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", + "node_modules/@aws-amplify/notifications/node_modules/@smithy/types": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-2.12.0.tgz", + "integrity": "sha512-QwYgloJ0sVNBeBuBs65cIkTbfzV/Q6ZNPCJ99EICFEdJYG50nGIY/uYXp+TbsdJReIuPr0a0kXmCvren3MbRRw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, "engines": { - "node": ">=6.9.0" + "node": ">=14.0.0" } }, - "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", - "dev": true, - "license": "MIT", + "node_modules/@aws-amplify/storage": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/@aws-amplify/storage/-/storage-6.11.0.tgz", + "integrity": "sha512-VRc2N+xLrdrajyqxmazRlICBZvly8XElpHfl96CtKKqCnAd3fTlsH0fsUwXCkZcVestn0POGQ1t4ShvRUaDYvQ==", + "license": "Apache-2.0", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" + "@aws-sdk/types": "3.398.0", + "@smithy/md5-js": "2.0.7", + "buffer": "4.9.2", + "crc-32": "1.2.2", + "fast-xml-parser": "^4.4.1", + "tslib": "^2.5.0" }, - "engines": { - "node": ">=6.9.0" + "peerDependencies": { + "@aws-amplify/core": "^6.1.0" } }, - "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", - "license": "MIT", + "node_modules/@aws-amplify/storage/node_modules/@aws-sdk/types": { + "version": "3.398.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.398.0.tgz", + "integrity": "sha512-r44fkS+vsEgKCuEuTV+TIk0t0m5ZlXHNjSDYEUvzLStbbfUFiNus/YG4UCa0wOk9R7VuQI67badsvvPeVPCGDQ==", + "license": "Apache-2.0", "dependencies": { - "@babel/types": "^7.28.5" - }, - "bin": { - "parser": "bin/babel-parser.js" + "@smithy/types": "^2.2.2", + "tslib": "^2.5.0" }, "engines": { - "node": ">=6.0.0" + "node": ">=14.0.0" } }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "license": "MIT", + "node_modules/@aws-amplify/storage/node_modules/@smithy/types": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-2.12.0.tgz", + "integrity": "sha512-QwYgloJ0sVNBeBuBs65cIkTbfzV/Q6ZNPCJ99EICFEdJYG50nGIY/uYXp+TbsdJReIuPr0a0kXmCvren3MbRRw==", + "license": "Apache-2.0", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=14.0.0" } }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dev": true, - "license": "MIT", + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=16.0.0" } }, - "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" } }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "license": "MIT", + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=16.0.0" } }, - "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", - "debug": "^4.3.1" + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-firehose": { + "version": "3.621.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-firehose/-/client-firehose-3.621.0.tgz", + "integrity": "sha512-XAjAkXdb35PDvBYph609Fxn4g00HYH/U6N4+KjF9gLQrdTU+wkjf3D9YD02DZNbApJVcu4eIxWh/8M25YkW02A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/client-sso-oidc": "3.621.0", + "@aws-sdk/client-sts": "3.621.0", + "@aws-sdk/core": "3.621.0", + "@aws-sdk/credential-provider-node": "3.621.0", + "@aws-sdk/middleware-host-header": "3.620.0", + "@aws-sdk/middleware-logger": "3.609.0", + "@aws-sdk/middleware-recursion-detection": "3.620.0", + "@aws-sdk/middleware-user-agent": "3.620.0", + "@aws-sdk/region-config-resolver": "3.614.0", + "@aws-sdk/types": "3.609.0", + "@aws-sdk/util-endpoints": "3.614.0", + "@aws-sdk/util-user-agent-browser": "3.609.0", + "@aws-sdk/util-user-agent-node": "3.614.0", + "@smithy/config-resolver": "^3.0.5", + "@smithy/core": "^2.3.1", + "@smithy/fetch-http-handler": "^3.2.4", + "@smithy/hash-node": "^3.0.3", + "@smithy/invalid-dependency": "^3.0.3", + "@smithy/middleware-content-length": "^3.0.5", + "@smithy/middleware-endpoint": "^3.1.0", + "@smithy/middleware-retry": "^3.0.13", + "@smithy/middleware-serde": "^3.0.3", + "@smithy/middleware-stack": "^3.0.3", + "@smithy/node-config-provider": "^3.1.4", + "@smithy/node-http-handler": "^3.1.4", + "@smithy/protocol-http": "^4.1.0", + "@smithy/smithy-client": "^3.1.11", + "@smithy/types": "^3.3.0", + "@smithy/url-parser": "^3.0.3", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.13", + "@smithy/util-defaults-mode-node": "^3.0.13", + "@smithy/util-endpoints": "^2.0.5", + "@smithy/util-middleware": "^3.0.3", + "@smithy/util-retry": "^3.0.3", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=16.0.0" } }, - "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", - "license": "MIT", + "node_modules/@aws-sdk/client-firehose/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=16.0.0" } }, - "node_modules/@emotion/babel-plugin": { - "version": "11.13.5", - "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", - "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", - "license": "MIT", + "node_modules/@aws-sdk/client-firehose/node_modules/@smithy/util-utf8": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-3.0.0.tgz", + "integrity": "sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==", + "license": "Apache-2.0", "dependencies": { - "@babel/helper-module-imports": "^7.16.7", - "@babel/runtime": "^7.18.3", - "@emotion/hash": "^0.9.2", - "@emotion/memoize": "^0.9.0", - "@emotion/serialize": "^1.3.3", - "babel-plugin-macros": "^3.1.0", - "convert-source-map": "^1.5.0", - "escape-string-regexp": "^4.0.0", - "find-root": "^1.1.0", - "source-map": "^0.5.7", - "stylis": "4.2.0" + "@smithy/util-buffer-from": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@emotion/cache": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", - "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", - "license": "MIT", + "node_modules/@aws-sdk/client-kinesis": { + "version": "3.621.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-kinesis/-/client-kinesis-3.621.0.tgz", + "integrity": "sha512-53Omt/beFmTQPjQNpMuPMk5nMzYVsXCRiO+MeqygZEKYG1fWw/UGluCWVbi7WjClOHacsW8lQcsqIRvkPDFNag==", + "license": "Apache-2.0", "dependencies": { - "@emotion/memoize": "^0.9.0", - "@emotion/sheet": "^1.4.0", - "@emotion/utils": "^1.4.2", - "@emotion/weak-memoize": "^0.4.0", - "stylis": "4.2.0" + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/client-sso-oidc": "3.621.0", + "@aws-sdk/client-sts": "3.621.0", + "@aws-sdk/core": "3.621.0", + "@aws-sdk/credential-provider-node": "3.621.0", + "@aws-sdk/middleware-host-header": "3.620.0", + "@aws-sdk/middleware-logger": "3.609.0", + "@aws-sdk/middleware-recursion-detection": "3.620.0", + "@aws-sdk/middleware-user-agent": "3.620.0", + "@aws-sdk/region-config-resolver": "3.614.0", + "@aws-sdk/types": "3.609.0", + "@aws-sdk/util-endpoints": "3.614.0", + "@aws-sdk/util-user-agent-browser": "3.609.0", + "@aws-sdk/util-user-agent-node": "3.614.0", + "@smithy/config-resolver": "^3.0.5", + "@smithy/core": "^2.3.1", + "@smithy/eventstream-serde-browser": "^3.0.5", + "@smithy/eventstream-serde-config-resolver": "^3.0.3", + "@smithy/eventstream-serde-node": "^3.0.4", + "@smithy/fetch-http-handler": "^3.2.4", + "@smithy/hash-node": "^3.0.3", + "@smithy/invalid-dependency": "^3.0.3", + "@smithy/middleware-content-length": "^3.0.5", + "@smithy/middleware-endpoint": "^3.1.0", + "@smithy/middleware-retry": "^3.0.13", + "@smithy/middleware-serde": "^3.0.3", + "@smithy/middleware-stack": "^3.0.3", + "@smithy/node-config-provider": "^3.1.4", + "@smithy/node-http-handler": "^3.1.4", + "@smithy/protocol-http": "^4.1.0", + "@smithy/smithy-client": "^3.1.11", + "@smithy/types": "^3.3.0", + "@smithy/url-parser": "^3.0.3", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.13", + "@smithy/util-defaults-mode-node": "^3.0.13", + "@smithy/util-endpoints": "^2.0.5", + "@smithy/util-middleware": "^3.0.3", + "@smithy/util-retry": "^3.0.3", + "@smithy/util-utf8": "^3.0.0", + "@smithy/util-waiter": "^3.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@emotion/hash": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", - "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", - "license": "MIT" - }, - "node_modules/@emotion/is-prop-valid": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", - "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", - "license": "MIT", + "node_modules/@aws-sdk/client-kinesis/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", "dependencies": { - "@emotion/memoize": "^0.9.0" + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@emotion/memoize": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", - "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", - "license": "MIT" - }, - "node_modules/@emotion/react": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", - "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", - "license": "MIT", + "node_modules/@aws-sdk/client-kinesis/node_modules/@smithy/util-utf8": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-3.0.0.tgz", + "integrity": "sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==", + "license": "Apache-2.0", "dependencies": { - "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.13.5", - "@emotion/cache": "^11.14.0", - "@emotion/serialize": "^1.3.3", - "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", - "@emotion/utils": "^1.4.2", - "@emotion/weak-memoize": "^0.4.0", - "hoist-non-react-statics": "^3.3.1" - }, - "peerDependencies": { - "react": ">=16.8.0" + "@smithy/util-buffer-from": "^3.0.0", + "tslib": "^2.6.2" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@emotion/serialize": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", - "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", - "license": "MIT", + "node_modules/@aws-sdk/client-personalize-events": { + "version": "3.621.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-personalize-events/-/client-personalize-events-3.621.0.tgz", + "integrity": "sha512-qkVkqYvOe3WVuVNL/gRITGYFfHJCx2ijGFK7H3hNUJH3P4AwskmouAd1pWf+3cbGedRnj2is7iw7E602LeJIHA==", + "license": "Apache-2.0", "dependencies": { - "@emotion/hash": "^0.9.2", - "@emotion/memoize": "^0.9.0", - "@emotion/unitless": "^0.10.0", - "@emotion/utils": "^1.4.2", - "csstype": "^3.0.2" + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/client-sso-oidc": "3.621.0", + "@aws-sdk/client-sts": "3.621.0", + "@aws-sdk/core": "3.621.0", + "@aws-sdk/credential-provider-node": "3.621.0", + "@aws-sdk/middleware-host-header": "3.620.0", + "@aws-sdk/middleware-logger": "3.609.0", + "@aws-sdk/middleware-recursion-detection": "3.620.0", + "@aws-sdk/middleware-user-agent": "3.620.0", + "@aws-sdk/region-config-resolver": "3.614.0", + "@aws-sdk/types": "3.609.0", + "@aws-sdk/util-endpoints": "3.614.0", + "@aws-sdk/util-user-agent-browser": "3.609.0", + "@aws-sdk/util-user-agent-node": "3.614.0", + "@smithy/config-resolver": "^3.0.5", + "@smithy/core": "^2.3.1", + "@smithy/fetch-http-handler": "^3.2.4", + "@smithy/hash-node": "^3.0.3", + "@smithy/invalid-dependency": "^3.0.3", + "@smithy/middleware-content-length": "^3.0.5", + "@smithy/middleware-endpoint": "^3.1.0", + "@smithy/middleware-retry": "^3.0.13", + "@smithy/middleware-serde": "^3.0.3", + "@smithy/middleware-stack": "^3.0.3", + "@smithy/node-config-provider": "^3.1.4", + "@smithy/node-http-handler": "^3.1.4", + "@smithy/protocol-http": "^4.1.0", + "@smithy/smithy-client": "^3.1.11", + "@smithy/types": "^3.3.0", + "@smithy/url-parser": "^3.0.3", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.13", + "@smithy/util-defaults-mode-node": "^3.0.13", + "@smithy/util-endpoints": "^2.0.5", + "@smithy/util-middleware": "^3.0.3", + "@smithy/util-retry": "^3.0.3", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@emotion/sheet": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", - "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", - "license": "MIT" - }, - "node_modules/@emotion/styled": { - "version": "11.14.1", - "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", - "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", - "license": "MIT", + "node_modules/@aws-sdk/client-personalize-events/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", "dependencies": { - "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.13.5", - "@emotion/is-prop-valid": "^1.3.0", - "@emotion/serialize": "^1.3.3", - "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", - "@emotion/utils": "^1.4.2" + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" }, - "peerDependencies": { - "@emotion/react": "^11.0.0-rc.0", - "react": ">=16.8.0" + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-personalize-events/node_modules/@smithy/util-utf8": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-3.0.0.tgz", + "integrity": "sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^3.0.0", + "tslib": "^2.6.2" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@emotion/unitless": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", - "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", - "license": "MIT" + "node_modules/@aws-sdk/client-sso": { + "version": "3.621.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.621.0.tgz", + "integrity": "sha512-xpKfikN4u0BaUYZA9FGUMkkDmfoIP0Q03+A86WjqDWhcOoqNA1DkHsE4kZ+r064ifkPUfcNuUvlkVTEoBZoFjA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.621.0", + "@aws-sdk/middleware-host-header": "3.620.0", + "@aws-sdk/middleware-logger": "3.609.0", + "@aws-sdk/middleware-recursion-detection": "3.620.0", + "@aws-sdk/middleware-user-agent": "3.620.0", + "@aws-sdk/region-config-resolver": "3.614.0", + "@aws-sdk/types": "3.609.0", + "@aws-sdk/util-endpoints": "3.614.0", + "@aws-sdk/util-user-agent-browser": "3.609.0", + "@aws-sdk/util-user-agent-node": "3.614.0", + "@smithy/config-resolver": "^3.0.5", + "@smithy/core": "^2.3.1", + "@smithy/fetch-http-handler": "^3.2.4", + "@smithy/hash-node": "^3.0.3", + "@smithy/invalid-dependency": "^3.0.3", + "@smithy/middleware-content-length": "^3.0.5", + "@smithy/middleware-endpoint": "^3.1.0", + "@smithy/middleware-retry": "^3.0.13", + "@smithy/middleware-serde": "^3.0.3", + "@smithy/middleware-stack": "^3.0.3", + "@smithy/node-config-provider": "^3.1.4", + "@smithy/node-http-handler": "^3.1.4", + "@smithy/protocol-http": "^4.1.0", + "@smithy/smithy-client": "^3.1.11", + "@smithy/types": "^3.3.0", + "@smithy/url-parser": "^3.0.3", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.13", + "@smithy/util-defaults-mode-node": "^3.0.13", + "@smithy/util-endpoints": "^2.0.5", + "@smithy/util-middleware": "^3.0.3", + "@smithy/util-retry": "^3.0.3", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } }, - "node_modules/@emotion/use-insertion-effect-with-fallbacks": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", - "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", - "license": "MIT", + "node_modules/@aws-sdk/client-sso-oidc": { + "version": "3.621.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.621.0.tgz", + "integrity": "sha512-mMjk3mFUwV2Y68POf1BQMTF+F6qxt5tPu6daEUCNGC9Cenk3h2YXQQoS4/eSyYzuBiYk3vx49VgleRvdvkg8rg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.621.0", + "@aws-sdk/credential-provider-node": "3.621.0", + "@aws-sdk/middleware-host-header": "3.620.0", + "@aws-sdk/middleware-logger": "3.609.0", + "@aws-sdk/middleware-recursion-detection": "3.620.0", + "@aws-sdk/middleware-user-agent": "3.620.0", + "@aws-sdk/region-config-resolver": "3.614.0", + "@aws-sdk/types": "3.609.0", + "@aws-sdk/util-endpoints": "3.614.0", + "@aws-sdk/util-user-agent-browser": "3.609.0", + "@aws-sdk/util-user-agent-node": "3.614.0", + "@smithy/config-resolver": "^3.0.5", + "@smithy/core": "^2.3.1", + "@smithy/fetch-http-handler": "^3.2.4", + "@smithy/hash-node": "^3.0.3", + "@smithy/invalid-dependency": "^3.0.3", + "@smithy/middleware-content-length": "^3.0.5", + "@smithy/middleware-endpoint": "^3.1.0", + "@smithy/middleware-retry": "^3.0.13", + "@smithy/middleware-serde": "^3.0.3", + "@smithy/middleware-stack": "^3.0.3", + "@smithy/node-config-provider": "^3.1.4", + "@smithy/node-http-handler": "^3.1.4", + "@smithy/protocol-http": "^4.1.0", + "@smithy/smithy-client": "^3.1.11", + "@smithy/types": "^3.3.0", + "@smithy/url-parser": "^3.0.3", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.13", + "@smithy/util-defaults-mode-node": "^3.0.13", + "@smithy/util-endpoints": "^2.0.5", + "@smithy/util-middleware": "^3.0.3", + "@smithy/util-retry": "^3.0.3", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, "peerDependencies": { - "react": ">=16.8.0" + "@aws-sdk/client-sts": "^3.621.0" } }, - "node_modules/@emotion/utils": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", - "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", - "license": "MIT" - }, - "node_modules/@emotion/weak-memoize": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", - "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", - "license": "MIT" + "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], + "node_modules/@aws-sdk/client-sso-oidc/node_modules/@smithy/util-utf8": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-3.0.0.tgz", + "integrity": "sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^3.0.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=16.0.0" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "node_modules/@aws-sdk/client-sso/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=16.0.0" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "node_modules/@aws-sdk/client-sso/node_modules/@smithy/util-utf8": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-3.0.0.tgz", + "integrity": "sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^3.0.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=16.0.0" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "node_modules/@aws-sdk/client-sts": { + "version": "3.621.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.621.0.tgz", + "integrity": "sha512-707uiuReSt+nAx6d0c21xLjLm2lxeKc7padxjv92CIrIocnQSlJPxSCM7r5zBhwiahJA6MNQwmTl2xznU67KgA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/client-sso-oidc": "3.621.0", + "@aws-sdk/core": "3.621.0", + "@aws-sdk/credential-provider-node": "3.621.0", + "@aws-sdk/middleware-host-header": "3.620.0", + "@aws-sdk/middleware-logger": "3.609.0", + "@aws-sdk/middleware-recursion-detection": "3.620.0", + "@aws-sdk/middleware-user-agent": "3.620.0", + "@aws-sdk/region-config-resolver": "3.614.0", + "@aws-sdk/types": "3.609.0", + "@aws-sdk/util-endpoints": "3.614.0", + "@aws-sdk/util-user-agent-browser": "3.609.0", + "@aws-sdk/util-user-agent-node": "3.614.0", + "@smithy/config-resolver": "^3.0.5", + "@smithy/core": "^2.3.1", + "@smithy/fetch-http-handler": "^3.2.4", + "@smithy/hash-node": "^3.0.3", + "@smithy/invalid-dependency": "^3.0.3", + "@smithy/middleware-content-length": "^3.0.5", + "@smithy/middleware-endpoint": "^3.1.0", + "@smithy/middleware-retry": "^3.0.13", + "@smithy/middleware-serde": "^3.0.3", + "@smithy/middleware-stack": "^3.0.3", + "@smithy/node-config-provider": "^3.1.4", + "@smithy/node-http-handler": "^3.1.4", + "@smithy/protocol-http": "^4.1.0", + "@smithy/smithy-client": "^3.1.11", + "@smithy/types": "^3.3.0", + "@smithy/url-parser": "^3.0.3", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.13", + "@smithy/util-defaults-mode-node": "^3.0.13", + "@smithy/util-endpoints": "^2.0.5", + "@smithy/util-middleware": "^3.0.3", + "@smithy/util-retry": "^3.0.3", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=16.0.0" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=16.0.0" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "node_modules/@aws-sdk/client-sts/node_modules/@smithy/util-utf8": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-3.0.0.tgz", + "integrity": "sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^3.0.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=16.0.0" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "node_modules/@aws-sdk/core": { + "version": "3.621.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.621.0.tgz", + "integrity": "sha512-CtOwWmDdEiINkGXD93iGfXjN0WmCp9l45cDWHHGa8lRgEDyhuL7bwd/pH5aSzj0j8SiQBG2k0S7DHbd5RaqvbQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^2.3.1", + "@smithy/node-config-provider": "^3.1.4", + "@smithy/protocol-http": "^4.1.0", + "@smithy/signature-v4": "^4.1.0", + "@smithy/smithy-client": "^3.1.11", + "@smithy/types": "^3.3.0", + "@smithy/util-middleware": "^3.0.3", + "fast-xml-parser": "4.4.1", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=16.0.0" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" + "node_modules/@aws-sdk/core/node_modules/fast-xml-parser": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz", + "integrity": "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + }, + { + "type": "paypal", + "url": "https://paypal.me/naturalintelligence" + } ], - "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.620.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.620.1.tgz", + "integrity": "sha512-ExuILJ2qLW5ZO+rgkNRj0xiAipKT16Rk77buvPP8csR7kkCflT/gXTyzRe/uzIiETTxM7tr8xuO9MP/DQXqkfg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.609.0", + "@smithy/property-provider": "^3.1.3", + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=16.0.0" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@aws-sdk/credential-provider-env/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=16.0.0" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.621.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.621.0.tgz", + "integrity": "sha512-/jc2tEsdkT1QQAI5Dvoci50DbSxtJrevemwFsm0B73pwCcOQZ5ZwwSdVqGsPutzYzUVx3bcXg3LRL7jLACqRIg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.609.0", + "@smithy/fetch-http-handler": "^3.2.4", + "@smithy/node-http-handler": "^3.1.4", + "@smithy/property-provider": "^3.1.3", + "@smithy/protocol-http": "^4.1.0", + "@smithy/smithy-client": "^3.1.11", + "@smithy/types": "^3.3.0", + "@smithy/util-stream": "^3.1.3", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=16.0.0" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@aws-sdk/credential-provider-http/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=16.0.0" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.621.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.621.0.tgz", + "integrity": "sha512-0EWVnSc+JQn5HLnF5Xv405M8n4zfdx9gyGdpnCmAmFqEDHA8LmBdxJdpUk1Ovp/I5oPANhjojxabIW5f1uU0RA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.620.1", + "@aws-sdk/credential-provider-http": "3.621.0", + "@aws-sdk/credential-provider-process": "3.620.1", + "@aws-sdk/credential-provider-sso": "3.621.0", + "@aws-sdk/credential-provider-web-identity": "3.621.0", + "@aws-sdk/types": "3.609.0", + "@smithy/credential-provider-imds": "^3.2.0", + "@smithy/property-provider": "^3.1.3", + "@smithy/shared-ini-file-loader": "^3.1.4", + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sts": "^3.621.0" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@aws-sdk/credential-provider-ini/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=16.0.0" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.621.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.621.0.tgz", + "integrity": "sha512-4JqpccUgz5Snanpt2+53hbOBbJQrSFq7E1sAAbgY6BKVQUsW5qyXqnjvSF32kDeKa5JpBl3bBWLZl04IadcPHw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.620.1", + "@aws-sdk/credential-provider-http": "3.621.0", + "@aws-sdk/credential-provider-ini": "3.621.0", + "@aws-sdk/credential-provider-process": "3.620.1", + "@aws-sdk/credential-provider-sso": "3.621.0", + "@aws-sdk/credential-provider-web-identity": "3.621.0", + "@aws-sdk/types": "3.609.0", + "@smithy/credential-provider-imds": "^3.2.0", + "@smithy/property-provider": "^3.1.3", + "@smithy/shared-ini-file-loader": "^3.1.4", + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=16.0.0" } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@aws-sdk/credential-provider-node/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=16.0.0" } }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.620.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.620.1.tgz", + "integrity": "sha512-hWqFMidqLAkaV9G460+1at6qa9vySbjQKKc04p59OT7lZ5cO5VH5S4aI05e+m4j364MBROjjk2ugNvfNf/8ILg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.609.0", + "@smithy/property-provider": "^3.1.3", + "@smithy/shared-ini-file-loader": "^3.1.4", + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=16.0.0" } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@aws-sdk/credential-provider-process/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=16.0.0" } }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.621.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.621.0.tgz", + "integrity": "sha512-Kza0jcFeA/GEL6xJlzR2KFf1PfZKMFnxfGzJzl5yN7EjoGdMijl34KaRyVnfRjnCWcsUpBWKNIDk9WZVMY9yiw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.621.0", + "@aws-sdk/token-providers": "3.614.0", + "@aws-sdk/types": "3.609.0", + "@smithy/property-provider": "^3.1.3", + "@smithy/shared-ini-file-loader": "^3.1.4", + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=16.0.0" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=16.0.0" } }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.621.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.621.0.tgz", + "integrity": "sha512-w7ASSyfNvcx7+bYGep3VBgC3K6vEdLmlpjT7nSIHxxQf+WSdvy+HynwJosrpZax0sK5q0D1Jpn/5q+r5lwwW6w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.609.0", + "@smithy/property-provider": "^3.1.3", + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sts": "^3.621.0" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], + "node_modules/@aws-sdk/credential-provider-web-identity/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=16.0.0" } }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.620.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.620.0.tgz", + "integrity": "sha512-VMtPEZwqYrII/oUkffYsNWY9PZ9xpNJpMgmyU0rlDQ25O1c0Hk3fJmZRe6pEkAJ0omD7kLrqGl1DUjQVxpd/Rg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.609.0", + "@smithy/protocol-http": "^4.1.0", + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=16.0.0" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], + "node_modules/@aws-sdk/middleware-host-header/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=16.0.0" } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.609.0.tgz", + "integrity": "sha512-S62U2dy4jMDhDFDK5gZ4VxFdWzCtLzwbYyFZx2uvPYTECkepLUfzLic2BHg2Qvtu4QjX+oGE3P/7fwaGIsGNuQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.609.0", + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=16.0.0" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "node_modules/@aws-sdk/middleware-logger/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=16.0.0" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.620.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.620.0.tgz", + "integrity": "sha512-nh91S7aGK3e/o1ck64sA/CyoFw+gAYj2BDOnoNa6ouyCrVJED96ZXWbhye/fz9SgmNUZR2g7GdVpiLpMKZoI5w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.609.0", + "@smithy/protocol-http": "^4.1.0", + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18" + "node": ">=16.0.0" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, - "license": "MIT", + "node_modules/@aws-sdk/middleware-recursion-detection/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", "dependencies": { - "eslint-visitor-keys": "^3.4.3" + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "node": ">=16.0.0" } }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.620.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.620.0.tgz", + "integrity": "sha512-bvS6etn+KsuL32ubY5D3xNof1qkenpbJXf/ugGXbg0n98DvDFQ/F+SMLxHgbnER5dsKYchNnhmtI6/FC3HFu/A==", "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "dependencies": { + "@aws-sdk/types": "3.609.0", + "@aws-sdk/util-endpoints": "3.614.0", + "@smithy/protocol-http": "^4.1.0", + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": ">=16.0.0" } }, - "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", - "dev": true, + "node_modules/@aws-sdk/middleware-user-agent/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.2" + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=16.0.0" } }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.614.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.614.0.tgz", + "integrity": "sha512-vDCeMXvic/LU0KFIUjpC3RiSTIkkvESsEfbVHiHH0YINfl8HnEqR5rj+L8+phsCeVg2+LmYwYxd5NRz4PHxt5g==", "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0" + "@aws-sdk/types": "3.609.0", + "@smithy/node-config-provider": "^3.1.4", + "@smithy/types": "^3.3.0", + "@smithy/util-config-provider": "^3.0.0", + "@smithy/util-middleware": "^3.0.3", + "tslib": "^2.6.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=16.0.0" } }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, + "node_modules/@aws-sdk/region-config-resolver/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", "license": "Apache-2.0", "dependencies": { - "@types/json-schema": "^7.0.15" + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=16.0.0" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", - "dev": true, - "license": "MIT", + "node_modules/@aws-sdk/token-providers": { + "version": "3.614.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.614.0.tgz", + "integrity": "sha512-okItqyY6L9IHdxqs+Z116y5/nda7rHxLvROxtAJdLavWTYDydxrZstImNgGWTeVdmc0xX2gJCI77UYUTQWnhRw==", + "license": "Apache-2.0", "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" + "@aws-sdk/types": "3.609.0", + "@smithy/property-provider": "^3.1.3", + "@smithy/shared-ini-file-loader": "^3.1.4", + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=16.0.0" }, - "funding": { - "url": "https://opencollective.com/eslint" + "peerDependencies": { + "@aws-sdk/client-sso-oidc": "^3.614.0" } }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" + "node_modules/@aws-sdk/token-providers/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", - "dev": true, - "license": "MIT", + "node_modules/@aws-sdk/types": { + "version": "3.387.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.387.0.tgz", + "integrity": "sha512-YTjFabNwjTF+6yl88f0/tWff018qmmgMmjlw45s6sdVKueWxdxV68U7gepNLF2nhaQPZa6FDOBoA51NaviVs0Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^2.1.0", + "tslib": "^2.5.0" + }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/types/node_modules/@smithy/types": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-2.12.0.tgz", + "integrity": "sha512-QwYgloJ0sVNBeBuBs65cIkTbfzV/Q6ZNPCJ99EICFEdJYG50nGIY/uYXp+TbsdJReIuPr0a0kXmCvren3MbRRw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" }, - "funding": { - "url": "https://eslint.org/donate" + "engines": { + "node": ">=14.0.0" } }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.614.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.614.0.tgz", + "integrity": "sha512-wK2cdrXHH4oz4IomV/yrGkftU9A+ITB6nFL+rxxyO78is2ifHJpFdV4aqk4LSkXYPi6CXWNru/Dqc7yiKXgJPw==", "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.609.0", + "@smithy/types": "^3.3.0", + "@smithy/util-endpoints": "^2.0.5", + "tslib": "^2.6.2" + }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=16.0.0" } }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, + "node_modules/@aws-sdk/util-endpoints/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=16.0.0" } }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.2.tgz", + "integrity": "sha512-qKgO7wAYsXzhwCHhdbaKFyxd83Fgs8/1Ka+jjSPrv2Ll7mB55Wbwlo0kkfMLh993/yEc8aoDIAc1Fz9h4Spi4Q==", "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18.18.0" + "node": ">=20.0.0" } }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", - "dev": true, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.609.0.tgz", + "integrity": "sha512-fojPU+mNahzQ0YHYBsx0ZIhmMA96H+ZIZ665ObU9tl+SGdbLneVZVikGve+NmHTQwHzwkFsZYYnVKAkreJLAtA==", "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" + "@aws-sdk/types": "3.609.0", + "@smithy/types": "^3.3.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=18.18.0" + "node": ">=16.0.0" } }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.614.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.614.0.tgz", + "integrity": "sha512-15ElZT88peoHnq5TEoEtZwoXTXRxNrk60TZNdpl/TUBJ5oNJ9Dqb5Z4ryb8ofN6nm9aFf59GVAerFDz8iUoHBA==", "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.609.0", + "@smithy/node-config-provider": "^3.1.4", + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=12.22" + "node": ">=16.0.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } } }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, + "node_modules/@aws-sdk/util-user-agent-node/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", "license": "Apache-2.0", - "engines": { - "node": ">=18.18" + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, "engines": { - "node": ">=6.0.0" + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, "license": "MIT" }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", "license": "MIT", "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@mui/core-downloads-tracker": { - "version": "7.3.6", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.6.tgz", - "integrity": "sha512-QaYtTHlr8kDFN5mE1wbvVARRKH7Fdw1ZuOjBJcFdVpfNfRYKF3QLT4rt+WaB6CKJvpqxRsmEo0kpYinhH5GeHg==", + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@mui/icons-material": { - "version": "7.3.6", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.6.tgz", - "integrity": "sha512-0FfkXEj22ysIq5pa41A2NbcAhJSvmcZQ/vcTIbjDsd6hlslG82k5BEBqqS0ZJprxwIL3B45qpJ+bPHwJPlF7uQ==", + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@mui/material": "^7.3.6", - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "node": ">=6.9.0" } }, - "node_modules/@mui/material": { - "version": "7.3.6", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.6.tgz", - "integrity": "sha512-R4DaYF3dgCQCUAkr4wW1w26GHXcf5rCmBRHVBuuvJvaGLmZdD8EjatP80Nz5JCw0KxORAzwftnHzXVnjR8HnFw==", + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", - "@mui/core-downloads-tracker": "^7.3.6", - "@mui/system": "^7.3.6", - "@mui/types": "^7.4.9", - "@mui/utils": "^7.3.6", - "@popperjs/core": "^2.11.8", - "@types/react-transition-group": "^4.4.12", - "clsx": "^2.1.1", - "csstype": "^3.1.3", - "prop-types": "^15.8.1", - "react-is": "^19.2.0", - "react-transition-group": "^4.4.5" + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" }, "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" + "node": ">=6.9.0" }, "peerDependencies": { - "@emotion/react": "^11.5.0", - "@emotion/styled": "^11.3.0", - "@mui/material-pigment-css": "^7.3.6", - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - }, - "@mui/material-pigment-css": { - "optional": true - }, - "@types/react": { - "optional": true - } + "@babel/core": "^7.0.0" } }, - "node_modules/@mui/private-theming": { - "version": "7.3.6", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.6.tgz", - "integrity": "sha512-Ws9wZpqM+FlnbZXaY/7yvyvWQo1+02Tbx50mVdNmzWEi51C51y56KAbaDCYyulOOBL6BJxuaqG8rNNuj7ivVyw==", + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", - "@mui/utils": "^7.3.6", - "prop-types": "^15.8.1" + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" }, "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + "bin": { + "parser": "bin/babel-parser.js" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "engines": { + "node": ">=6.0.0" } }, - "node_modules/@mui/styled-engine": { - "version": "7.3.6", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.6.tgz", - "integrity": "sha512-+wiYbtvj+zyUkmDB+ysH6zRjuQIJ+CM56w0fEXV+VDNdvOuSywG+/8kpjddvvlfMLsaWdQe5oTuYGBcodmqGzQ==", + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", - "@emotion/cache": "^11.14.0", - "@emotion/serialize": "^1.3.3", - "@emotion/sheet": "^1.4.0", - "csstype": "^3.1.3", - "prop-types": "^15.8.1" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" + "node": ">=6.9.0" }, "peerDependencies": { - "@emotion/react": "^11.4.1", - "@emotion/styled": "^11.3.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - } + "@babel/core": "^7.0.0-0" } }, - "node_modules/@mui/system": { - "version": "7.3.6", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.6.tgz", - "integrity": "sha512-8fehAazkHNP1imMrdD2m2hbA9sl7Ur6jfuNweh5o4l9YPty4iaZzRXqYvBCWQNwFaSHmMEj2KPbyXGp7Bt73Rg==", + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", - "@mui/private-theming": "^7.3.6", - "@mui/styled-engine": "^7.3.6", - "@mui/types": "^7.4.9", - "@mui/utils": "^7.3.6", - "clsx": "^2.1.1", - "csstype": "^3.1.3", - "prop-types": "^15.8.1" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" + "node": ">=6.9.0" }, "peerDependencies": { - "@emotion/react": "^11.5.0", - "@emotion/styled": "^11.3.0", - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - }, - "@types/react": { - "optional": true - } + "@babel/core": "^7.0.0-0" } }, - "node_modules/@mui/types": { - "version": "7.4.9", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.9.tgz", - "integrity": "sha512-dNO8Z9T2cujkSIaCnWwprfeKmTWh97cnjkgmpFJ2sbfXLx8SMZijCYHOtP/y5nnUb/Rm2omxbDMmtUoSaUtKaw==", + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@mui/utils": { - "version": "7.3.6", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.6.tgz", - "integrity": "sha512-jn+Ba02O6PiFs7nKva8R2aJJ9kJC+3kQ2R0BbKNY3KQQ36Qng98GnPRFTlbwYTdMD6hLEBKaMLUktyg/rTfd2w==", + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", - "@mui/types": "^7.4.9", - "@types/prop-types": "^15.7.15", - "clsx": "^2.1.1", - "prop-types": "^15.8.1", - "react-is": "^19.2.0" + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" }, "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "node": ">=6.9.0" } }, - "node_modules/@mui/x-date-pickers": { - "version": "8.23.0", - "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-8.23.0.tgz", - "integrity": "sha512-uKtam5wqMEuErmRxZLPEX/7CZZFTMfrl05V9cWNjBkpGTcdDBIs1Kba8z2pfQU93e9lSLrRlxbCMJzCu6iF0Rg==", + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", - "@mui/utils": "^7.3.5", - "@mui/x-internals": "8.23.0", - "@types/react-transition-group": "^4.4.12", - "clsx": "^2.1.1", - "prop-types": "^15.8.1", - "react-transition-group": "^4.4.5" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@emotion/react": "^11.9.0", - "@emotion/styled": "^11.8.1", - "@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0", - "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0", - "date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0", - "date-fns-jalali": "^2.13.0-0 || ^3.2.0-0 || ^4.0.0-0", - "dayjs": "^1.10.7", - "luxon": "^3.0.2", - "moment": "^2.29.4", - "moment-hijri": "^2.1.2 || ^3.0.0", - "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - }, - "date-fns": { - "optional": true - }, - "date-fns-jalali": { - "optional": true - }, - "dayjs": { - "optional": true - }, - "luxon": { - "optional": true - }, - "moment": { - "optional": true - }, - "moment-hijri": { - "optional": true - }, - "moment-jalaali": { - "optional": true - } + "node": ">=6.9.0" } }, - "node_modules/@mui/x-internals": { - "version": "8.23.0", - "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.23.0.tgz", - "integrity": "sha512-FN7wdqwTxqq1tJBYVz8TA/HMcViuaHS0Jphr4pEjT/8Iuf94Yt3P82WbsTbXyYrgOQDQl07UqE7qWcJetRcHcg==", + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", - "@mui/utils": "^7.3.5", - "reselect": "^5.1.1", - "use-sync-external-store": "^1.6.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" }, "peerDependencies": { - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@popperjs/core": { - "version": "2.11.8", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", - "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/popperjs" + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" } }, - "node_modules/@reduxjs/toolkit": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", - "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@standard-schema/utils": "^0.3.0", - "immer": "^11.0.0", - "redux": "^5.0.1", - "redux-thunk": "^3.1.0", - "reselect": "^5.1.0" + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" }, "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", - "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" }, "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-redux": { + "@types/react": { "optional": true } } }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.53", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", - "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", - "dev": true, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", "license": "MIT" }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", - "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ - "arm" + "ppc64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "android" - ] + "aix" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", - "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ - "arm64" + "arm" ], "dev": true, "license": "MIT", "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", - "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], @@ -1567,13 +1866,16 @@ "license": "MIT", "optional": true, "os": [ - "darwin" - ] + "android" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", - "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], @@ -1581,13 +1883,16 @@ "license": "MIT", "optional": true, "os": [ - "darwin" - ] + "android" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", - "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], @@ -1595,41 +1900,67 @@ "license": "MIT", "optional": true, "os": [ - "freebsd" - ] + "darwin" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", - "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], "dev": true, "license": "MIT", "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, "os": [ "freebsd" - ] + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", - "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ - "arm" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" - ] + "freebsd" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", - "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], @@ -1638,12 +1969,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", - "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], @@ -1652,26 +1986,32 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", - "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ - "arm64" + "ia32" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", - "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ "loong64" ], @@ -1680,26 +2020,32 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", - "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ - "loong64" + "mips64el" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", - "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ "ppc64" ], @@ -1708,175 +2054,1992 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", - "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ - "ppc64" + "riscv64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", - "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ - "riscv64" + "s390x" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", - "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ - "riscv64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", - "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", "cpu": [ - "s390x" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" - ] + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.7.tgz", + "integrity": "sha512-8jWwS6FweMkpyRkrJooamUGe1CQfO1yJ+lM43IyUJbrhHW/ObES+6ry4vfGi8EKaldHL3t3BG1bcLcERuJPcjg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.7.tgz", + "integrity": "sha512-3Q+ulAqG+A1+R4ebgoIs7AccaJhIGy+Xi/9OnvX376jQ6wcy+rz4geDGrxQxCGzdjOQr4Z3NgyFSZCz4T999lA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^7.3.7", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.7.tgz", + "integrity": "sha512-6bdIxqzeOtBAj2wAsfhWCYyMKPLkRO9u/2o5yexcL0C3APqyy91iGSWgT3H7hg+zR2XgE61+WAu12wXPON8b6A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/core-downloads-tracker": "^7.3.7", + "@mui/system": "^7.3.7", + "@mui/types": "^7.4.10", + "@mui/utils": "^7.3.7", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1", + "react-is": "^19.2.3", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^7.3.7", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.7.tgz", + "integrity": "sha512-w7r1+CYhG0syCAQUWAuV5zSaU2/67WA9JXUderdb7DzCIJdp/5RmJv6L85wRjgKCMsxFF0Kfn0kPgPbPgw/jdw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.7", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.7.tgz", + "integrity": "sha512-y/QkNXv6cF6dZ5APztd/dFWfQ6LHKPx3skyYO38YhQD4+Cxd6sFAL3Z38WMSSC8LQz145Mpp3CcLrSCLKPwYAg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.7.tgz", + "integrity": "sha512-DovL3k+FBRKnhmatzUMyO5bKkhMLlQ9L7Qw5qHrre3m8zCZmE+31NDVBFfqrbrA7sq681qaEIHdkWD5nmiAjyQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/private-theming": "^7.3.7", + "@mui/styled-engine": "^7.3.7", + "@mui/types": "^7.4.10", + "@mui/utils": "^7.3.7", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.10.tgz", + "integrity": "sha512-0+4mSjknSu218GW3isRqoxKRTOrTLd/vHi/7UC4+wZcUrOAqD9kRk7UQRL1mcrzqRoe7s3UT6rsRpbLkW5mHpQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.7.tgz", + "integrity": "sha512-+YjnjMRnyeTkWnspzoxRdiSOgkrcpTikhNPoxOZW0APXx+urHtUoXJ9lbtCZRCA5a4dg5gSbd19alL1DvRs5fg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/types": "^7.4.10", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/x-date-pickers": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-8.23.0.tgz", + "integrity": "sha512-uKtam5wqMEuErmRxZLPEX/7CZZFTMfrl05V9cWNjBkpGTcdDBIs1Kba8z2pfQU93e9lSLrRlxbCMJzCu6iF0Rg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.5", + "@mui/x-internals": "8.23.0", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0", + "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0", + "date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0", + "date-fns-jalali": "^2.13.0-0 || ^3.2.0-0 || ^4.0.0-0", + "dayjs": "^1.10.7", + "luxon": "^3.0.2", + "moment": "^2.29.4", + "moment-hijri": "^2.1.2 || ^3.0.0", + "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "date-fns": { + "optional": true + }, + "date-fns-jalali": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + }, + "moment-hijri": { + "optional": true + }, + "moment-jalaali": { + "optional": true + } + } + }, + "node_modules/@mui/x-internals": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.23.0.tgz", + "integrity": "sha512-FN7wdqwTxqq1tJBYVz8TA/HMcViuaHS0Jphr4pEjT/8Iuf94Yt3P82WbsTbXyYrgOQDQl07UqE7qWcJetRcHcg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.5", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", + "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", + "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", + "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", + "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", + "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", + "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", + "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", + "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", + "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", + "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", + "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", + "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", + "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", + "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", + "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", + "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", + "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", + "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", + "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", + "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", + "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", + "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", + "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", + "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", + "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@smithy/abort-controller": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-3.1.9.tgz", + "integrity": "sha512-yiW0WI30zj8ZKoSYNx90no7ugVn3khlyH/z5W8qtKBtVE6awRALbhSG+2SAHA1r6bO/6M9utxYKVZ3PCJ1rWxw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "3.0.13", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-3.0.13.tgz", + "integrity": "sha512-Gr/qwzyPaTL1tZcq8WQyHhTZREER5R1Wytmz4WnVGL4onA3dNk6Btll55c8Vr58pLdvWZmtG8oZxJTw3t3q7Jg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^3.1.12", + "@smithy/types": "^3.7.2", + "@smithy/util-config-provider": "^3.0.0", + "@smithy/util-middleware": "^3.0.11", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-2.5.7.tgz", + "integrity": "sha512-8olpW6mKCa0v+ibCjoCzgZHQx1SQmZuW/WkrdZo73wiTprTH6qhmskT60QLFdT9DRa5mXxjz89kQPZ7ZSsoqqg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^3.0.11", + "@smithy/protocol-http": "^4.1.8", + "@smithy/types": "^3.7.2", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-middleware": "^3.0.11", + "@smithy/util-stream": "^3.3.4", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/core/node_modules/@smithy/util-utf8": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-3.0.0.tgz", + "integrity": "sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-3.2.8.tgz", + "integrity": "sha512-ZCY2yD0BY+K9iMXkkbnjo+08T2h8/34oHd0Jmh6BZUSZwaaGlGCyBT/3wnS7u7Xl33/EEfN4B6nQr3Gx5bYxgw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^3.1.12", + "@smithy/property-provider": "^3.1.11", + "@smithy/types": "^3.7.2", + "@smithy/url-parser": "^3.0.11", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-3.1.10.tgz", + "integrity": "sha512-323B8YckSbUH0nMIpXn7HZsAVKHYHFUODa8gG9cHo0ySvA1fr5iWaNT+iIL0UCqUzG6QPHA3BSsBtRQou4mMqQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^3.7.2", + "@smithy/util-hex-encoding": "^3.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@smithy/eventstream-codec/node_modules/@smithy/util-hex-encoding": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-3.0.0.tgz", + "integrity": "sha512-eFndh1WEK5YMUYvy3lPlVmYY/fZcQE1D8oSf41Id2vCeIkKJXPcYDCZD+4+xViI6b1XSd7tE+s5AmXzz5ilabQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "3.0.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-3.0.14.tgz", + "integrity": "sha512-kbrt0vjOIihW3V7Cqj1SXQvAI5BR8SnyQYsandva0AOR307cXAc+IhPngxIPslxTLfxwDpNu0HzCAq6g42kCPg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^3.0.13", + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-3.0.11.tgz", + "integrity": "sha512-P2pnEp4n75O+QHjyO7cbw/vsw5l93K/8EWyjNCAAybYwUmj3M+hjSQZ9P5TVdUgEG08ueMAP5R4FkuSkElZ5tQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "3.0.13", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-3.0.13.tgz", + "integrity": "sha512-zqy/9iwbj8Wysmvi7Lq7XFLeDgjRpTbCfwBhJa8WbrylTAHiAu6oQTwdY7iu2lxigbc9YYr9vPv5SzYny5tCXQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^3.0.13", + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "3.0.13", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-3.0.13.tgz", + "integrity": "sha512-L1Ib66+gg9uTnqp/18Gz4MDpJPKRE44geOjOQ2SVc0eiaO5l255ADziATZgjQjqumC7yPtp1XnjHlF1srcwjKw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^3.1.10", + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-3.2.9.tgz", + "integrity": "sha512-hYNVQOqhFQ6vOpenifFME546f0GfJn2OiQ3M0FDmuUu8V/Uiwy2wej7ZXxFBNqdx0R5DZAqWM1l6VRhGz8oE6A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^4.1.4", + "@smithy/querystring-builder": "^3.0.7", + "@smithy/types": "^3.5.0", + "@smithy/util-base64": "^3.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@smithy/hash-node": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-3.0.11.tgz", + "integrity": "sha512-emP23rwYyZhQBvklqTtwetkQlqbNYirDiEEwXl2v0GYWMnCzxst7ZaRAnWuy28njp5kAH54lvkdG37MblZzaHA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.7.2", + "@smithy/util-buffer-from": "^3.0.0", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/hash-node/node_modules/@smithy/util-utf8": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-3.0.0.tgz", + "integrity": "sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-3.0.11.tgz", + "integrity": "sha512-NuQmVPEJjUX6c+UELyVz8kUx8Q539EDeNwbRyu4IIF8MeV7hUtq1FB3SHVyki2u++5XLMFqngeMKk7ccspnNyQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-3.0.0.tgz", + "integrity": "sha512-+Fsu6Q6C4RSJiy81Y8eApjEB5gVtM+oFKTffg+jSuwtvomJJrhUJBu2zS8wjXSgH/g1MKEWrzyChTBe6clb5FQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/md5-js": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-2.0.7.tgz", + "integrity": "sha512-2i2BpXF9pI5D1xekqUsgQ/ohv5+H//G9FlawJrkOJskV18PgJ8LiNbLiskMeYt07yAsSTZR7qtlcAaa/GQLWww==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^2.3.1", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.5.0" + } + }, + "node_modules/@smithy/md5-js/node_modules/@smithy/types": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-2.12.0.tgz", + "integrity": "sha512-QwYgloJ0sVNBeBuBs65cIkTbfzV/Q6ZNPCJ99EICFEdJYG50nGIY/uYXp+TbsdJReIuPr0a0kXmCvren3MbRRw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "3.0.13", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-3.0.13.tgz", + "integrity": "sha512-zfMhzojhFpIX3P5ug7jxTjfUcIPcGjcQYzB9t+rv0g1TX7B0QdwONW+ATouaLoD7h7LOw/ZlXfkq4xJ/g2TrIw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^4.1.8", + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-3.2.8.tgz", + "integrity": "sha512-OEJZKVUEhMOqMs3ktrTWp7UvvluMJEvD5XgQwRePSbDg1VvBaL8pX8mwPltFn6wk1GySbcVwwyldL8S+iqnrEQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^2.5.7", + "@smithy/middleware-serde": "^3.0.11", + "@smithy/node-config-provider": "^3.1.12", + "@smithy/shared-ini-file-loader": "^3.1.12", + "@smithy/types": "^3.7.2", + "@smithy/url-parser": "^3.0.11", + "@smithy/util-middleware": "^3.0.11", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "3.0.34", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-3.0.34.tgz", + "integrity": "sha512-yVRr/AAtPZlUvwEkrq7S3x7Z8/xCd97m2hLDaqdz6ucP2RKHsBjEqaUA2ebNv2SsZoPEi+ZD0dZbOB1u37tGCA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^3.1.12", + "@smithy/protocol-http": "^4.1.8", + "@smithy/service-error-classification": "^3.0.11", + "@smithy/smithy-client": "^3.7.0", + "@smithy/types": "^3.7.2", + "@smithy/util-middleware": "^3.0.11", + "@smithy/util-retry": "^3.0.11", + "tslib": "^2.6.2", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/middleware-retry/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-3.0.11.tgz", + "integrity": "sha512-KzPAeySp/fOoQA82TpnwItvX8BBURecpx6ZMu75EZDkAcnPtO6vf7q4aH5QHs/F1s3/snQaSFbbUMcFFZ086Mw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-3.0.11.tgz", + "integrity": "sha512-1HGo9a6/ikgOMrTrWL/WiN9N8GSVYpuRQO5kjstAq4CvV59bjqnh7TbdXGQ4vxLD3xlSjfBjq5t1SOELePsLnA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "3.1.12", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-3.1.12.tgz", + "integrity": "sha512-O9LVEu5J/u/FuNlZs+L7Ikn3lz7VB9hb0GtPT9MQeiBmtK8RSY3ULmsZgXhe6VAlgTw0YO+paQx4p8xdbs43vQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^3.1.11", + "@smithy/shared-ini-file-loader": "^3.1.12", + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-3.3.3.tgz", + "integrity": "sha512-BrpZOaZ4RCbcJ2igiSNG16S+kgAc65l/2hmxWdmhyoGWHTLlzQzr06PXavJp9OBlPEG/sHlqdxjWmjzV66+BSQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^3.1.9", + "@smithy/protocol-http": "^4.1.8", + "@smithy/querystring-builder": "^3.0.11", + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "3.1.11", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-3.1.11.tgz", + "integrity": "sha512-I/+TMc4XTQ3QAjXfOcUWbSS073oOEAxgx4aZy8jHaf8JQnRkq2SZWw8+PfDtBvLUjcGMdxl+YwtzWe6i5uhL/A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-4.1.8.tgz", + "integrity": "sha512-hmgIAVyxw1LySOwkgMIUN0kjN8TG9Nc85LJeEmEE/cNEe2rkHDUWhnJf2gxcSRFLWsyqWsrZGw40ROjUogg+Iw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-3.0.11.tgz", + "integrity": "sha512-u+5HV/9uJaeLj5XTb6+IEF/dokWWkEqJ0XiaRRogyREmKGUgZnNecLucADLdauWFKUNbQfulHFEZEdjwEBjXRg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.7.2", + "@smithy/util-uri-escape": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-3.0.11.tgz", + "integrity": "sha512-Je3kFvCsFMnso1ilPwA7GtlbPaTixa3WwC+K21kmMZHsBEOZYQaqxcMqeFFoU7/slFjKDIpiiPydvdJm8Q/MCw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-3.0.11.tgz", + "integrity": "sha512-QnYDPkyewrJzCyaeI2Rmp7pDwbUETe+hU8ADkXmgNusO1bgHBH7ovXJiYmba8t0fNfJx75fE8dlM6SEmZxheog==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.7.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "3.1.12", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-3.1.12.tgz", + "integrity": "sha512-1xKSGI+U9KKdbG2qDvIR9dGrw3CNx+baqJfyr0igKEpjbHL5stsqAesYBzHChYHlelWtb87VnLWlhvfCz13H8Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-4.2.4.tgz", + "integrity": "sha512-5JWeMQYg81TgU4cG+OexAWdvDTs5JDdbEZx+Qr1iPbvo91QFGzjy0IkXAKaXUHqmKUJgSHK0ZxnCkgZpzkeNTA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^3.0.0", + "@smithy/protocol-http": "^4.1.8", + "@smithy/types": "^3.7.2", + "@smithy/util-hex-encoding": "^3.0.0", + "@smithy/util-middleware": "^3.0.11", + "@smithy/util-uri-escape": "^3.0.0", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/signature-v4/node_modules/@smithy/util-hex-encoding": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-3.0.0.tgz", + "integrity": "sha512-eFndh1WEK5YMUYvy3lPlVmYY/fZcQE1D8oSf41Id2vCeIkKJXPcYDCZD+4+xViI6b1XSd7tE+s5AmXzz5ilabQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/signature-v4/node_modules/@smithy/util-utf8": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-3.0.0.tgz", + "integrity": "sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-3.7.0.tgz", + "integrity": "sha512-9wYrjAZFlqWhgVo3C4y/9kpc68jgiSsKUnsFPzr/MSiRL93+QRDafGTfhhKAb2wsr69Ru87WTiqSfQusSmWipA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^2.5.7", + "@smithy/middleware-endpoint": "^3.2.8", + "@smithy/middleware-stack": "^3.0.11", + "@smithy/protocol-http": "^4.1.8", + "@smithy/types": "^3.7.2", + "@smithy/util-stream": "^3.3.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-3.7.2.tgz", + "integrity": "sha512-bNwBYYmN8Eh9RyjS1p2gW6MIhSO2rl7X9QeLM8iTdcGRP+eDiIWDt66c9IysCc22gefKszZv+ubV9qZc7hdESg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-3.0.11.tgz", + "integrity": "sha512-TmlqXkSk8ZPhfc+SQutjmFr5FjC0av3GZP4B/10caK1SbRwe/v+Wzu/R6xEKxoNqL+8nY18s1byiy6HqPG37Aw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^3.0.11", + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + } + }, + "node_modules/@smithy/util-base64": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-3.0.0.tgz", + "integrity": "sha512-Kxvoh5Qtt0CDsfajiZOCpJxgtPHXOKwmM+Zy4waD43UoEMA+qPxxa98aE/7ZhdnBFZFXMOiBR5xbcaMhLtznQQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^3.0.0", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/util-base64/node_modules/@smithy/util-utf8": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-3.0.0.tgz", + "integrity": "sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-3.0.0.tgz", + "integrity": "sha512-cbjJs2A1mLYmqmyVl80uoLTJhAcfzMOyPgjwAYusWKMdLeNtzmMz9YxNl3/jRLoxSS3wkqkf0jwNdtXWtyEBaQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-3.0.0.tgz", + "integrity": "sha512-Tj7pZ4bUloNUP6PzwhN7K386tmSmEET9QtQg0TgdNOnxhZvCssHji+oZTUIuzxECRfG8rdm2PMw2WCFs6eIYkA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-3.0.0.tgz", + "integrity": "sha512-aEOHCgq5RWFbP+UDPvPot26EJHjOC+bRgse5A8V3FSShqd5E5UN4qc7zkwsvJPPAVsf73QwYcHN1/gt/rtLwQA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-3.0.0.tgz", + "integrity": "sha512-pbjk4s0fwq3Di/ANL+rCvJMKM5bzAQdE5S/6RL5NXgMExFAi6UgQMPOm5yPaIWPpr+EOXKXRonJ3FoxKf4mCJQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "3.0.34", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-3.0.34.tgz", + "integrity": "sha512-FumjjF631lR521cX+svMLBj3SwSDh9VdtyynTYDAiBDEf8YPP5xORNXKQ9j0105o5+ARAGnOOP/RqSl40uXddA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^3.1.11", + "@smithy/smithy-client": "^3.7.0", + "@smithy/types": "^3.7.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "3.0.34", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-3.0.34.tgz", + "integrity": "sha512-vN6aHfzW9dVVzkI0wcZoUXvfjkl4CSbM9nE//08lmUMyf00S75uuCpTrqF9uD4bD9eldIXlt53colrlwKAT8Gw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^3.0.13", + "@smithy/credential-provider-imds": "^3.2.8", + "@smithy/node-config-provider": "^3.1.12", + "@smithy/property-provider": "^3.1.11", + "@smithy/smithy-client": "^3.7.0", + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-2.1.7.tgz", + "integrity": "sha512-tSfcqKcN/Oo2STEYCABVuKgJ76nyyr6skGl9t15hs+YaiU06sgMkN7QYjo0BbVw+KT26zok3IzbdSOksQ4YzVw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^3.1.12", + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-2.0.0.tgz", + "integrity": "sha512-c5xY+NUnFqG6d7HFh1IFfrm3mGl29lC+vF+geHv4ToiuJCBmIfzx6IeHLg+OgRdPFKDXIw6pvi+p3CsscaMcMA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-3.0.11.tgz", + "integrity": "sha512-dWpyc1e1R6VoXrwLoLDd57U1z6CwNSdkM69Ie4+6uYh2GC7Vg51Qtan7ITzczuVpqezdDTKJGJB95fFvvjU/ow==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-3.0.11.tgz", + "integrity": "sha512-hJUC6W7A3DQgaee3Hp9ZFcOxVDZzmBIRBPlUAk8/fSOEl7pE/aX7Dci0JycNOnm9Mfr0KV2XjIlUOcGWXQUdVQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^3.0.11", + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", - "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "node_modules/@smithy/util-stream": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-3.3.4.tgz", + "integrity": "sha512-SGhGBG/KupieJvJSZp/rfHHka8BFgj56eek9px4pp7lZbOF+fRiVr4U7A3y3zJD8uGhxq32C5D96HxsTC9BckQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^4.1.3", + "@smithy/node-http-handler": "^3.3.3", + "@smithy/types": "^3.7.2", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-buffer-from": "^3.0.0", + "@smithy/util-hex-encoding": "^3.0.0", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", - "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "node_modules/@smithy/util-stream/node_modules/@smithy/fetch-http-handler": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-4.1.3.tgz", + "integrity": "sha512-6SxNltSncI8s689nvnzZQc/dPXcpHQ34KUj6gR/HBroytKOd/isMG3gJF/zBE1TBmTT18TXyzhg3O3SOOqGEhA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^4.1.8", + "@smithy/querystring-builder": "^3.0.11", + "@smithy/types": "^3.7.2", + "@smithy/util-base64": "^3.0.0", + "tslib": "^2.6.2" + } }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", - "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "node_modules/@smithy/util-stream/node_modules/@smithy/util-hex-encoding": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-3.0.0.tgz", + "integrity": "sha512-eFndh1WEK5YMUYvy3lPlVmYY/fZcQE1D8oSf41Id2vCeIkKJXPcYDCZD+4+xViI6b1XSd7tE+s5AmXzz5ilabQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", - "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] + "node_modules/@smithy/util-stream/node_modules/@smithy/util-utf8": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-3.0.0.tgz", + "integrity": "sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", - "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "node_modules/@smithy/util-uri-escape": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-3.0.0.tgz", + "integrity": "sha512-LqR7qYLgZTD7nWLBecUi4aqolw8Mhza9ArpNEQ881MJJIU2sE5iHCK6TdyqqzcDLy0OPe10IY4T8ctVdtynubg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", - "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "node_modules/@smithy/util-utf8": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.0.0.tgz", + "integrity": "sha512-rctU1VkziY84n5OXe3bPNpKR001ZCME2JCaBBFgtiM2hfKbHFudc/BkMuPab8hRbLd0j3vbnBTTZ1igBf0wgiQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", - "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "node_modules/@smithy/util-utf8/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", - "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "node_modules/@smithy/util-utf8/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-waiter": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-3.2.0.tgz", + "integrity": "sha512-PpjSboaDUE6yl+1qlg3Si57++e84oXdWGbuFUSAciXsVfEZJJJupR2Nb0QuXHiunt2vGR+1PTizOMvnUPaG2Qg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^3.1.9", + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } }, "node_modules/@standard-schema/spec": { "version": "1.1.0", @@ -1890,6 +4053,12 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, + "node_modules/@types/aws-lambda": { + "version": "8.10.159", + "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.159.tgz", + "integrity": "sha512-SAP22WSGNN12OQ8PlCzGzRCZ7QDCwI85dQZbmpz7+mAk+L7j+wI7qnvmdKh+o7A5LaOp6QnOZ2NJphAZQTTHQg==", + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1946,7 +4115,6 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, "license": "MIT" }, "node_modules/@types/parse-json": { @@ -1995,6 +4163,12 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, + "node_modules/@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", @@ -2085,6 +4259,22 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/aws-amplify": { + "version": "6.15.9", + "resolved": "https://registry.npmjs.org/aws-amplify/-/aws-amplify-6.15.9.tgz", + "integrity": "sha512-KnRxqIwy1Qgwkv6vMWmji/KXhY1T1ii0Cpy7F9bYZ7IkacywcC1LwsXuq5ySz1HtbCcSG1zZ3DJs3wJpSzNcEA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-amplify/analytics": "7.0.90", + "@aws-amplify/api": "6.3.21", + "@aws-amplify/auth": "6.17.1", + "@aws-amplify/core": "6.15.0", + "@aws-amplify/datastore": "5.1.2", + "@aws-amplify/notifications": "2.0.90", + "@aws-amplify/storage": "6.11.0", + "tslib": "^2.5.0" + } + }, "node_modules/axios": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", @@ -2118,6 +4308,26 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.9.11", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", @@ -2128,6 +4338,12 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bowser": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.13.1.tgz", + "integrity": "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -2173,6 +4389,17 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", + "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4", + "isarray": "^1.0.0" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2325,6 +4552,18 @@ "node": ">= 6" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2736,6 +4975,24 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-parser": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz", + "integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^1.1.1" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2956,6 +5213,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graphql": { + "version": "15.8.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-15.8.0.tgz", + "integrity": "sha512-5gghUc24tP9HRznNpV2+FIoq3xKkj5dTQqf4v0CpdPbFVwFkWoxOM+o+2OC9ZSvjEMTjfmG9QT+gcvggTwW1zw==", + "license": "MIT", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3020,6 +5286,32 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/idb": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/idb/-/idb-5.0.6.tgz", + "integrity": "sha512-/PFvOWPzRcEPmlDt5jEvzVZVs0wyd/EvGvkDIcbBpGuMMLQKrTPG0TxvE2UJtgZtCQCmOtM2QD7yQJBVEjKGOw==", + "license": "ISC" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3110,6 +5402,12 @@ "node": ">=0.10.0" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3117,6 +5415,15 @@ "dev": true, "license": "ISC" }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3234,6 +5541,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -3621,9 +5934,9 @@ } }, "node_modules/react-router": { - "version": "7.11.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz", - "integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==", + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz", + "integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -3643,12 +5956,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.11.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.11.0.tgz", - "integrity": "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==", + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz", + "integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==", "license": "MIT", "dependencies": { - "react-router": "7.11.0" + "react-router": "7.12.0" }, "engines": { "node": ">=20.0.0" @@ -3769,6 +6082,15 @@ "fsevents": "~2.3.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -3846,6 +6168,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", + "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", @@ -3894,6 +6228,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3907,6 +6247,15 @@ "node": ">= 0.8.0" } }, + "node_modules/ulid": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ulid/-/ulid-2.4.0.tgz", + "integrity": "sha512-fIRiVTJNcSRmXKPZtGzFQv9WRrZ3M9eoptl/teFJvjOzmpU+/K/JH6HZ8deBfb5vMEpicJcLn7JmvdknlMq7Zg==", + "license": "MIT", + "bin": { + "ulid": "bin/cli.js" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -3957,6 +6306,19 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", diff --git a/package.json b/package.json index 0305f7b..8f46707 100644 --- a/package.json +++ b/package.json @@ -11,17 +11,18 @@ }, "dependencies": { "@emotion/react": "^11.14.0", - "@emotion/styled": "^11.14.0", - "@mui/icons-material": "^7.0.0", - "@mui/material": "^7.0.0", + "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^7.3.7", + "@mui/material": "^7.3.7", "@mui/x-date-pickers": "^8.0.0", "@reduxjs/toolkit": "^2.0.0", - "axios": "^1.7.0", + "aws-amplify": "^6.15.9", + "axios": "^1.13.2", "date-fns": "^4.1.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-redux": "^9.0.0", - "react-router-dom": "^7.0.0" + "react-router-dom": "^7.12.0" }, "devDependencies": { "@eslint/js": "^9.0.0", diff --git a/src/App.jsx b/src/App.jsx index e652c82..59c2179 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,6 +1,7 @@ -import {useState} from 'react' -import {Route, Routes, useNavigate} from 'react-router-dom' -import {Box, Button, Card, CardContent, Collapse, Container, Grid, Typography,} from '@mui/material' +import { useState, useEffect } from 'react' +import { Navigate, Route, Routes, useNavigate } from 'react-router-dom' +import { useDispatch } from 'react-redux' +import { Box, Button, Card, CardContent, CircularProgress, Collapse, Container, Grid, Typography } from '@mui/material' import { ChevronRight as ChevronRightIcon, Create as WritingCategoryIcon, @@ -9,14 +10,17 @@ import { LibraryBooks as WordListIcon, MenuBook as VocabIcon, Mic as SpeakingIcon, + Newspaper as NewsIcon, People as PeopleIcon, Quiz as QuizIcon, School as LearnIcon, SmartToy as AiIcon, + SportsEsports as GameIcon, WavingHand as WaveIcon, } from '@mui/icons-material' import MainLayout from './layouts/MainLayout' import FreetalkPeoplePage from './domains/freetalk/pages/FreetalkPeoplePage' +import { SpeakingPage } from './domains/speaking' import ChatRoomPage from './domains/freetalk/pages/ChatRoomPage' import ChatRoomModal from './domains/freetalk/components/ChatRoomModal' import VocabDashboard from './domains/vocab/pages/VocabDashboard' @@ -24,16 +28,151 @@ import DailyLearning from './domains/vocab/pages/DailyLearning' import TestPage from './domains/vocab/pages/TestPage' import WordListPage from './domains/vocab/pages/WordListPage' import StatsPage from './domains/vocab/pages/StatsPage' -import {WritingPage} from './domains/grammar' -import {BadgeSection} from './domains/badge' -import {useChat} from './contexts/ChatContext' -import {useSettings} from './contexts/SettingsContext' +import { WritingPage } from './domains/grammar' +import { BadgeSection } from './domains/badge' +import CatchmindLobbyPage from './domains/games/pages/CatchmindLobbyPage' +import CatchmindWaitingPage from './domains/games/pages/CatchmindWaitingPage' +import CatchmindPlayPage from './domains/games/pages/CatchmindPlayPage' +import WordchainLobbyPage from './domains/games/pages/WordchainLobbyPage' +import WordchainWaitingPage from './domains/games/pages/WordchainWaitingPage' +import WordchainPlayPage from './domains/games/pages/WordchainPlayPage' +import { NewsListPage, NewsDetailPage, NewsQuizPage, NewsWordsPage, NewsStatsPage } from './domains/news' +import { dailyService, statsService } from './domains/vocab/services/vocabService' +import { getNewsStats, getDashboardStats } from './domains/news/services/newsService' +import { useChat } from './contexts/ChatContext' +import { useSettings } from './contexts/SettingsContext' +import { useAuth } from './contexts/AuthContext' +import LoginPage from './pages/Login' +import SignUpPage from './pages/SignUp' +import { fetchMyProfile } from "./domains/profile/store/profileSlice"; +import ProfilePage from './domains/profile/pages/ProfilePage' +import OPIcPage from './domains/opic/pages/OPIcPage' + + +function ProtectedRoute({ children }) { + const { isAuthenticated, isLoading } = useAuth() + + if (isLoading) { + return ( + + + + ) + } + + if (!isAuthenticated) { + return + } + + return children +} + +// 이미 로그인된 경우 대시보드로 +function PublicRoute({ children }) { + const { isAuthenticated, isLoading } = useAuth() + + if (isLoading) { + return ( + + + + ) + } + + if (isAuthenticated) { + return + } + + return children +} // Dashboard Page function Dashboard() { const navigate = useNavigate() const [expandedCard, setExpandedCard] = useState(null) - const {t} = useSettings() + const { t, isKorean } = useSettings() + const [activityData, setActivityData] = useState(null) + const [loadingActivity, setLoadingActivity] = useState(true) + + // 최근 활동 데이터 로드 - 통합 대시보드 API 사용 + useEffect(() => { + const fetchActivityData = async () => { + try { + setLoadingActivity(true) + + // 통합 대시보드 API 호출 + const dashboardRes = await getDashboardStats().catch(() => null) + const dashboard = dashboardRes?.data || dashboardRes + + console.log('📊 Dashboard Stats:', dashboard) + + if (dashboard) { + // 통합 API 응답 구조: + // { today, overall, weeklyProgress, levelDistribution } + setActivityData({ + todayWords: dashboard.today?.wordsLearned || 0, + totalWords: dashboard.overall?.totalWordsLearned || 0, + dailyTotal: dashboard.today?.wordsTotal || 25, + vocabStreak: dashboard.overall?.currentStreak || 0, + newsRead: dashboard.today?.newsRead || 0, + totalNewsRead: dashboard.overall?.totalNewsRead || 0, + quizzesTaken: dashboard.today?.quizzesTaken || 0, + totalQuizzes: dashboard.overall?.totalQuizzes || 0, + averageAccuracy: dashboard.overall?.averageAccuracy || 0, + longestStreak: dashboard.overall?.longestStreak || 0, + weeklyProgress: dashboard.weeklyProgress || [], + levelDistribution: dashboard.levelDistribution || {}, + }) + } else { + // 통합 API 실패 시 개별 API 폴백 + const [dailyRes, statsRes, newsRes] = await Promise.allSettled([ + dailyService.getWords().catch(() => null), + statsService.getOverall().catch(() => null), + getNewsStats().catch(() => null), + ]) + + const dailyData = dailyRes.status === 'fulfilled' ? dailyRes.value : null + const statsData = statsRes.status === 'fulfilled' ? statsRes.value : null + const newsData = newsRes.status === 'fulfilled' ? newsRes.value?.data : null + + const daily = dailyData?.data || dailyData + const todayLearned = daily?.progress?.learned || daily?.dailyStudy?.learnedCount || 0 + const totalWords = daily?.progress?.total || daily?.dailyStudy?.totalWords || 0 + + const stats = statsData?.data || statsData + const vocabStreak = stats?.currentStreak || stats?.streakDays || 0 + const totalLearned = stats?.newWordsLearned || stats?.totalLearned || 0 + + setActivityData({ + todayWords: todayLearned, + totalWords: totalLearned, + dailyTotal: totalWords, + vocabStreak: vocabStreak, + newsRead: newsData?.todayRead || 0, + totalNewsRead: newsData?.totalRead || 0, + newsStreak: newsData?.currentStreak || 0, + quizScore: newsData?.averageQuizScore || 0, + }) + } + } catch (err) { + console.error('Failed to fetch activity data:', err) + } finally { + setLoadingActivity(false) + } + } + + fetchActivityData() + }, []) const learningModes = [ { @@ -41,8 +180,8 @@ function Dashboard() { title: t('dashboard.speakingTitle'), description: t('dashboard.speakingDesc'), icon: SpeakingIcon, - color: '#3b82f6', - bgColor: '#eff6ff', + color: '#059669', + bgColor: '#ecfdf5', children: [ { id: 'opic', @@ -65,8 +204,8 @@ function Dashboard() { title: t('dashboard.writingTitle'), description: t('dashboard.writingDesc'), icon: WritingCategoryIcon, - color: '#10b981', - bgColor: '#ecfdf5', + color: '#8b5cf6', + bgColor: '#f5f3ff', children: [ { id: 'chat-people', @@ -82,6 +221,13 @@ function Dashboard() { path: '/writing', description: t('dashboard.compositionDesc') }, + { + id: 'news-learning', + title: t('dashboard.newsTitle') || '뉴스 학습', + icon: NewsIcon, + path: '/news', + description: t('dashboard.newsDesc') || '실제 뉴스로 영어 학습' + }, ], }, { @@ -115,6 +261,30 @@ function Dashboard() { }, ], }, + { + id: 'games', + title: t('games.title'), + description: t('games.description'), + icon: GameIcon, + color: '#06b6d4', + bgColor: '#ecfeff', + children: [ + { + id: 'catchmind', + title: t('games.catchmindTitle'), + icon: GameIcon, + path: '/games/catchmind', + description: t('games.catchmindDesc') + }, + { + id: 'wordchain', + title: t('games.wordchainTitle'), + icon: GameIcon, + path: '/games/wordchain', + description: t('games.wordchainDesc') + }, + ], + }, ] const handleCardHover = (modeId) => { @@ -131,9 +301,9 @@ function Dashboard() { } return ( - + {/* Header */} - + - + - + {t('dashboard.greeting')} @@ -168,7 +338,7 @@ function Dashboard() { const hasChildren = mode.children && mode.children.length > 0 return ( - + handleCardHover(mode.id)} onMouseLeave={handleCardLeave} @@ -189,8 +359,8 @@ function Dashboard() { minHeight: isExpanded ? 'auto' : 140, }} > - - + + {/* Icon */} - + {/* Text */} - + )} - + {mode.description} @@ -286,14 +456,14 @@ function Dashboard() { boxShadow: '0 2px 8px -2px rgba(0,0,0,0.1)', }} > - + + sx={{ mb: 0.5 }}> {child.title} + sx={{ lineHeight: 1.3 }}> {child.description} @@ -309,85 +479,261 @@ function Dashboard() { })} - {/* Recent Activity */} - - - {t('dashboard.recentActivity')} - - - - - - - - {t('dashboard.noHistory')} - - - {t('dashboard.startLearning')} - - - - + {/* Today's Activity Stats */} + + {loadingActivity ? ( + + {[...Array(4)].map((_, i) => ( + + + + + + + + ))} + + ) : ( + + {/* 오늘 외운 단어 */} + + navigate('/vocab')} + sx={{ + borderRadius: '16px', + height: '100%', + cursor: 'pointer', + transition: 'all 0.2s ease', + '&:hover': { + transform: 'translateY(-4px)', + boxShadow: '0 12px 24px -8px rgba(249, 115, 22, 0.2)', + }, + }} + > + + + + + + {activityData?.todayWords || 0} + {activityData?.dailyTotal > 0 && ( + + /{activityData.dailyTotal} + + )} + + + {isKorean ? '오늘 외운 단어' : 'Words Today'} + + + + + {/* 읽은 뉴스 */} + + navigate('/news')} + sx={{ + borderRadius: '16px', + height: '100%', + cursor: 'pointer', + transition: 'all 0.2s ease', + '&:hover': { + transform: 'translateY(-4px)', + boxShadow: '0 12px 24px -8px rgba(139, 92, 246, 0.2)', + }, + }} + > + + + + + + {activityData?.newsRead || 0} + + + {isKorean ? '오늘 읽은 뉴스' : 'News Today'} + + + + + {/* 총 학습 단어 */} + + navigate('/vocab/words')} + sx={{ + borderRadius: '16px', + height: '100%', + cursor: 'pointer', + transition: 'all 0.2s ease', + '&:hover': { + transform: 'translateY(-4px)', + boxShadow: '0 12px 24px -8px rgba(249, 115, 22, 0.2)', + }, + }} + > + + + + + + {activityData?.totalWords || 0} + + + {isKorean ? '총 학습 단어' : 'Total Words'} + + + + + {/* 연속 학습 */} + + navigate('/reports')} + sx={{ + borderRadius: '16px', + height: '100%', + cursor: 'pointer', + transition: 'all 0.2s ease', + '&:hover': { + transform: 'translateY(-4px)', + boxShadow: '0 12px 24px -8px rgba(236, 72, 153, 0.2)', + }, + }} + > + + + + + + {Math.max(activityData?.vocabStreak || 0, activityData?.newsStreak || 0)} + + + {isKorean ? '연속 학습' : 'Day Streak'} + + + + + + )} ) } -// Placeholder Pages -function OpicPage() { - return ( - - OPIC Practice - Level-based training - - ) -} -function FreetalkAiPage() { - return ( - - AI Conversation - Free conversation with AI - - ) -} +function ReportsPage() { + const { isKorean } = useSettings() + const [loading, setLoading] = useState(true) + const [stats, setStats] = useState({ + totalStudyDays: 0, + totalWords: 0, + totalTests: 0, + averageScore: 0, + currentStreak: 0, + bestStreak: 0, + newsRead: 0, + newsQuizScore: 0, + }) + useEffect(() => { + const fetchReportData = async () => { + try { + setLoading(true) + const [vocabStatsRes, vocabHistoryRes, newsStatsRes] = await Promise.allSettled([ + statsService.getOverall().catch(() => null), + statsService.getDaily(null, { limit: 30 }).catch(() => null), + getNewsStats().catch(() => null), + ]) -function ReportsPage() { - const {isKorean} = useSettings() - - // 더미 통계 데이터 - const stats = { - totalStudyDays: 15, - totalWords: 285, - totalTests: 12, - averageScore: 82, - currentStreak: 5, - bestStreak: 8, + const vocabStats = vocabStatsRes.status === 'fulfilled' ? (vocabStatsRes.value?.data || vocabStatsRes.value) : null + const vocabHistory = vocabHistoryRes.status === 'fulfilled' ? (vocabHistoryRes.value?.data || vocabHistoryRes.value) : null + const newsStats = newsStatsRes.status === 'fulfilled' ? newsStatsRes.value?.data : null + + // 학습일 계산 (히스토리에서 학습한 날 수) + const historyData = vocabHistory?.history || vocabHistory?.dailyStats || [] + const studyDays = historyData.filter(d => (d.newWordsLearned || d.learnedCount || 0) > 0).length + + setStats({ + totalStudyDays: studyDays || 0, + totalWords: vocabStats?.newWordsLearned || vocabStats?.totalLearned || 0, + totalTests: vocabStats?.testsCompleted || vocabStats?.testCount || 0, + averageScore: Math.round(vocabStats?.successRate || vocabStats?.averageAccuracy || 0), + currentStreak: vocabStats?.currentStreak || vocabStats?.streakDays || 0, + bestStreak: vocabStats?.longestStreak || 0, + newsRead: newsStats?.totalRead || 0, + newsQuizScore: newsStats?.averageQuizScore || 0, + }) + } catch (err) { + console.error('Failed to fetch report data:', err) + } finally { + setLoading(false) + } + } + + fetchReportData() + }, []) + + if (loading) { + return ( + + + + + + ) } return ( - + {/* 헤더 */} - + - + @@ -415,13 +761,13 @@ function ReportsPage() { {/* 통계 요약 카드 */} - - - + + + {isKorean ? '총 학습일' : 'Study Days'} - + {stats.totalStudyDays} @@ -429,8 +775,8 @@ function ReportsPage() { - - + + {isKorean ? '학습한 단어' : 'Words Learned'} @@ -442,8 +788,8 @@ function ReportsPage() { - - + + {isKorean ? '테스트 완료' : 'Tests Taken'} @@ -455,8 +801,8 @@ function ReportsPage() { - - + + {isKorean ? '평균 점수' : 'Average Score'} @@ -470,13 +816,44 @@ function ReportsPage() { + {/* 뉴스 학습 통계 */} + {(stats.newsRead > 0 || stats.newsQuizScore > 0) && ( + + + {isKorean ? '뉴스 학습' : 'News Learning'} + + + + + + {stats.newsRead} + + + {isKorean ? '읽은 기사' : 'Articles Read'} + + + + + + + {stats.newsQuizScore}% + + + {isKorean ? '퀴즈 평균' : 'Quiz Average'} + + + + + + )} + {/* 연속 학습 */} - + {isKorean ? '연속 학습 기록' : 'Study Streak'} - + - + {/* 배지 섹션 */} - + ) } function SettingsPage() { - const {settings, setTtsVoice, setLanguage, t} = useSettings() + const { settings, setTtsVoice, setLanguage, t } = useSettings() const languageOptions = [ - {value: 'ko', label: '한국어', flag: '🇰🇷'}, - {value: 'en', label: 'English', flag: '🇺🇸'}, + { value: 'ko', label: '한국어', flag: '🇰🇷' }, + { value: 'en', label: 'English', flag: '🇺🇸' }, ] return ( - - + + - + - + {t('settings.title')} @@ -558,48 +935,48 @@ function SettingsPage() { {/* Language Settings */} - + - + {t('settings.language')} - + {t('settings.languageDesc')} - + {languageOptions.map((option) => ( - + setLanguage(option.value)} sx={{ p: 2.5, borderRadius: '16px', border: '2px solid', - borderColor: settings.language === option.value ? '#3b82f6' : 'divider', - backgroundColor: settings.language === option.value ? '#eff6ff' : 'transparent', + borderColor: settings.language === option.value ? '#059669' : 'divider', + backgroundColor: settings.language === option.value ? '#ecfdf5' : 'transparent', cursor: 'pointer', transition: 'all 0.2s ease', textAlign: 'center', '&:hover': { - borderColor: '#3b82f6', - backgroundColor: '#eff6ff', + borderColor: '#059669', + backgroundColor: '#ecfdf5', }, }} > - + {option.flag} {option.label} @@ -611,23 +988,23 @@ function SettingsPage() { {/* TTS Voice Settings */} - + - + {t('settings.ttsVoice')} - + {t('settings.ttsVoiceDesc')} - + - + setTtsVoice('FEMALE')} sx={{ @@ -658,7 +1035,7 @@ function SettingsPage() { mb: 1.5, }} > - 👩 + 👩 - + setTtsVoice('MALE')} sx={{ @@ -700,7 +1077,7 @@ function SettingsPage() { mb: 1.5, }} > - 👨 + 👨 @@ -739,10 +1116,10 @@ function NotFound() { > 404 - + {t('notFound.title')} - + {t('notFound.message')} + + {/* 타이틀 */} + + + + + + 이메일 인증 + + + {email}로 발송된 +
+ 6자리 인증 코드를 입력해주세요. +
+
+ + {/* 에러/성공 메시지 */} + {error && ( + + {error} + + )} + {success && ( + + {success} + + )} + + {/* 인증 코드 입력 */} + + {code.map((digit, index) => ( + (inputRefs.current[index] = el)} + value={digit} + onChange={(e) => handleCodeChange(index, e.target.value)} + onKeyDown={(e) => handleKeyDown(index, e)} + disabled={isLoading} + inputProps={{ + maxLength: 6, // 붙여넣기 허용 + style: { + textAlign: 'center', + fontSize: '1.5rem', + fontWeight: 700, + padding: '12px', + }, + }} + sx={{ + width: 48, + '& .MuiOutlinedInput-root': { + borderRadius: 2, + }, + }} + /> + ))} + + + {/* 확인 버튼 */} + + + {/* 재전송 */} + + + 코드를 받지 못하셨나요?{' '} + {countdown > 0 ? ( + + {countdown}초 후 재전송 가능 + + ) : ( + + {isResending ? ( + + ) : ( + + )} + 재전송 + + )} + + +
+ ) +} diff --git a/src/domains/auth/components/LoginForm.jsx b/src/domains/auth/components/LoginForm.jsx new file mode 100644 index 0000000..5978ce4 --- /dev/null +++ b/src/domains/auth/components/LoginForm.jsx @@ -0,0 +1,114 @@ +import {useState} from 'react'; +import {useNavigate} from 'react-router-dom'; +import { + Alert, + Box, + Button, + CircularProgress, + IconButton, + InputAdornment, + Link, + TextField, + Typography, +} from '@mui/material'; +import {Email as EmailIcon, Lock as LockIcon, Visibility, VisibilityOff,} from '@mui/icons-material'; +import {useAuth} from '../../../contexts/AuthContext'; + +export default function LoginForm({onSwitchToSignUp}) { + const navigate = useNavigate(); + const {login} = useAuth(); + + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + + if (!email || !password) { + setError('이메일과 비밀번호를 입력해주세요.'); + return; + } + + setIsLoading(true); + const result = await login(email, password); + + if (result.success) { + navigate('/dashboard'); + } else { + setError(result.message); + } + setIsLoading(false); + }; + + return ( + + + 로그인 + + + AI 언어 학습 시스템에 오신 것을 환영합니다 + + + {error && {error}} + + setEmail(e.target.value)} + disabled={isLoading} + sx={{mb: 2}} + InputProps={{ + startAdornment: ( + + ), + }} + /> + + setPassword(e.target.value)} + disabled={isLoading} + sx={{mb: 3}} + InputProps={{ + startAdornment: ( + + ), + endAdornment: ( + + setShowPassword(!showPassword)}> + {showPassword ? : } + + + ), + }} + /> + + + + + 계정이 없으신가요?{' '} + + 회원가입 + + + + ); +} diff --git a/src/domains/auth/components/SignupForm.jsx b/src/domains/auth/components/SignupForm.jsx new file mode 100644 index 0000000..d9a2027 --- /dev/null +++ b/src/domains/auth/components/SignupForm.jsx @@ -0,0 +1,366 @@ +import {useState} from 'react'; +import { + Alert, + Box, + Button, + CircularProgress, + Divider, + IconButton, + InputAdornment, + Link, + TextField, + Typography, +} from '@mui/material'; +import { + Check as CheckIcon, + Close as CloseIcon, + Email as EmailIcon, + Lock as LockIcon, + Visibility, + VisibilityOff, +} from '@mui/icons-material'; +import {useAuth} from '../../../contexts/AuthContext'; +import EmailVerification from './EmailVerification' + +export default function SignupForm({onSwitchToLogin}) { + + const {register} = useAuth() + + const [step, setStep] = useState('form') + + const [formData, setFormData] = useState({ + email: '', + password: '', + confirmPassword: '', + }) + const [showPassword, setShowPassword] = useState(false) + const [showConfirmPassword, setShowConfirmPassword] = useState(false) + const [error, setError] = useState('') + const [success, setSuccess] = useState('') + const [isLoading, setIsLoading] = useState(false) + + const passwordChecks = { + length: formData.password.length >= 8, + lowercase: /[a-z]/.test(formData.password), + number: /[0-9]/.test(formData.password), + special: /[!@#$%^&*(),.?":{}|<>]/.test(formData.password), + }; + + const handleChange = (e) => { + const {name, value} = e.target + setFormData((prev) => ({...prev, [name]: value})) + setError('') + } + + const passwordStrength = Object.values(passwordChecks).filter(Boolean).length + const isPasswordValid = passwordStrength >= 4 + + const handleSubmit = async (e) => { + e.preventDefault() + setError('') + setSuccess('') + setIsLoading(true) + + // 유효성 검사 + if (!formData.email || !formData.password || !formData.confirmPassword) { + setError('모든 필드를 입력해주세요.') + setIsLoading(false) + return + } + if (!isPasswordValid) { + setError('비밀번호 조건을 충족해주세요.') + setIsLoading(false) + return + } + + if (formData.password !== formData.confirmPassword) { + setError('비밀번호가 일치하지 않습니다.') + setIsLoading(false) + return + } + + try { + const result = await register(formData.email, formData.password) + + if (result.success) { + setSuccess(result.message) + setStep('verify') + } else { + setError(result.message) + } + } catch (err) { + setError('회원가입 중 오류가 발생했습니다.') + } finally { + setIsLoading(false) + } + } + + // 비밀번호 강도 표시 + const getStrengthColor = () => { + if (passwordStrength <= 2) return 'error' + if (passwordStrength <= 3) return 'warning' + return 'success' + } + + // 이메일 인증 화면 + if (step === 'verify') { + return ( + setStep('form')} + /> + ) + } + + return ( + + {/* 타이틀 */} + + + 회원가입 + + + 새로운 계정을 만들어보세요 + + + + {/* 에러/성공 메시지 */} + {error && ( + + {error} + + )} + {success && ( + + {success} + + )} + + {/* 이메일 입력 */} + + + + ), + }} + /> + + {/* 비밀번호 입력 */} + + + + + ), + endAdornment: ( + + setShowPassword(!showPassword)} + edge="end" + size="small" + > + {showPassword ? : } + + + ), + }} + /> + + {formData.password && ( + + {/* 세그먼트 바 + 강도 텍스트 */} + + {/* 20칸 세그먼트 바 */} + + {[...Array(20)].map((_, index) => ( + + ))} + + {/* 강도 텍스트 */} + + {passwordStrength <= 1 ? '약함' + : passwordStrength <= 2 ? '보통' + : passwordStrength <= 3 ? '좋음' + : '강함'} + + + {/* 체크 항목 */} + + + + + + + + )} + + + + {/* 비밀번호 확인 */} + + + + ), + endAdornment: ( + + setShowConfirmPassword(!showConfirmPassword)} + edge="end" + size="small" + > + {showConfirmPassword ? : } + + + ), + }} + /> + + {/* 가입 버튼 */} + + + {/* 구분선 */} + + + 또는 + + + + {/* 로그인 안내 */} + + + 이미 계정이 있으신가요?{' '} + + 로그인 + + + + + ) +} + +// 비밀번호 체크 아이템 컴포넌트 +function PasswordCheck({checked, label}) { + return ( + + {checked ? ( + + ) : ( + + )} + {label} + + ) +} diff --git a/src/domains/badge/components/BadgeCard.jsx b/src/domains/badge/components/BadgeCard.jsx index f9a1b51..5b40c9c 100644 --- a/src/domains/badge/components/BadgeCard.jsx +++ b/src/domains/badge/components/BadgeCard.jsx @@ -2,14 +2,22 @@ import {useState} from 'react' import {Box, Fade, LinearProgress, Tooltip, Typography,} from '@mui/material' import {EmojiEvents as TrophyIcon, Lock as LockIcon,} from '@mui/icons-material' import {useSettings} from '../../../contexts/SettingsContext' -import {BADGE_CATEGORY_COLORS, BADGE_DESCRIPTIONS_EN, BADGE_NAMES_EN,} from '../constants/badgeConstants' +import {useThemeMode} from '../../../contexts/ThemeContext' +import {BADGE_CATEGORY_COLORS, BADGE_DESCRIPTIONS_EN, BADGE_NAMES_EN, NEWS_BADGE_TYPES,} from '../constants/badgeConstants' + +// 뉴스 뱃지 색상 +const NEWS_BADGE_COLOR = '#10b981' export default function BadgeCard({badge, size = 'medium'}) { const {isKorean} = useSettings() + const {mode} = useThemeMode() + const isDark = mode === 'dark' const [imageError, setImageError] = useState(false) const isEarned = badge.earned const progress = Math.min((badge.progress / badge.threshold) * 100, 100) + const isNewsBadge = NEWS_BADGE_TYPES.includes(badge.badgeType) || badge.badgeType?.startsWith('NEWS_') + const badgeColor = isNewsBadge ? NEWS_BADGE_COLOR : BADGE_CATEGORY_COLORS[badge.category] // Size configurations const sizes = { @@ -63,7 +71,7 @@ export default function BadgeCard({badge, size = 'medium'}) { backgroundColor: '#e5e7eb', '& .MuiLinearProgress-bar': { borderRadius: 3, - background: `linear-gradient(135deg, ${BADGE_CATEGORY_COLORS[badge.category]} 0%, ${BADGE_CATEGORY_COLORS[badge.category]}99 100%)`, + background: `linear-gradient(135deg, ${badgeColor} 0%, ${badgeColor}99 100%)`, }, }} /> @@ -101,14 +109,14 @@ export default function BadgeCard({badge, size = 'medium'}) { PopperProps={{ sx: { '& .MuiTooltip-tooltip': { - backgroundColor: '#fff', + backgroundColor: isDark ? '#27272a' : '#fff', color: '#1f2937', boxShadow: '0 10px 40px -10px rgba(0,0,0,0.2)', borderRadius: '12px', border: '1px solid #e5e7eb', }, '& .MuiTooltip-arrow': { - color: '#fff', + color: isDark ? '#27272a' : '#fff', '&::before': { border: '1px solid #e5e7eb', }, @@ -140,11 +148,11 @@ export default function BadgeCard({badge, size = 'medium'}) { justifyContent: 'center', position: 'relative', background: isEarned - ? `linear-gradient(135deg, ${BADGE_CATEGORY_COLORS[badge.category]}20 0%, ${BADGE_CATEGORY_COLORS[badge.category]}10 100%)` - : '#f3f4f6', - border: isEarned ? `3px solid ${BADGE_CATEGORY_COLORS[badge.category]}` : '3px solid #d1d5db', + ? `linear-gradient(135deg, ${badgeColor}20 0%, ${badgeColor}10 100%)` + : isDark ? '#3f3f46' : '#f3f4f6', + border: isEarned ? `3px solid ${badgeColor}` : '3px solid #d1d5db', boxShadow: isEarned - ? `0 8px 24px -4px ${BADGE_CATEGORY_COLORS[badge.category]}40` + ? `0 8px 24px -4px ${badgeColor}40` : 'none', overflow: 'hidden', }} @@ -168,7 +176,7 @@ export default function BadgeCard({badge, size = 'medium'}) { diff --git a/src/domains/badge/components/BadgeGrid.jsx b/src/domains/badge/components/BadgeGrid.jsx index 757b49f..62427b6 100644 --- a/src/domains/badge/components/BadgeGrid.jsx +++ b/src/domains/badge/components/BadgeGrid.jsx @@ -1,29 +1,76 @@ +import {useRef, useState} from 'react' import {Box, Skeleton, Typography} from '@mui/material' import BadgeCard from './BadgeCard' import {useSettings} from '../../../contexts/SettingsContext' +import {useThemeMode} from '../../../contexts/ThemeContext' export default function BadgeGrid({badges = [], loading = false, size = 'medium'}) { const {isKorean} = useSettings() + const {mode} = useThemeMode() + const isDark = mode === 'dark' + const scrollRef = useRef(null) + const [isDragging, setIsDragging] = useState(false) + const [startX, setStartX] = useState(0) + const [scrollLeft, setScrollLeft] = useState(0) + + // 드래그 스크롤 핸들러 + const handleMouseDown = (e) => { + setIsDragging(true) + setStartX(e.pageX - scrollRef.current.offsetLeft) + setScrollLeft(scrollRef.current.scrollLeft) + } + + const handleMouseLeave = () => { + setIsDragging(false) + } + + const handleMouseUp = () => { + setIsDragging(false) + } + + const handleMouseMove = (e) => { + if (!isDragging) return + e.preventDefault() + const x = e.pageX - scrollRef.current.offsetLeft + const walk = (x - startX) * 1.5 + scrollRef.current.scrollLeft = scrollLeft - walk + } + + // 터치 이벤트 핸들러 + const handleTouchStart = (e) => { + setIsDragging(true) + setStartX(e.touches[0].pageX - scrollRef.current.offsetLeft) + setScrollLeft(scrollRef.current.scrollLeft) + } + + const handleTouchMove = (e) => { + if (!isDragging) return + const x = e.touches[0].pageX - scrollRef.current.offsetLeft + const walk = (x - startX) * 1.5 + scrollRef.current.scrollLeft = scrollLeft - walk + } + + const handleTouchEnd = () => { + setIsDragging(false) + } if (loading) { return ( - {Array.from({length: 12}).map((_, index) => ( - - - + {Array.from({length: 6}).map((_, index) => ( + + + ))} @@ -32,12 +79,7 @@ export default function BadgeGrid({badges = [], loading = false, size = 'medium' if (badges.length === 0) { return ( - + {isKorean ? '배지가 없습니다' : 'No badges available'} @@ -54,20 +96,45 @@ export default function BadgeGrid({badges = [], loading = false, size = 'medium' return ( {sortedBadges.map((badge) => ( - + + + ))} ) diff --git a/src/domains/badge/components/BadgeSection.jsx b/src/domains/badge/components/BadgeSection.jsx index 01890de..f1bf56f 100644 --- a/src/domains/badge/components/BadgeSection.jsx +++ b/src/domains/badge/components/BadgeSection.jsx @@ -4,9 +4,12 @@ import {EmojiEvents as TrophyIcon, WorkspacePremium as BadgeIcon,} from '@mui/ic import BadgeGrid from './BadgeGrid' import {badgeService} from '../services/badgeService' import {useSettings} from '../../../contexts/SettingsContext' +import {useThemeMode} from '../../../contexts/ThemeContext' export default function BadgeSection() { const {isKorean} = useSettings() + const {mode} = useThemeMode() + const isDark = mode === 'dark' const [badges, setBadges] = useState([]) const [totalCount, setTotalCount] = useState(0) const [earnedCount, setEarnedCount] = useState(0) @@ -43,7 +46,7 @@ export default function BadgeSection() { borderRadius: '20px', border: '1px solid', borderColor: 'divider', - backgroundColor: '#fff', + backgroundColor: isDark ? '#27272a' : '#fff', }} > {/* Header */} @@ -122,7 +125,7 @@ export default function BadgeSection() { sx={{ height: 10, borderRadius: 5, - backgroundColor: '#f3f4f6', + backgroundColor: isDark ? '#3f3f46' : '#f3f4f6', '& .MuiLinearProgress-bar': { borderRadius: 5, background: diff --git a/src/domains/badge/constants/badgeConstants.js b/src/domains/badge/constants/badgeConstants.js index 079f3d2..fb36496 100644 --- a/src/domains/badge/constants/badgeConstants.js +++ b/src/domains/badge/constants/badgeConstants.js @@ -20,6 +20,21 @@ export const BADGE_TYPES = { QUICK_GUESSER: 'QUICK_GUESSER', PERFECT_DRAWER: 'PERFECT_DRAWER', MASTER: 'MASTER', + // 뉴스 관련 뱃지 + NEWS_FIRST_READ: 'NEWS_FIRST_READ', + NEWS_10_READ: 'NEWS_10_READ', + NEWS_50_READ: 'NEWS_50_READ', + NEWS_100_READ: 'NEWS_100_READ', + NEWS_QUIZ_FIRST: 'NEWS_QUIZ_FIRST', + NEWS_QUIZ_PERFECT: 'NEWS_QUIZ_PERFECT', + NEWS_QUIZ_10: 'NEWS_QUIZ_10', + NEWS_QUIZ_50: 'NEWS_QUIZ_50', + NEWS_WORDS_10: 'NEWS_WORDS_10', + NEWS_WORDS_50: 'NEWS_WORDS_50', + NEWS_WORDS_100: 'NEWS_WORDS_100', + NEWS_STREAK_7: 'NEWS_STREAK_7', + NEWS_STREAK_30: 'NEWS_STREAK_30', + NEWS_MASTER: 'NEWS_MASTER', } // 배지 카테고리 @@ -35,8 +50,22 @@ export const BADGE_CATEGORIES = { QUICK_GUESSES: 'QUICK_GUESSES', PERFECT_DRAWS: 'PERFECT_DRAWS', ALL_BADGES: 'ALL_BADGES', + // 뉴스 관련 카테고리 + NEWS_READ: 'NEWS_READ', + NEWS_QUIZ: 'NEWS_QUIZ', + NEWS_WORDS: 'NEWS_WORDS', + NEWS_STREAK: 'NEWS_STREAK', + NEWS_MASTER: 'NEWS_MASTER', } +// 뉴스 뱃지 타입 목록 (초록색 테두리 적용용) +export const NEWS_BADGE_TYPES = [ + 'NEWS_FIRST_READ', 'NEWS_10_READ', 'NEWS_50_READ', 'NEWS_100_READ', + 'NEWS_QUIZ_FIRST', 'NEWS_QUIZ_PERFECT', 'NEWS_QUIZ_10', 'NEWS_QUIZ_50', + 'NEWS_WORDS_10', 'NEWS_WORDS_50', 'NEWS_WORDS_100', + 'NEWS_STREAK_7', 'NEWS_STREAK_30', 'NEWS_MASTER', +] + // 배지 카테고리 라벨 (한국어) export const BADGE_CATEGORY_LABELS_KO = { FIRST_STUDY: '첫 시작', diff --git a/src/domains/badge/services/badgeService.js b/src/domains/badge/services/badgeService.js index e449d89..dc3c77d 100644 --- a/src/domains/badge/services/badgeService.js +++ b/src/domains/badge/services/badgeService.js @@ -1,7 +1,7 @@ import badgeApi from '../../../api/badgeApi' -// Mock 데이터 사용 여부 -const USE_MOCK = true +// Mock 데이터 사용 여부 (환경변수로 제어: VITE_USE_MOCK=true) +const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true' // Placeholder 이미지 (실제 S3 이미지가 없을 경우 대비) const PLACEHOLDER_BADGE = 'https://via.placeholder.com/100x100/FFD700/000000?text=Badge' @@ -188,7 +188,10 @@ const withMock = (apiCall, mockData) => { setTimeout(() => resolve(mockData), 500) }) } - return apiCall().catch(() => mockData) + // 실제 API 호출 시 응답의 data 필드 추출 (백엔드 응답: { isSuccess, message, data }) + return apiCall() + .then(response => response.data || response) + .catch(() => mockData) } /** diff --git a/src/domains/chat/services/chatService.js b/src/domains/chat/services/chatService.js index f81918d..d544f29 100644 --- a/src/domains/chat/services/chatService.js +++ b/src/domains/chat/services/chatService.js @@ -2,8 +2,8 @@ import chatApi from '../../../api/chatApi' const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1' -// Mock 데이터 사용 여부 (true: 목 데이터 사용, false: 실제 API 호출) -const USE_MOCK = true +// Mock 데이터 사용 여부 (환경변수로 제어: VITE_USE_MOCK=true) +const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true' // ============================================ // Mock 데이터 (백엔드 API 응답 형식과 동일) @@ -200,19 +200,13 @@ const mockMessages = { const withMock = (apiCall, mockData) => { if (USE_MOCK) { - // interceptor가 response.data를 반환하므로 동일한 형식으로 반환 - // 실제 API: { success: true, message: ..., data: {...} } - return Promise.resolve({ - success: true, - message: 'Mock data', - data: mockData - }) + // Mock 모드에서는 직접 데이터 반환 + return Promise.resolve(mockData) } - return apiCall().catch(() => ({ - success: true, - message: 'Fallback mock data', - data: mockData - })) + // 실제 API 호출 시 응답의 data 필드 추출 (백엔드 응답: { isSuccess, message, data }) + return apiCall() + .then(response => response.data || response) + .catch(() => mockData) } /** @@ -235,7 +229,7 @@ export const chatRoomService = { lastMessageAt: new Date().toISOString(), } return withMock( - () => chatApi.post('/rooms', {...data, createdBy: TEMP_USER_ID}), + () => chatApi.post('/chat/rooms', {...data, createdBy: TEMP_USER_ID}), newRoom ) }, @@ -247,6 +241,7 @@ export const chatRoomService = { return withMock( () => { const queryParams = new URLSearchParams() + queryParams.append('type', 'CHAT') // 채팅방만 조회 queryParams.append('limit', limit) if (level) queryParams.append('level', level) if (joined) { @@ -254,7 +249,7 @@ export const chatRoomService = { queryParams.append('userId', TEMP_USER_ID) } if (cursor) queryParams.append('cursor', cursor) - return chatApi.get(`/rooms?${queryParams.toString()}`) + return chatApi.get(`/chat/rooms?${queryParams.toString()}`) }, { rooms: mockChatRooms @@ -273,7 +268,7 @@ export const chatRoomService = { // GET /rooms/{roomId} - 채팅방 상세 조회 getDetail: async (roomId) => { return withMock( - () => chatApi.get(`/rooms/${roomId}`), + () => chatApi.get(`/chat/rooms/${roomId}`), mockChatRooms.find(r => r.roomId === roomId) || mockChatRooms[0] ) }, @@ -282,7 +277,7 @@ export const chatRoomService = { join: async (roomId, password) => { const room = mockChatRooms.find(r => r.roomId === roomId) return withMock( - () => chatApi.post(`/rooms/${roomId}/join`, { + () => chatApi.post(`/chat/rooms/${roomId}/join`, { userId: TEMP_USER_ID, ...(password && {password}), }), @@ -301,7 +296,7 @@ export const chatRoomService = { // POST /rooms/{roomId}/leave - 채팅방 퇴장 leave: async (roomId) => { return withMock( - () => chatApi.post(`/rooms/${roomId}/leave`, {userId: TEMP_USER_ID}), + () => chatApi.post(`/chat/rooms/${roomId}/leave`, {userId: TEMP_USER_ID}), {roomId, currentMembers: 2} ) }, @@ -322,7 +317,7 @@ export const messageService = { createdAt: new Date().toISOString(), } return withMock( - () => chatApi.post(`/rooms/${roomId}/messages`, { + () => chatApi.post(`/chat/rooms/${roomId}/messages`, { userId: TEMP_USER_ID, content, messageType, @@ -340,7 +335,7 @@ export const messageService = { const queryParams = new URLSearchParams() queryParams.append('limit', limit) if (cursor) queryParams.append('cursor', cursor) - return chatApi.get(`/rooms/${roomId}/messages?${queryParams.toString()}`) + return chatApi.get(`/chat/rooms/${roomId}/messages?${queryParams.toString()}`) }, { messages: (mockMessages[roomId] || []).slice(0, limit), @@ -358,7 +353,7 @@ export const voiceService = { // POST /voice/synthesize - TTS 변환 synthesize: async (messageId, roomId, voice = 'FEMALE') => { return withMock( - () => chatApi.post('/voice/synthesize', {messageId, roomId, voice}), + () => chatApi.post('/chat/voice/synthesize', {messageId, roomId, voice}), {audioUrl: null, cached: false} ) }, @@ -412,7 +407,7 @@ export const gameService = { } return withMock( - () => chatApi.post(`/rooms/${roomId}/game/start`, {userId: TEMP_USER_ID}), + () => chatApi.post(`/chat/rooms/${roomId}/game/start`, {userId: TEMP_USER_ID}), { gameStatus: mockGameState.gameStatus, currentRound: mockGameState.currentRound, @@ -447,7 +442,7 @@ export const gameService = { } return withMock( - () => chatApi.post(`/rooms/${roomId}/game/stop`, {userId: TEMP_USER_ID}), + () => chatApi.post(`/chat/rooms/${roomId}/game/stop`, {userId: TEMP_USER_ID}), {message: '게임이 종료되었습니다.', scores: finalScores} ) }, @@ -455,7 +450,7 @@ export const gameService = { // GET /rooms/{roomId}/game/status - 게임 상태 조회 getStatus: async (roomId) => { return withMock( - () => chatApi.get(`/rooms/${roomId}/game/status`), + () => chatApi.get(`/chat/rooms/${roomId}/game/status`), { gameStatus: mockGameState.gameStatus, currentRound: mockGameState.currentRound, @@ -480,7 +475,7 @@ export const gameService = { .map(([userId, score], index) => ({rank: index + 1, userId, score})) return withMock( - () => chatApi.get(`/rooms/${roomId}/game/scores`), + () => chatApi.get(`/chat/rooms/${roomId}/game/scores`), {scores: sortedScores, currentRound: mockGameState.currentRound, totalRounds: mockGameState.totalRounds} ) }, @@ -522,7 +517,7 @@ export const gameService = { mockGameState.hintUsed = true return withMock( - () => chatApi.post(`/rooms/${roomId}/game/hint`, {userId: TEMP_USER_ID}), + () => chatApi.post(`/chat/rooms/${roomId}/game/hint`, {userId: TEMP_USER_ID}), {hint, hintUsed: true} ) }, @@ -532,7 +527,7 @@ export const gameService = { if (mockGameState.currentRound >= mockGameState.totalRounds) { mockGameState.gameStatus = 'FINISHED' return withMock( - () => chatApi.post(`/rooms/${roomId}/game/skip`, {userId: TEMP_USER_ID}), + () => chatApi.post(`/chat/rooms/${roomId}/game/skip`, {userId: TEMP_USER_ID}), {gameStatus: 'FINISHED', message: '게임이 종료되었습니다.'} ) } @@ -553,7 +548,7 @@ export const gameService = { } return withMock( - () => chatApi.post(`/rooms/${roomId}/game/skip`, {userId: TEMP_USER_ID}), + () => chatApi.post(`/chat/rooms/${roomId}/game/skip`, {userId: TEMP_USER_ID}), { currentRound: mockGameState.currentRound, currentDrawerId: mockGameState.currentDrawerId, diff --git a/src/domains/freetalk/components/ChatRoomCard.jsx b/src/domains/freetalk/components/ChatRoomCard.jsx index 7a3c34f..899b5ed 100644 --- a/src/domains/freetalk/components/ChatRoomCard.jsx +++ b/src/domains/freetalk/components/ChatRoomCard.jsx @@ -1,5 +1,6 @@ import {Box, Button, Card, CardContent, Chip, Typography} from '@mui/material' import {AccessTime as TimeIcon, Lock as LockIcon, People as PeopleIcon} from '@mui/icons-material' +import {useThemeMode} from '../../../contexts/ThemeContext' const levelColors = { beginner: {bg: '#e8f5e9', color: '#2e7d32', label: '초급'}, @@ -26,6 +27,8 @@ const formatDate = (date) => { } const ChatRoomCard = ({room, onClick}) => { + const {mode} = useThemeMode() + const isDark = mode === 'dark' const level = levelColors[room.level] || levelColors.beginner const handleEnterClick = (e) => { diff --git a/src/domains/freetalk/components/ChatRoomModal.jsx b/src/domains/freetalk/components/ChatRoomModal.jsx index bedd277..d3d0901 100644 --- a/src/domains/freetalk/components/ChatRoomModal.jsx +++ b/src/domains/freetalk/components/ChatRoomModal.jsx @@ -1,4 +1,4 @@ -import {useCallback, useEffect, useRef, useState} from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { Alert, Avatar, @@ -8,62 +8,75 @@ import { Fade, IconButton, Paper, - Tab, - Tabs, + Popover, + Slider, TextField, Typography, useTheme, } from '@mui/material' import { - Chat as ChatIcon, + Circle as CircleIcon, Close as CloseIcon, ExitToApp as ExitToAppIcon, Minimize as MinimizeIcon, + Opacity as OpacityIcon, OpenInFull as MaximizeIcon, Refresh as RefreshIcon, Send as SendIcon, - SportsEsports as GameIcon, VolumeUp as VolumeUpIcon, } from '@mui/icons-material' import { chatRoomService, - GAME_STATUS, - gameService, - MESSAGE_TYPES, messageService, - TEMP_USER_ID, voiceService } from '../../chat/services/chatService' -import {useSettings} from '../../../contexts/SettingsContext' -import {DESIGN_TOKENS, getChatStyles} from '../../../theme/theme' -import GameModePanel from './GameModePanel' - -const ChatRoomModal = ({open, onClose, room, onLeave}) => { +import { useSettings } from '../../../contexts/SettingsContext' +import { useAuth } from '../../../contexts/AuthContext' +import { useThemeMode } from '../../../contexts/ThemeContext' +import { DESIGN_TOKENS, getChatStyles } from '../../../theme/theme' +import { useChatWebSocket } from '../hooks/useChatWebSocket' +import SystemCommandMessage from './SystemCommandMessage' +import { MessageType } from '../types/chatCommandTypes' + +const ChatRoomModal = ({ open, onClose, room, onLeave }) => { const theme = useTheme() - const isDark = theme.palette.mode === 'dark' - const {settings} = useSettings() + const { mode } = useThemeMode() + const isDark = mode === 'dark' + const { settings } = useSettings() + const { user } = useAuth() + const currentUserId = user?.userId || user?.username || user?.sub const messagesEndRef = useRef(null) const dragRef = useRef(null) - const [messages, setMessages] = useState([]) + // WebSocket 훅 사용 + const { + isConnected, + messages, + error: wsError, + connect: wsConnect, + disconnect: wsDisconnect, + sendMessage: wsSendMessage, + setMessages, + } = useChatWebSocket(room?.id, currentUserId) + const [newMessage, setNewMessage] = useState('') const [loading, setLoading] = useState(true) const [sendingMessage, setSendingMessage] = useState(false) const [error, setError] = useState(null) const [playingTTS, setPlayingTTS] = useState(null) const [minimized, setMinimized] = useState(false) - const [position, setPosition] = useState({x: 0, y: 0}) - const [savedPosition, setSavedPosition] = useState({x: 0, y: 0}) + const [position, setPosition] = useState({ x: 0, y: 0 }) + const [savedPosition, setSavedPosition] = useState({ x: 0, y: 0 }) const [isDragging, setIsDragging] = useState(false) - const [dragOffset, setDragOffset] = useState({x: 0, y: 0}) - const [activeTab, setActiveTab] = useState(0) // 0: 채팅, 1: 게임 - const [gameStatus, setGameStatus] = useState(GAME_STATUS.NONE) + const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }) + const [opacity, setOpacity] = useState(100) + const [opacityAnchorEl, setOpacityAnchorEl] = useState(null) // 메시지 목록 조회 const fetchMessages = useCallback(async () => { if (!room?.id) return try { - const response = await messageService.getList(room.id, {limit: 50}) + const response = await messageService.getList(room.id, { limit: 50 }) const responseData = response.data || response const transformedMessages = (responseData.messages || []).map((msg) => ({ id: msg.messageId || msg.pk?.replace('MESSAGE#', ''), @@ -71,7 +84,7 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { userId: msg.userId, messageType: msg.messageType, createdAt: new Date(msg.createdAt), - isOwn: msg.userId === TEMP_USER_ID, + isOwn: msg.userId === currentUserId, })) setMessages(transformedMessages.reverse()) } catch (err) { @@ -80,62 +93,39 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { } }, [room?.id]) - // 게임 상태 조회 - const fetchGameStatus = useCallback(async () => { - if (!room?.id) return - try { - const response = await gameService.getStatus(room.id) - const data = response.data || response - setGameStatus(data.gameStatus || GAME_STATUS.NONE) - // 게임 중이면 게임 탭으로 전환 - if (data.gameStatus === GAME_STATUS.PLAYING) { - setActiveTab(1) - } - } catch (err) { - console.error('Failed to fetch game status:', err) + // WebSocket 에러 통합 + useEffect(() => { + if (wsError) { + setError(wsError) } - }, [room?.id]) + }, [wsError]) // 초기 로드 useEffect(() => { - if (open && room?.id) { + console.log('[ChatRoomModal] useEffect triggered:', { open, roomId: room?.id, currentUserId }) + if (open && room?.id && currentUserId) { + console.log('[ChatRoomModal] Initializing...', { roomId: room.id, userId: currentUserId }) setLoading(true) - setMessages([]) setMinimized(false) - setActiveTab(0) - Promise.all([ - fetchMessages(), - fetchGameStatus(), - ]).finally(() => setLoading(false)) - } - }, [open, room?.id, fetchMessages, fetchGameStatus]) - - // 게임 메시지 처리 - const handleGameMessage = (gameMessage) => { - const systemMessage = { - id: `game-${Date.now()}`, - content: gameMessage.content, - userId: 'SYSTEM', - messageType: gameMessage.type, - createdAt: new Date(), - isOwn: false, - isSystem: true, + + // WebSocket 연결 + wsConnect() + + // 기존 메시지 로드 + fetchMessages().finally(() => setLoading(false)) } - setMessages((prev) => [...prev, systemMessage]) - - // 게임 시작 시 게임 탭으로 전환 - if (gameMessage.type === MESSAGE_TYPES.GAME_START) { - setGameStatus(GAME_STATUS.PLAYING) - setActiveTab(1) - } else if (gameMessage.type === MESSAGE_TYPES.GAME_END) { - setGameStatus(GAME_STATUS.NONE) - setActiveTab(0) + + return () => { + if (open && room?.id) { + console.log('[ChatRoomModal] Disconnecting WebSocket...') + wsDisconnect() + } } - } + }, [open, room?.id, currentUserId]) // eslint-disable-line react-hooks/exhaustive-deps // 스크롤 맨 아래로 const scrollToBottom = (instant = false) => { - messagesEndRef.current?.scrollIntoView({behavior: instant ? 'instant' : 'smooth'}) + messagesEndRef.current?.scrollIntoView({ behavior: instant ? 'instant' : 'smooth' }) } // 메시지 로드 완료 후 스크롤 @@ -194,7 +184,7 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { } }, [isDragging, dragOffset]) - // 메시지 전송 + // 메시지 전송 (WebSocket 사용) const handleSendMessage = async () => { if (!newMessage.trim() || sendingMessage) return @@ -202,25 +192,38 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { const messageContent = newMessage.trim() setNewMessage('') - const tempMessage = { - id: `temp-${Date.now()}`, - content: messageContent, - userId: TEMP_USER_ID, - messageType: 'TEXT', - createdAt: new Date(), - isOwn: true, - } - setMessages((prev) => [...prev, tempMessage]) + // /start, /stop 명령어는 서버 응답(WebSocket game_start/game_end)을 기다림 + // 탭 전환은 useEffect의 wsGameState 감지에서 처리 - try { - await messageService.send(room.id, messageContent) - await fetchMessages() - } catch (err) { - console.error('Failed to send message:', err) - setMessages((prev) => prev.filter((m) => m.id !== tempMessage.id)) - setError('메시지 전송에 실패했습니다') - } finally { + // WebSocket으로 전송 + if (isConnected) { + const success = wsSendMessage(messageContent, 'TEXT') + if (!success) { + setError('메시지 전송에 실패했습니다') + } setSendingMessage(false) + } else { + // WebSocket 연결이 안 된 경우 REST API fallback + const tempMessage = { + id: `temp-${Date.now()}`, + content: messageContent, + userId: currentUserId, + messageType: 'TEXT', + createdAt: new Date(), + isOwn: true, + } + setMessages((prev) => [...prev, tempMessage]) + + try { + await messageService.send(room.id, messageContent) + await fetchMessages() + } catch (err) { + console.error('Failed to send message:', err) + setMessages((prev) => prev.filter((m) => m.id !== tempMessage.id)) + setError('메시지 전송에 실패했습니다') + } finally { + setSendingMessage(false) + } } } @@ -256,7 +259,7 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { if (!minimized) { // 최소화: 현재 위치 저장 후 우측 하단으로 이동 setSavedPosition(position) - setPosition({x: 0, y: 0}) + setPosition({ x: 0, y: 0 }) } else { // 최대화: 저장된 위치로 복원 후 스크롤 맨 아래로 setPosition(savedPosition) @@ -278,10 +281,15 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { } const formatTime = (date) => { + // date가 문자열이거나 Date 객체일 수 있음 + const dateObj = date instanceof Date ? date : new Date(date) + if (isNaN(dateObj.getTime())) { + return '' // 유효하지 않은 날짜는 빈 문자열 반환 + } return new Intl.DateTimeFormat('ko-KR', { hour: '2-digit', minute: '2-digit', - }).format(date) + }).format(dateObj) } if (!open) return null @@ -291,6 +299,7 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { { zIndex: 1300, cursor: isDragging ? 'grabbing' : 'default', border: isDark ? '1px solid rgba(255,255,255,0.1)' : 'none', + pointerEvents: opacity < 50 ? 'none' : 'auto', }} > - {/* 헤더 - 드래그 가능 */} + {/* 헤더 - 드래그 가능, 항상 조작 가능 */} { justifyContent: 'space-between', cursor: 'grab', userSelect: 'none', + pointerEvents: 'auto', }} > - + {room?.name || '채팅방'} + {/* 연결 상태 표시 */} + {room?.level && ( { /> )} - - - + + setOpacityAnchorEl(e.currentTarget)} + sx={{ color: 'white' }} + title="투명도" + > + - - {minimized ? : } + setOpacityAnchorEl(null)} + anchorOrigin={{ vertical: 'top', horizontal: 'center' }} + transformOrigin={{ vertical: 'bottom', horizontal: 'center' }} + slotProps={{ + paper: { + sx: { pointerEvents: 'auto' } + } + }} + > + + + 투명도: {opacity}% + + setOpacity(v)} + min={10} + max={100} + size="small" + /> + + + + + + + {minimized ? : } - - + + - - + + {!minimized && ( <> - {/* 탭 (채팅/게임) */} - setActiveTab(v)} - variant="fullWidth" - sx={{ - minHeight: 36, - '& .MuiTab-root': {minHeight: 36, py: 0.5}, - }} - > - } - iconPosition="start" - label="채팅" - sx={{fontSize: '0.75rem'}} - /> - } - iconPosition="start" - label="캐치마인드" - sx={{fontSize: '0.75rem'}} - /> - - {/* 에러 메시지 */} {error && ( - setError(null)} sx={{borderRadius: 0}}> + setError(null)} sx={{ borderRadius: 0 }}> {error} )} - {/* 게임 모드 */} - {activeTab === 1 && ( - - + {/* 메시지 영역 */} + {loading ? ( + + - )} - - {/* 채팅 모드 - 메시지 영역 */} - {activeTab === 0 && ( - loading ? ( - - - - ) : ( - - {messages.length === 0 ? ( - - - 첫 메시지를 보내보세요! - - - ) : ( - messages.map((message) => ( + // 스크롤바 숨김 (hover 시만 표시) + '&::-webkit-scrollbar': { + width: 6, + }, + '&::-webkit-scrollbar-thumb': { + bgcolor: 'transparent', + borderRadius: 3, + }, + '&:hover::-webkit-scrollbar-thumb': { + bgcolor: isDark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)', + }, + }} + > + {messages.length === 0 ? ( + + + 첫 메시지를 보내보세요! + + + ) : ( + messages.map((message) => { + // 시스템 명령어 메시지 (SYSTEM_COMMAND) + if (message.messageType === MessageType.SYSTEM_COMMAND || message.messageType === 'SYSTEM_COMMAND') { + return ( + + ) + } + + // 투표 관련 메시지 (POLL_CREATE, POLL_VOTE, POLL_END) + if (message.messageType === 'POLL_CREATE' || message.messageType === 'poll_create' || + message.messageType === 'POLL_VOTE' || message.messageType === 'poll_vote' || + message.messageType === 'POLL_END' || message.messageType === 'poll_end') { + return ( + + + + {message.content} + + + + ) + } + + return ( { whiteSpace: 'pre-wrap', height: 'auto', py: 0.5, - '& .MuiChip-label': {whiteSpace: 'pre-wrap'}, + '& .MuiChip-label': { whiteSpace: 'pre-wrap' }, }} /> ) : ( @@ -484,15 +545,14 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { maxWidth: '70%', }} > - {!message.isOwn && ( - - {message.userId} - - )} + + {message.nickname || message.userId} + { size="small" onClick={() => handlePlayTTS(message.id)} disabled={playingTTS === message.id} - sx={{p: 0.25}} + sx={{ p: 0.25 }} > {playingTTS === message.id ? ( - + ) : ( + }} /> )} + color="text.secondary" + sx={{ fontSize: '0.55rem' }}> {formatTime(message.createdAt)} @@ -557,17 +617,17 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { size="small" onClick={() => handlePlayTTS(message.id)} disabled={playingTTS === message.id} - sx={{p: 0.25}} + sx={{ p: 0.25 }} > {playingTTS === message.id ? ( - + ) : ( - + )} + color="text.secondary" + sx={{ fontSize: '0.55rem' }}> {formatTime(message.createdAt)} @@ -577,11 +637,11 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { )} - )) - )} -
- - ) + ) + }) + )} +
+ )} {/* 입력 영역 */} @@ -594,6 +654,7 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { borderTop: 1, borderColor: 'divider', bgcolor: 'background.paper', + pointerEvents: 'auto', }} > { color: 'white', width: 32, height: 32, - '&:hover': {bgcolor: 'primary.dark'}, - '&:disabled': {bgcolor: 'grey.300'}, + '&:hover': { bgcolor: 'primary.dark' }, + '&:disabled': { bgcolor: 'grey.300' }, }} > - {sendingMessage ? : - } + {sendingMessage ? : + } diff --git a/src/domains/freetalk/components/CommandAutocomplete.jsx b/src/domains/freetalk/components/CommandAutocomplete.jsx new file mode 100644 index 0000000..09b83e3 --- /dev/null +++ b/src/domains/freetalk/components/CommandAutocomplete.jsx @@ -0,0 +1,150 @@ +import { useEffect, useState } from 'react' +import { Box, Paper, Typography, List, ListItem, ListItemButton, ListItemText } from '@mui/material' +import { useThemeMode } from '../../../contexts/ThemeContext' +import { searchCommands } from '../types/chatCommandTypes' + +/** + * 채팅 명령어 자동완성 컴포넌트 + * 사용자가 "/"를 입력하면 사용 가능한 명령어 목록을 표시합니다. + * + * @param {Object} props + * @param {string} props.input - 현재 입력 값 + * @param {function} props.onSelect - 명령어 선택 시 호출되는 콜백 + * @param {boolean} props.show - 표시 여부 + * @returns {JSX.Element|null} + */ +const CommandAutocomplete = ({ input, onSelect, show }) => { + const { mode } = useThemeMode() + const isDark = mode === 'dark' + const [selectedIndex, setSelectedIndex] = useState(0) + const [filteredCommands, setFilteredCommands] = useState([]) + + // 입력값에 따라 명령어 필터링 + useEffect(() => { + if (!show || !input.startsWith('/')) { + setFilteredCommands([]) + setSelectedIndex(0) + return + } + + const commands = searchCommands(input) + setFilteredCommands(commands) + setSelectedIndex(0) + }, [input, show]) + + // 키보드 이벤트 처리 + useEffect(() => { + const handleKeyDown = (e) => { + if (!show || filteredCommands.length === 0) return + + if (e.key === 'ArrowDown') { + e.preventDefault() + setSelectedIndex((prev) => (prev + 1) % filteredCommands.length) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setSelectedIndex((prev) => (prev - 1 + filteredCommands.length) % filteredCommands.length) + } else if (e.key === 'Enter' && filteredCommands[selectedIndex]) { + e.preventDefault() + onSelect(filteredCommands[selectedIndex].command) + } else if (e.key === 'Escape') { + onSelect('') + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [show, filteredCommands, selectedIndex, onSelect]) + + // 표시할 항목이 없으면 렌더링하지 않음 + if (!show || filteredCommands.length === 0) { + return null + } + + return ( + + + + 사용 가능한 명령어 ({filteredCommands.length}) + + + + + {filteredCommands.map((cmd, index) => ( + + onSelect(cmd.command)} + sx={{ + py: 1.5, + px: 2, + '&.Mui-selected': { + bgcolor: isDark ? 'rgba(250, 204, 21, 0.2)' : 'rgba(254, 229, 0, 0.3)', + '&:hover': { + bgcolor: isDark ? 'rgba(250, 204, 21, 0.25)' : 'rgba(254, 229, 0, 0.4)', + }, + }, + '&:hover': { + bgcolor: isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)', + }, + }} + > + + + {cmd.command} + + + {cmd.usage} + + + } + secondary={ + + {cmd.description} + + } + /> + + + ))} + + + + + 화살표로 선택, Enter로 입력, Esc로 닫기 + + + + ) +} + +export default CommandAutocomplete diff --git a/src/domains/freetalk/components/GameModePanel.jsx b/src/domains/freetalk/components/GameModePanel.jsx index f77c21d..7891cb4 100644 --- a/src/domains/freetalk/components/GameModePanel.jsx +++ b/src/domains/freetalk/components/GameModePanel.jsx @@ -8,18 +8,23 @@ import { SkipNext as SkipIcon, Stop as StopIcon, } from '@mui/icons-material' -import {GAME_STATUS, gameService, TEMP_USER_ID} from '../../chat/services/chatService' +import {GAME_STATUS, gameService} from '../../chat/services/chatService' +import {useAuth} from '../../../contexts/AuthContext' +import {useThemeMode} from '../../../contexts/ThemeContext' import {DESIGN_TOKENS} from '../../../theme/theme' const CANVAS_WIDTH = 340 const CANVAS_HEIGHT = 200 -const GameModePanel = ({roomId, onGameMessage}) => { +const GameModePanel = ({roomId, onGameMessage, initialGameStatus, wsGameState, isConnected, onStartGame, onStopGame, onSendMessage, onSendDrawing, onClearDrawing, receivedDrawing, onDrawingProcessed, shouldClearCanvas, onCanvasCleared, messages, correctAnswerBubble, onBubbleProcessed}) => { const theme = useTheme() - const isDark = theme.palette.mode === 'dark' + const {mode} = useThemeMode() + const isDark = mode === 'dark' + const {user} = useAuth() + const currentUserId = user?.userId || user?.username const canvasRef = useRef(null) const [gameState, setGameState] = useState({ - gameStatus: GAME_STATUS.NONE, + gameStatus: initialGameStatus || GAME_STATUS.NONE, currentRound: 0, totalRounds: 5, currentDrawerId: null, @@ -34,32 +39,105 @@ const GameModePanel = ({roomId, onGameMessage}) => { const [brushColor, setBrushColor] = useState('#000000') const [brushSize, setBrushSize] = useState(3) const [loading, setLoading] = useState(false) + const [currentStroke, setCurrentStroke] = useState([]) // 현재 그리는 스트로크 + const [bubbles, setBubbles] = useState([]) // 비눗방울 애니메이션 - const isDrawer = gameState.currentDrawerId === TEMP_USER_ID + const isDrawer = gameState.currentDrawerId === currentUserId const isGameActive = gameState.gameStatus === GAME_STATUS.PLAYING + // 최신 값을 interval에서 사용하기 위한 ref + const isDrawerRef = useRef(isDrawer) + const isConnectedRef = useRef(isConnected) + const onSendMessageRef = useRef(onSendMessage) + useEffect(() => { + isDrawerRef.current = isDrawer + isConnectedRef.current = isConnected + onSendMessageRef.current = onSendMessage + }, [isDrawer, isConnected, onSendMessage]) + + // 디버깅 로그 + console.log('[GameModePanel] State:', { + isDrawer, + isGameActive, + currentDrawerId: gameState.currentDrawerId, + currentUserId, + gameStatus: gameState.gameStatus + }) + // 게임 상태 조회 const fetchGameStatus = useCallback(async () => { try { const response = await gameService.getStatus(roomId) const data = response.data || response - setGameState(data) + setGameState(prev => ({ + ...prev, + ...data, + gameStatus: data.gameStatus || GAME_STATUS.NONE, + })) } catch (err) { console.error('Failed to fetch game status:', err) } }, [roomId]) - // 타이머 + // 마운트 시 게임 상태 조회 + useEffect(() => { + if (roomId) { + fetchGameStatus() + } + }, [roomId, fetchGameStatus]) + + // 부모 컴포넌트의 게임 상태 변경 반영 + useEffect(() => { + if (initialGameStatus) { + setGameState(prev => ({ + ...prev, + gameStatus: initialGameStatus, + })) + } + }, [initialGameStatus]) + + // WebSocket 게임 상태 동기화 (제시어, 출제자, 라운드 등) + useEffect(() => { + if (wsGameState) { + console.log('[GameModePanel] Syncing wsGameState:', wsGameState) + setGameState(prev => ({ + ...prev, + gameStatus: wsGameState.status === 'PLAYING' ? GAME_STATUS.PLAYING : + wsGameState.status === 'FINISHED' ? GAME_STATUS.NONE : prev.gameStatus, + currentRound: wsGameState.currentRound ?? prev.currentRound, + totalRounds: wsGameState.totalRounds ?? prev.totalRounds, + currentDrawerId: wsGameState.currentDrawerId ?? prev.currentDrawerId, + currentWord: wsGameState.currentWord ?? prev.currentWord, + roundStartTime: wsGameState.roundStartTime ?? prev.roundStartTime, + scores: wsGameState.scores ?? prev.scores, + hintUsed: wsGameState.hintUsed ?? prev.hintUsed, + hint: wsGameState.hint ?? prev.hint, + })) + } + }, [wsGameState]) + + // 타이머 + 자동 스킵 (출제자만) + const autoSkipSentRef = useRef(false) useEffect(() => { if (!isGameActive || !gameState.roundStartTime) return + // 새 라운드 시작 시 autoSkip 플래그 초기화 + autoSkipSentRef.current = false + const interval = setInterval(() => { const elapsed = Math.floor((Date.now() - gameState.roundStartTime) / 1000) const remaining = Math.max(0, gameState.roundTimeLimit - elapsed) setTimeLeft(remaining) - if (remaining === 0) { - // 시간 초과 처리 + // 시간 초과 시 자동 스킵 (출제자만, 연결된 경우만, 한 번만 전송) + // ref를 사용해서 최신 값 확인 + if (remaining === 0 && isDrawerRef.current && isConnectedRef.current && !autoSkipSentRef.current && onSendMessageRef.current) { + console.log('[GameModePanel] Time expired, auto-skipping... isDrawer:', isDrawerRef.current, 'isConnected:', isConnectedRef.current) + autoSkipSentRef.current = true + onSendMessageRef.current('/skip', 'TEXT') + clearInterval(interval) + } else if (remaining === 0) { + console.log('[GameModePanel] Time expired, but cannot skip. isDrawer:', isDrawerRef.current, 'isConnected:', isConnectedRef.current) clearInterval(interval) } }, 1000) @@ -67,39 +145,49 @@ const GameModePanel = ({roomId, onGameMessage}) => { return () => clearInterval(interval) }, [isGameActive, gameState.roundStartTime, gameState.roundTimeLimit]) - // 게임 시작 - const handleStartGame = async () => { - setLoading(true) - try { - const response = await gameService.start(roomId) - const data = response.data || response - setGameState(data) - onGameMessage?.({ - type: 'game_start', - content: `🎮 게임 시작! 총 ${data.totalRounds}라운드\n출제자: ${data.currentDrawerId}`, - }) - } catch (err) { - console.error('Failed to start game:', err) - } finally { - setLoading(false) + // 게임 시작 - WebSocket을 통해 /start 명령어 전송 (서버에서 인원 검증) + const handleStartGame = () => { + console.log('[GameModePanel] handleStartGame called, onStartGame:', !!onStartGame) + if (onStartGame) { + // WebSocket으로 /start 명령어 전송 (채팅창에서 /start 입력과 동일) + const result = onStartGame() + console.log('[GameModePanel] onStartGame result:', result) + } else { + // fallback: REST API 사용 (WebSocket 미연결 시) + setLoading(true) + gameService.start(roomId) + .then(response => { + const data = response.data || response + setGameState(data) + onGameMessage?.({ + type: 'game_start', + content: `🎮 게임 시작! 총 ${data.totalRounds}라운드\n출제자: ${data.currentDrawerId}`, + }) + }) + .catch(err => console.error('Failed to start game:', err)) + .finally(() => setLoading(false)) } } - // 게임 종료 - const handleStopGame = async () => { - setLoading(true) - try { - const response = await gameService.stop(roomId) - const data = response.data || response - setGameState(prev => ({...prev, gameStatus: GAME_STATUS.NONE})) - onGameMessage?.({ - type: 'game_end', - content: `🎮 게임 종료!\n${data.message}`, - }) - } catch (err) { - console.error('Failed to stop game:', err) - } finally { - setLoading(false) + // 게임 종료 - WebSocket을 통해 /stop 명령어 전송 + const handleStopGame = () => { + if (onStopGame) { + // WebSocket으로 /stop 명령어 전송 (채팅창에서 /stop 입력과 동일) + onStopGame() + } else { + // fallback: REST API 사용 (WebSocket 미연결 시) + setLoading(true) + gameService.stop(roomId) + .then(response => { + const data = response.data || response + setGameState(prev => ({...prev, gameStatus: GAME_STATUS.NONE})) + onGameMessage?.({ + type: 'game_end', + content: `🎮 게임 종료!\n${data.message}`, + }) + }) + .catch(err => console.error('Failed to stop game:', err)) + .finally(() => setLoading(false)) } } @@ -147,28 +235,57 @@ const GameModePanel = ({roomId, onGameMessage}) => { } // 캔버스 초기화 - const clearCanvas = () => { + const clearCanvas = (sendToOthers = false) => { const canvas = canvasRef.current if (!canvas) return const ctx = canvas.getContext('2d') ctx.fillStyle = '#ffffff' ctx.fillRect(0, 0, canvas.width, canvas.height) + + // 다른 사용자에게도 초기화 메시지 전송 + if (sendToOthers && onClearDrawing) { + onClearDrawing() + } } - // 캔버스 드로잉 + // 캔버스 드로잉 - 스트로크 배열 방식 const startDrawing = (e) => { if (!isDrawer) return + const canvas = canvasRef.current + if (!canvas) return + + const rect = canvas.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top + setIsDrawing(true) - draw(e) + // 스트로크 시작 + setCurrentStroke([{x, y, type: 'start', color: brushColor, width: brushSize}]) + + const ctx = canvas.getContext('2d') + ctx.beginPath() + ctx.moveTo(x, y) } const stopDrawing = () => { + if (!isDrawing) return setIsDrawing(false) + const canvas = canvasRef.current if (canvas) { const ctx = canvas.getContext('2d') ctx.beginPath() } + + // 스트로크 완료 시 WebSocket으로 전송 + if (currentStroke.length > 0 && onSendDrawing) { + const strokeData = [...currentStroke, {type: 'end'}] + console.log('[GameModePanel] Sending stroke:', strokeData) + onSendDrawing(JSON.stringify(strokeData)) + } else { + console.log('[GameModePanel] Not sending:', {strokeLength: currentStroke.length, hasOnSendDrawing: !!onSendDrawing}) + } + setCurrentStroke([]) } const draw = (e) => { @@ -188,15 +305,115 @@ const GameModePanel = ({roomId, onGameMessage}) => { ctx.stroke() ctx.beginPath() ctx.moveTo(x, y) + + // 스트로크에 좌표 추가 + setCurrentStroke(prev => [...prev, {x, y, type: 'move'}]) } - // 캔버스 초기화 + // 캔버스 초기화 (라운드 변경 시) useEffect(() => { if (canvasRef.current) { clearCanvas() } }, [gameState.currentRound]) + // 원격 캔버스 초기화 (다른 사용자가 초기화 시) + useEffect(() => { + if (shouldClearCanvas && canvasRef.current) { + console.log('[GameModePanel] Clearing canvas from remote') + clearCanvas(false) // 다시 전송하지 않음 + onCanvasCleared?.() + } + }, [shouldClearCanvas, onCanvasCleared]) + + // 수신된 그리기 데이터를 캔버스에 그리기 + useEffect(() => { + if (!receivedDrawing || !canvasRef.current) return + + const canvas = canvasRef.current + const ctx = canvas.getContext('2d') + + try { + // content에서 스트로크 데이터 파싱 + const content = receivedDrawing.content + const strokeData = typeof content === 'string' ? JSON.parse(content) : content + + console.log('[GameModePanel] Drawing received stroke:', strokeData) + + if (!Array.isArray(strokeData)) return + + // 스트로크 그리기 + strokeData.forEach((point, index) => { + if (point.type === 'start') { + ctx.beginPath() + ctx.moveTo(point.x, point.y) + ctx.lineWidth = point.width || 3 + ctx.lineCap = 'round' + ctx.strokeStyle = point.color || '#000000' + } else if (point.type === 'move') { + ctx.lineTo(point.x, point.y) + ctx.stroke() + ctx.beginPath() + ctx.moveTo(point.x, point.y) + } else if (point.type === 'end') { + ctx.beginPath() + } + }) + + // 처리 완료 알림 + onDrawingProcessed?.() + } catch (err) { + console.error('[GameModePanel] Error drawing received data:', err) + } + }, [receivedDrawing, onDrawingProcessed]) + + // 비눗방울 애니메이션 생성 + const createBubble = useCallback((userId, content, isCorrect = false) => { + const id = `bubble-${Date.now()}-${Math.random()}` + const bubble = { + id, + userId, + content, + isCorrect, + x: Math.random() * (CANVAS_WIDTH - 100) + 50, // 랜덤 x 위치 + startTime: Date.now(), + } + setBubbles(prev => [...prev, bubble]) + + // 3초 후 자동 삭제 + setTimeout(() => { + setBubbles(prev => prev.filter(b => b.id !== id)) + }, 3000) + }, []) + + // correctAnswerBubble prop으로 정답 버블 생성 (정답일 때 특별 효과) + // 백엔드가 정답 시 자동으로 ROUND_END를 보내므로 프론트엔드에서 /skip 불필요 + useEffect(() => { + if (correctAnswerBubble) { + createBubble(correctAnswerBubble.userId, correctAnswerBubble.content, true) // isCorrect = true + onBubbleProcessed?.() + // 백엔드가 정답 처리 후 자동으로 라운드 전환하므로 /skip 제거 + } + }, [correctAnswerBubble, createBubble, onBubbleProcessed]) + + // 게임 중 모든 채팅 메시지를 비눗방울로 표시 + const lastMessageIdRef = useRef(null) + useEffect(() => { + if (!isGameActive || !messages || messages.length === 0) return + + // 마지막 메시지만 처리 (중복 방지) + const lastMessage = messages[messages.length - 1] + if (!lastMessage || lastMessage.id === lastMessageIdRef.current) return + if (lastMessage.isSystem || lastMessage.messageType === 'SYSTEM') return + if (lastMessage.messageType === 'DRAWING' || lastMessage.messageType === 'DRAWING_CLEAR') return + + // 출제자의 메시지는 제외 (출제자가 답을 알려줘선 안 됨) + if (lastMessage.userId === gameState.currentDrawerId) return + + lastMessageIdRef.current = lastMessage.id + createBubble(lastMessage.userId, lastMessage.content, false) // isCorrect = false + }, [messages, isGameActive, gameState.currentDrawerId, createBubble]) + // 점수 정렬 const sortedScores = Object.entries(gameState.scores || {}) .sort(([, a], [, b]) => b - a) @@ -211,7 +428,10 @@ const GameModePanel = ({roomId, onGameMessage}) => { + )} + + ) + })} + + + {/* 하단 정보 */} + + + + + 총 {totalVotes}명 참여 + + + + {isCreator && poll.isActive && ( + + + + )} + + + {/* 생성자 정보 */} + + 생성자: {poll.creatorId} + + + + ) +} + +export default PollCard diff --git a/src/domains/freetalk/components/PollResultBar.jsx b/src/domains/freetalk/components/PollResultBar.jsx new file mode 100644 index 0000000..bd46201 --- /dev/null +++ b/src/domains/freetalk/components/PollResultBar.jsx @@ -0,0 +1,57 @@ +import { Box, LinearProgress } from '@mui/material' +import { useThemeMode } from '../../../contexts/ThemeContext' + +/** + * 투표 결과 진행바 컴포넌트 + * 투표 옵션의 득표율을 시각적으로 표시합니다. + * + * @param {Object} props + * @param {number} props.percentage - 득표율 (0-100) + * @param {boolean} props.isUserVoted - 사용자가 해당 옵션에 투표했는지 여부 + * @returns {JSX.Element} + */ +const PollResultBar = ({ percentage, isUserVoted = false }) => { + const { mode } = useThemeMode() + const isDark = mode === 'dark' + + return ( + + + {isUserVoted && ( + + )} + + ) +} + +export default PollResultBar diff --git a/src/domains/freetalk/components/SystemCommandMessage.jsx b/src/domains/freetalk/components/SystemCommandMessage.jsx new file mode 100644 index 0000000..253382f --- /dev/null +++ b/src/domains/freetalk/components/SystemCommandMessage.jsx @@ -0,0 +1,211 @@ +import { Box, Paper, Typography } from '@mui/material' +import { useThemeMode } from '../../../contexts/ThemeContext' +import { SystemCommandConfig } from '../types/chatCommandTypes' + +/** + * 시스템 명령어 메시지 컴포넌트 + * /dice, /coin, /random, /members, /help 등의 명령어 결과를 표시합니다. + * + * @param {Object} props + * @param {Object} props.data - 시스템 명령어 데이터 + * @param {string} props.data.commandType - 명령어 타입 + * @param {string} props.data.userId - 실행한 사용자 ID + * @param {Object} props.data.result - 명령어 결과 + * @param {string} props.data.displayText - 표시할 텍스트 + * @returns {JSX.Element} + */ +const SystemCommandMessage = ({ data }) => { + const { mode } = useThemeMode() + const isDark = mode === 'dark' + + // 명령어 타입 추출 + const commandType = data?.commandType || data?.type || data?.raw?.type || 'help' + const config = SystemCommandConfig[commandType] || SystemCommandConfig.help + const { icon, color, bgColor } = config + + // 표시 텍스트 추출 + const displayText = data?.displayText || data?.message || data?.content || '' + const userId = data?.userId || data?.nickname || '' + + // 결과 값 추출 + const result = data?.result || data?.raw || {} + + return ( + + + + {/* 아이콘 */} + + {icon} + + + {/* 내용 */} + + {/* displayText가 있으면 그대로 표시 (백엔드에서 이미 포맷팅됨) */} + {displayText ? ( + + {displayText} + + ) : ( + <> + + + {userId} + + + {/* 추가 결과 정보 */} + {renderCommandResult(commandType, result, isDark)} + + )} + + + + + ) +} + +/** + * 명령어 타입별 결과 렌더링 + */ +function renderCommandResult(commandType, result, isDark) { + if (!result) return null + + switch (commandType) { + case 'dice': + return ( + + {result.value} + + ) + + case 'coin': + return ( + + {result.side === 'heads' ? '앞면' : '뒷면'} + + ) + + case 'random': + return ( + + {result.value} + {result.min !== undefined && result.max !== undefined && ( + + ({result.min}-{result.max}) + + )} + + ) + + case 'members': + return result.memberIds ? ( + + + 참여 중인 멤버 ({result.totalCount}명): + + + {result.memberIds.map((memberId) => ( + + {memberId} + + ))} + + + ) : null + + case 'help': + return result.commands ? ( + + + 사용 가능한 명령어: + + + {result.commands.map((cmd) => ( + + {cmd.command} - {cmd.description} + + ))} + + + ) : null + + default: + return null + } +} + +export default SystemCommandMessage diff --git a/src/domains/freetalk/hooks/useChatWebSocket.js b/src/domains/freetalk/hooks/useChatWebSocket.js new file mode 100644 index 0000000..f566225 --- /dev/null +++ b/src/domains/freetalk/hooks/useChatWebSocket.js @@ -0,0 +1,685 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { chatWebSocketService } from '../services/chatWebSocketService' +import { chatRoomService } from '../../chat/services/chatService' + +/** + * Chat WebSocket 훅 + * 실시간 채팅 및 게임을 위한 상태 관리 + * + * 연결 흐름: + * 1. POST /chat/rooms/{roomId}/join 호출 → roomToken 발급 + * 2. roomToken으로 WebSocket 연결 + */ +export function useChatWebSocket(roomId, userId) { + const [isConnected, setIsConnected] = useState(false) + const [messages, setMessages] = useState([]) + const [gameState, setGameState] = useState(null) + const [error, setError] = useState(null) + const [receivedDrawing, setReceivedDrawing] = useState(null) + const [shouldClearCanvas, setShouldClearCanvas] = useState(false) + const [correctAnswerBubble, setCorrectAnswerBubble] = useState(null) + + const isConnectedRef = useRef(false) + const roomTokenRef = useRef(null) + + /** + * WebSocket 연결 + */ + const connect = useCallback(async (forceNewToken = false) => { + console.log('[useChatWebSocket] Attempting to connect...', { roomId, userId, forceNewToken }) + + if (!roomId || !userId) { + console.error('[useChatWebSocket] roomId and userId are required', { roomId, userId }) + return + } + + try { + setError(null) + + // forceNewToken이 true면 기존 토큰 삭제 + if (forceNewToken) { + console.log('[useChatWebSocket] Forcing new token, clearing old one') + sessionStorage.removeItem(`roomToken_${roomId}`) + } + + // 1. 먼저 sessionStorage에서 roomToken 확인 (이미 발급받은 경우) + let roomToken = sessionStorage.getItem(`roomToken_${roomId}`) + console.log('[useChatWebSocket] Checking sessionStorage for roomToken:', roomToken ? 'found' : 'not found') + + // 2. 없으면 REST API로 roomToken 발급받기 + if (!roomToken) { + console.log('[useChatWebSocket] Getting roomToken via join API...') + const joinResponse = await chatRoomService.join(roomId) + roomToken = joinResponse.roomToken || joinResponse.data?.roomToken + console.log('[useChatWebSocket] Got roomToken from API:', roomToken ? 'exists' : 'missing') + + // sessionStorage에 저장 + if (roomToken) { + sessionStorage.setItem(`roomToken_${roomId}`, roomToken) + } + } + + if (!roomToken) { + throw new Error('roomToken not received') + } + + console.log('[useChatWebSocket] Using roomToken:', roomToken.substring(0, 20) + '...') + roomTokenRef.current = roomToken + + // 콜백 설정 + chatWebSocketService.setCallbacks({ + onMessage: (data) => { + const messageId = data.messageId || `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + const newMessage = { + id: messageId, + content: data.content, + userId: data.userId, + nickname: data.nickname, + messageType: data.messageType || 'TEXT', + createdAt: data.createdAt || new Date().toISOString(), + isOwn: data.userId === userId, + // 추가 데이터 (투표, 시스템 명령어 등) + data: data.data || data.payload, + } + setMessages((prev) => { + // 중복 메시지 방지 (같은 messageId가 이미 있으면 추가하지 않음) + if (data.messageId && prev.some(m => m.id === data.messageId)) { + return prev + } + return [...prev, newMessage] + }) + }, + + onPollCreate: (data) => { + console.log('[useChatWebSocket] Poll created:', data) + const pollData = data.data || data + const pollMessage = { + id: `poll-${pollData.pollId}`, + messageType: 'POLL_CREATE', + userId: pollData.creatorId || pollData.createdBy, + content: data.content, + createdAt: data.createdAt || pollData.createdAt || new Date().toISOString(), + isOwn: (pollData.creatorId || pollData.createdBy) === userId, + data: pollData, + } + setMessages((prev) => [...prev, pollMessage]) + }, + + onPollVote: (data) => { + console.log('[useChatWebSocket] Poll vote:', data) + const voteData = data.data || data + setMessages((prev) => { + const updated = prev.map((msg) => { + if (msg.id === `poll-${voteData.pollId}` && msg.data) { + return { + ...msg, + data: { + ...msg.data, + options: voteData.updatedOptions || msg.data.options, + }, + } + } + return msg + }) + if (data.content) { + updated.push({ + id: `poll-vote-${Date.now()}`, + messageType: 'POLL_VOTE', + userId: voteData.voterId || voteData.userId, + content: data.content, + createdAt: data.createdAt || new Date().toISOString(), + data: voteData, + }) + } + return updated + }) + }, + + onPollEnd: (data) => { + console.log('[useChatWebSocket] Poll ended:', data) + console.log('[useChatWebSocket] Poll end content:', data.content) + const endData = data.data || data + setMessages((prev) => { + const updated = prev.map((msg) => { + if (msg.id === `poll-${endData.pollId}` && msg.data) { + return { + ...msg, + data: { + ...msg.data, + isActive: false, + options: endData.finalResults || msg.data.options, + }, + } + } + return msg + }) + console.log('[useChatWebSocket] Adding poll end message, content exists:', !!data.content) + if (data.content) { + const pollEndMsg = { + id: `poll-end-${endData.pollId}`, + messageType: 'POLL_END', + userId: endData.endedBy, + content: data.content, + createdAt: data.createdAt || new Date().toISOString(), + data: endData, + } + console.log('[useChatWebSocket] Poll end message:', pollEndMsg) + updated.push(pollEndMsg) + } + return updated + }) + }, + + onClearChat: (data) => { + console.log('[useChatWebSocket] Clear chat:', data) + const clearData = data.data || data + // 특정 사용자의 메시지만 삭제 + setMessages((prev) => + prev.filter((msg) => !clearData.messageIds?.includes(msg.id)) + ) + }, + + onLeaveRoom: (data) => { + console.log('[useChatWebSocket] User left room:', data) + const leaveData = data.data || data + const systemMessage = { + id: `leave-${Date.now()}`, + content: `${leaveData.userId}님이 채팅방을 나갔습니다.`, + messageType: 'SYSTEM', + createdAt: leaveData.leftAt || new Date().toISOString(), + isSystem: true, + } + setMessages((prev) => [...prev, systemMessage]) + }, + + onSystemCommand: (data) => { + console.log('[useChatWebSocket] System command received:', data) + const commandData = data.data || {} + const displayText = data.content || data.message || commandData.content || commandData.message || commandData.displayText || '' + console.log('[useChatWebSocket] System command displayText:', displayText) + const commandMessage = { + id: `syscmd-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + messageType: 'SYSTEM_COMMAND', + userId: commandData.userId || commandData.nickname || data.userId, + content: displayText, + createdAt: data.createdAt || new Date().toISOString(), + data: { + commandType: commandData.type || 'help', + userId: commandData.userId || commandData.nickname, + displayText: displayText, + result: typeof commandData.result === 'object' + ? commandData.result + : { value: commandData.result }, + raw: commandData, + }, + } + setMessages((prev) => [...prev, commandMessage]) + }, + + onGameStart: (data) => { + console.log('[useChatWebSocket] Game started - FULL DATA:', JSON.stringify(data, null, 2)) + // 실제 게임 데이터 추출 (data.data에 중첩되어 있을 수 있음) + const gameData = data.data || data + // currentWord는 객체: { wordId, word } - 영어 단어만 포함 + // 출제자만 currentWord를 받음 + const word = gameData.currentWord + setGameState({ + status: 'PLAYING', + currentRound: gameData.currentRound || 1, + totalRounds: gameData.totalRounds || 5, + currentDrawerId: gameData.currentDrawerId, + currentWord: word?.word || word?.korean || word, // word 필드 우선 사용 + drawerOrder: gameData.drawerOrder, + roundStartTime: gameData.roundStartTime || Date.now(), + roundTimeLimit: gameData.roundTimeLimit || 60, + scores: gameData.scores || {}, + }) + }, + + onGameEnd: (data) => { + console.log('[useChatWebSocket] Game ended - FULL DATA:', JSON.stringify(data, null, 2)) + const gameData = data.data || data + setGameState((prev) => ({ + ...prev, + status: 'FINISHED', + finalScores: gameData.scores, + })) + }, + + // 끝말잇기 게임 시작 + onWordchainStart: (data) => { + console.log('[useChatWebSocket] Wordchain started - FULL DATA:', JSON.stringify(data, null, 2)) + const gameData = data.data || data + // 서버 필드명 매핑: starterWord->currentWord, currentPlayerId->currentTurnUserId, timeLimit->turnTimeLimit + const wordchainState = { + status: 'PLAYING', + gameType: 'WORDCHAIN', + currentTurnUserId: gameData.currentPlayerId || gameData.currentTurnUserId, + currentWord: gameData.starterWord || gameData.currentWord, + nextLetter: gameData.nextLetter, + turnTimeLimit: gameData.timeLimit || gameData.turnTimeLimit || 15, + turnStartTime: gameData.turnStartTime || Date.now(), + scores: gameData.scores || {}, + players: gameData.players || gameData.activePlayers || [], + } + setGameState(wordchainState) + // PlayPage에서 사용할 수 있도록 sessionStorage에 저장 + sessionStorage.setItem(`wordchainState_${roomId}`, JSON.stringify(wordchainState)) + }, + + // 끝말잇기 정답 + onWordchainCorrect: (data) => { + console.log('[useChatWebSocket] Wordchain correct:', data) + const correctData = data.data || data + // 서버 필드명 매핑: word->currentWord, nextPlayerId->currentTurnUserId, nextTimeLimit->turnTimeLimit + setGameState((prev) => { + const newState = { + ...prev, + currentWord: correctData.word || correctData.currentWord, + nextLetter: correctData.nextLetter || prev?.nextLetter, + currentTurnUserId: correctData.nextPlayerId || correctData.nextTurnUserId, + turnTimeLimit: correctData.nextTimeLimit || prev?.turnTimeLimit, + turnStartTime: correctData.turnStartTime || Date.now(), + scores: correctData.scores || prev?.scores, + usedWords: prev?.usedWords ? [...prev.usedWords, correctData.word] : [correctData.word], + } + // sessionStorage 업데이트 + sessionStorage.setItem(`wordchainState_${roomId}`, JSON.stringify(newState)) + return newState + }) + }, + + // 끝말잇기 오답 + onWordchainWrong: (data) => { + console.log('[useChatWebSocket] Wordchain wrong:', data) + }, + + // 끝말잇기 타임아웃 + onWordchainTimeout: (data) => { + console.log('[useChatWebSocket] Wordchain timeout:', data) + const timeoutData = data.data || data + // 서버 필드명 매핑: nextPlayerId->currentTurnUserId, nextTimeLimit->turnTimeLimit + setGameState((prev) => { + const newState = { + ...prev, + currentTurnUserId: timeoutData.nextPlayerId || timeoutData.nextTurnUserId, + nextLetter: timeoutData.nextLetter || prev?.nextLetter, + turnTimeLimit: timeoutData.nextTimeLimit || prev?.turnTimeLimit, + turnStartTime: timeoutData.turnStartTime || Date.now(), + players: timeoutData.activePlayers || prev?.players, + } + // sessionStorage 업데이트 + sessionStorage.setItem(`wordchainState_${roomId}`, JSON.stringify(newState)) + return newState + }) + }, + + // 끝말잇기 게임 종료 + onWordchainEnd: (data) => { + console.log('[useChatWebSocket] Wordchain ended:', data) + const endData = data.data || data + setGameState((prev) => { + const newState = { + ...prev, + status: 'FINISHED', + winner: endData.winnerId ? { + id: endData.winnerId, + nickname: endData.winnerNickname, + } : null, + ranking: endData.ranking, + finalScores: endData.scores || prev?.scores, + usedWords: endData.usedWords || prev?.usedWords, + wordDefinitions: endData.wordDefinitions || {}, + } + // sessionStorage 업데이트 + sessionStorage.setItem(`wordchainState_${roomId}`, JSON.stringify(newState)) + return newState + }) + }, + + onRoundStart: (data) => { + console.log('[useChatWebSocket] Round started - FULL DATA:', JSON.stringify(data, null, 2)) + // 실제 라운드 데이터 추출 (data.data에 중첩되어 있을 수 있음) + const roundData = data.data || data + // currentWord는 객체: { wordId, word } - 영어 단어만 포함 + const word = roundData.currentWord + setGameState((prev) => ({ + ...prev, + currentRound: roundData.currentRound, + currentDrawerId: roundData.currentDrawerId, + currentWord: word?.word || word?.korean || word, // word 필드 우선 사용 + roundStartTime: roundData.roundStartTime || Date.now(), + hintUsed: false, + correctGuessers: [], + })) + }, + + onRoundEnd: (data) => { + console.log('[useChatWebSocket] Round ended - FULL DATA:', JSON.stringify(data, null, 2)) + + // 실제 라운드 데이터 추출 (data.data에 중첩되어 있을 수 있음) + const roundData = data.data || data + + console.log('[useChatWebSocket] Extracted roundData:', roundData) + + // ROUND_END 처리 - 다음 라운드 정보 추출 + const nextRoundNum = roundData.nextRound ?? ((roundData.currentRound || 0) + 1) + const nextWord = roundData.nextWord + const answer = roundData.answer // 이번 라운드 정답 + + // ranking 배열을 scores 객체로 변환 + let scores = roundData.scores + if (!scores && roundData.ranking) { + scores = {} + roundData.ranking.forEach(item => { + scores[item.userId] = item.score + }) + } + + setGameState((prev) => { + console.log('[useChatWebSocket] setGameState callback, prev:', prev) + + // 다음 출제자 계산 - 서버에서 제공하지 않으면 drawerOrder 기반으로 계산 + let nextDrawer = roundData.nextDrawer ?? roundData.nextDrawerId + + // 서버에서 다음 출제자를 제공하지 않은 경우, drawerOrder 기반으로 계산 + if (!nextDrawer && prev?.drawerOrder && prev.drawerOrder.length > 0) { + const drawerOrder = prev.drawerOrder + const currentDrawerIndex = drawerOrder.indexOf(prev.currentDrawerId) + const nextDrawerIndex = (currentDrawerIndex + 1) % drawerOrder.length + nextDrawer = drawerOrder[nextDrawerIndex] + console.log('[useChatWebSocket] Calculated nextDrawer from drawerOrder:', { + drawerOrder, + currentDrawerIndex, + nextDrawerIndex, + nextDrawer, + }) + } + + // 그래도 없으면 currentDrawerId 사용 (fallback, 이 경우 문제가 있음) + if (!nextDrawer) { + nextDrawer = roundData.currentDrawerId || prev?.currentDrawerId + console.warn('[useChatWebSocket] Could not determine nextDrawer, using fallback:', nextDrawer) + } + + console.log('[useChatWebSocket] Calling setGameState with:', { + nextRoundNum, + nextDrawer, + nextWord, + answer, + scores, + prevDrawerId: prev?.currentDrawerId, + }) + + if (!prev) { + console.warn('[useChatWebSocket] prev is null, creating new state') + // prev가 null이면 새 상태 생성 + return { + status: 'PLAYING', + currentRound: nextRoundNum, + totalRounds: 5, + currentDrawerId: nextDrawer, + currentWord: nextWord?.word || nextWord?.korean || nextWord, + lastAnswer: answer, + roundStartTime: Date.now(), + scores: scores || {}, + hintUsed: false, + correctGuessers: [], + } + } + + // 항상 다음 라운드로 전환 (ROUND_END는 라운드가 끝났다는 의미) + return { + ...prev, + scores: scores || prev.scores, + currentRound: nextRoundNum, + currentDrawerId: nextDrawer, + currentWord: nextWord?.word || nextWord?.korean || nextWord, // word 필드 우선 사용 + lastAnswer: answer, // 이전 라운드 정답 저장 + roundStartTime: Date.now(), + hintUsed: false, + correctGuessers: [], + } + }) + }, + + onCorrectAnswer: (data) => { + console.log('[useChatWebSocket] Correct answer - FULL DATA:', JSON.stringify(data, null, 2)) + const answerData = data.data || data + setGameState((prev) => { + // prev가 null이면 기본 상태 유지 + if (!prev) return prev + + return { + ...prev, + correctGuessers: [...(prev.correctGuessers || []), answerData.userId], + // scores는 기존 값 유지 (answerData.scores가 있을 때만 업데이트) + scores: answerData.scores ? answerData.scores : prev.scores, + } + }) + // 정답 비눗방울 표시 데이터 설정 + setCorrectAnswerBubble({ + userId: answerData.userId, + content: answerData.content || '정답!', + timestamp: Date.now(), + }) + }, + + onScoreUpdate: (data) => { + const scoreData = data.data || data + setGameState((prev) => ({ + ...prev, + scores: scoreData.scores, + })) + }, + + onHint: (data) => { + const hintData = data.data || data + setGameState((prev) => ({ + ...prev, + hint: hintData.hint, + hintUsed: true, + })) + }, + + onDrawing: (data) => { + // 캔버스에 그리기 데이터 적용 + console.log('[useChatWebSocket] Drawing received:', data) + // 실제 stroke 데이터는 content 필드에 있음 (전송 시 content에 넣었으므로) + // 서버 응답 구조: { messageType: 'DRAWING', data: { content: '...' }, ... } + // 또는: { messageType: 'DRAWING', content: '...', ... } + const strokeData = data.content || data.data?.content || data.data || data + console.log('[useChatWebSocket] Extracted stroke data:', strokeData) + setReceivedDrawing(strokeData) + }, + + onDrawingClear: () => { + // 캔버스 초기화 + console.log('[useChatWebSocket] Drawing clear received') + setShouldClearCanvas(true) + }, + + onUserJoin: (data) => { + const systemMessage = { + id: `system-${Date.now()}`, + content: `${data.userId}님이 입장했습니다.`, + messageType: 'SYSTEM', + createdAt: new Date().toISOString(), + isSystem: true, + } + setMessages((prev) => [...prev, systemMessage]) + }, + + onUserLeave: (data) => { + const systemMessage = { + id: `system-${Date.now()}`, + content: `${data.userId}님이 퇴장했습니다.`, + messageType: 'SYSTEM', + createdAt: new Date().toISOString(), + isSystem: true, + } + setMessages((prev) => [...prev, systemMessage]) + }, + + onError: (data) => { + setError(data.message || '연결 오류가 발생했습니다') + }, + + onClose: () => { + setIsConnected(false) + isConnectedRef.current = false + }, + + onReconnectNeeded: () => { + console.log('[useChatWebSocket] Reconnect needed, getting new token...') + // 기존 토큰 삭제 후 새 토큰으로 재연결 + sessionStorage.removeItem(`roomToken_${roomId}`) + connect(true) + }, + }) + + // 2. roomToken으로 WebSocket 연결 + await chatWebSocketService.connect(roomToken, roomId, userId) + setIsConnected(true) + isConnectedRef.current = true + setError(null) // 연결 성공 시 에러 초기화 + } catch (err) { + console.error('[useChatWebSocket] Connection error:', err) + console.error('[useChatWebSocket] Error details:', { + message: err.message, + stack: err.stack, + roomId, + userId, + forceNewToken + }) + + // 기존 토큰으로 연결 실패 시, 새 토큰으로 재시도 (1회만) + if (!forceNewToken && sessionStorage.getItem(`roomToken_${roomId}`)) { + console.log('[useChatWebSocket] Connection failed with cached token, retrying with new token...') + sessionStorage.removeItem(`roomToken_${roomId}`) + // 재귀 호출로 새 토큰 발급 후 재시도 + return connect(true) + } + + setError('연결에 실패했습니다: ' + (err.message || '알 수 없는 오류')) + setIsConnected(false) + isConnectedRef.current = false + } + }, [roomId, userId]) + + /** + * 연결 종료 + */ + const disconnect = useCallback(() => { + chatWebSocketService.disconnect() + setIsConnected(false) + isConnectedRef.current = false + }, []) + + /** + * 메시지 전송 + * - optimistic update 제거: 서버 브로드캐스트만 표시하여 중복 방지 + */ + const sendMessage = useCallback((content, messageType = 'TEXT') => { + if (!isConnectedRef.current) { + console.warn('[useChatWebSocket] Not connected, cannot send message:', content) + // 연결 안 됐을 때는 에러 설정하지 않고 조용히 실패 + return false + } + + return chatWebSocketService.sendMessage(content, messageType) + }, []) + + /** + * 게임 시작 + */ + const startGame = useCallback(() => { + return chatWebSocketService.startGame() + }, []) + + /** + * 게임 종료 + */ + const stopGame = useCallback(() => { + return chatWebSocketService.stopGame() + }, []) + + /** + * 그리기 데이터 전송 + */ + const sendDrawing = useCallback((drawingData) => { + console.log('[useChatWebSocket] sendDrawing, isConnected:', isConnectedRef.current) + if (!isConnectedRef.current) { + console.error('[useChatWebSocket] Not connected, cannot send drawing') + return + } + chatWebSocketService.sendDrawing(drawingData) + }, []) + + /** + * 캔버스 초기화 전송 + */ + const clearDrawing = useCallback(() => { + console.log('[useChatWebSocket] clearDrawing, isConnected:', isConnectedRef.current) + if (!isConnectedRef.current) { + console.error('[useChatWebSocket] Not connected, cannot clear drawing') + return + } + chatWebSocketService.clearDrawing() + }, []) + + /** + * 에러 초기화 + */ + const clearError = useCallback(() => { + setError(null) + }, []) + + /** + * 메시지 초기화 + */ + const clearMessages = useCallback(() => { + setMessages([]) + }, []) + + /** + * 컴포넌트 언마운트 시 정리 + */ + useEffect(() => { + return () => { + if (isConnectedRef.current) { + chatWebSocketService.disconnect() + } + } + }, []) + + return { + // 상태 + isConnected, + messages, + gameState, + error, + receivedDrawing, + shouldClearCanvas, + correctAnswerBubble, + + // 액션 + connect, + disconnect, + sendMessage, + startGame, + stopGame, + sendDrawing, + clearDrawing, + clearError, + clearMessages, + + // 유틸 + setMessages, + setReceivedDrawing, + setShouldClearCanvas, + setCorrectAnswerBubble, + } +} + +export default useChatWebSocket diff --git a/src/domains/freetalk/pages/ChatRoomPage.jsx b/src/domains/freetalk/pages/ChatRoomPage.jsx index 77f9d75..781a891 100644 --- a/src/domains/freetalk/pages/ChatRoomPage.jsx +++ b/src/domains/freetalk/pages/ChatRoomPage.jsx @@ -1,5 +1,5 @@ -import {useCallback, useEffect, useRef, useState} from 'react' -import {useNavigate, useParams} from 'react-router-dom' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' import { Alert, AppBar, @@ -15,32 +15,59 @@ import { } from '@mui/material' import { ArrowBack as ArrowBackIcon, + Circle as CircleIcon, ExitToApp as ExitToAppIcon, Refresh as RefreshIcon, Send as SendIcon, VolumeUp as VolumeUpIcon, } from '@mui/icons-material' -import {chatRoomService, messageService, TEMP_USER_ID, voiceService} from '../../chat/services/chatService' +import { chatRoomService, messageService, voiceService } from '../../chat/services/chatService' +import { useAuth } from '../../../contexts/AuthContext' +import { useChatWebSocket } from '../hooks/useChatWebSocket' +import { useThemeMode } from '../../../contexts/ThemeContext' +import CommandAutocomplete from '../components/CommandAutocomplete' +import PollCard from '../components/PollCard' +import SystemCommandMessage from '../components/SystemCommandMessage' +import { parseCommand, MessageType } from '../types/chatCommandTypes' const levelColors = { - beginner: {bg: '#e8f5e9', color: '#2e7d32', label: '초급'}, - intermediate: {bg: '#fff3e0', color: '#ef6c00', label: '중급'}, - advanced: {bg: '#fce4ec', color: '#c2185b', label: '고급'}, + beginner: { bg: '#e8f5e9', color: '#2e7d32', label: '초급' }, + intermediate: { bg: '#fff3e0', color: '#ef6c00', label: '중급' }, + advanced: { bg: '#fce4ec', color: '#c2185b', label: '고급' }, } const ChatRoomPage = () => { - const {roomId} = useParams() + const { roomId } = useParams() const navigate = useNavigate() + const { user } = useAuth() + const { mode } = useThemeMode() + const isDark = mode === 'dark' + const currentUserId = user?.userId || user?.username || user?.sub + + // 디버깅: 사용자 정보 확인 + console.log('[ChatRoomPage] User info:', { user, currentUserId, roomId }) + const messagesEndRef = useRef(null) const messagesContainerRef = useRef(null) const [room, setRoom] = useState(null) - const [messages, setMessages] = useState([]) const [newMessage, setNewMessage] = useState('') const [loading, setLoading] = useState(true) - const [sendingMessage, setSendingMessage] = useState(false) const [error, setError] = useState(null) const [playingTTS, setPlayingTTS] = useState(null) + const [showCommandAutocomplete, setShowCommandAutocomplete] = useState(false) + + // WebSocket 훅 사용 (채팅방에서는 게임 관련 기능 제외) + const { + isConnected, + messages, + error: wsError, + connect: wsConnect, + disconnect: wsDisconnect, + sendMessage: wsSendMessage, + clearError: wsClearError, + setMessages, + } = useChatWebSocket(roomId, currentUserId) // 채팅방 정보 조회 const fetchRoomDetail = useCallback(async () => { @@ -60,25 +87,29 @@ const ChatRoomPage = () => { } }, [roomId]) - // 메시지 목록 조회 + // 기존 메시지 목록 조회 (초기 로드용) const fetchMessages = useCallback(async () => { try { - const response = await messageService.getList(roomId, {limit: 50}) - const transformedMessages = (response.messages || []).map((msg) => ({ - id: msg.messageId, + const response = await messageService.getList(roomId, { limit: 50 }) + const transformedMessages = (response.messages || []).map((msg, index) => ({ + id: msg.messageId || `msg-${index}-${Date.now()}`, content: msg.content, userId: msg.userId, + nickname: msg.nickname, messageType: msg.messageType, createdAt: new Date(msg.createdAt), - isOwn: msg.userId === TEMP_USER_ID, + isOwn: msg.userId === currentUserId, })) - // 오래된 메시지가 위에 오도록 정렬 - setMessages(transformedMessages.reverse()) + // 중복 제거 후 정렬 + const uniqueMessages = transformedMessages.filter( + (msg, index, self) => index === self.findIndex(m => m.id === msg.id) + ) + setMessages(uniqueMessages.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))) } catch (err) { console.error('Failed to fetch messages:', err) setError('메시지를 불러오는데 실패했습니다') } - }, [roomId]) + }, [roomId, currentUserId, setMessages]) // 초기 로드 useEffect(() => { @@ -88,47 +119,82 @@ const ChatRoomPage = () => { setLoading(false) } loadData() - }, [fetchRoomDetail, fetchMessages]) + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + // WebSocket 연결 (별도 effect) + useEffect(() => { + console.log('[ChatRoomPage] WebSocket effect triggered:', { roomId, currentUserId, isConnected }) + if (currentUserId && roomId) { + console.log('[ChatRoomPage] Connecting WebSocket...', { roomId, currentUserId }) + wsConnect() + } else { + console.log('[ChatRoomPage] Missing required values:', { roomId, currentUserId }) + } + + return () => { + console.log('[ChatRoomPage] Disconnecting WebSocket...') + wsDisconnect() + } + }, [roomId, currentUserId]) // eslint-disable-line react-hooks/exhaustive-deps // 스크롤 맨 아래로 const scrollToBottom = () => { - messagesEndRef.current?.scrollIntoView({behavior: 'smooth'}) + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) } useEffect(() => { scrollToBottom() }, [messages]) - // 메시지 전송 + // 에러 통합 + useEffect(() => { + if (wsError) { + setError(wsError) + } + }, [wsError]) + + // 메시지 전송 (WebSocket 사용) const handleSendMessage = async () => { - if (!newMessage.trim() || sendingMessage) return + if (!newMessage.trim()) return - setSendingMessage(true) const messageContent = newMessage.trim() setNewMessage('') + setShowCommandAutocomplete(false) - // Optimistic update - const tempMessage = { - id: `temp-${Date.now()}`, - content: messageContent, - userId: TEMP_USER_ID, - messageType: 'TEXT', - createdAt: new Date(), - isOwn: true, + // 명령어 파싱 + const { isCommand, command, args } = parseCommand(messageContent) + + // /leave 명령어는 클라이언트에서 직접 처리 + if (isCommand && command === '/leave') { + handleLeaveRoom() + return } - setMessages((prev) => [...prev, tempMessage]) - try { - await messageService.send(roomId, messageContent) - // 전송 성공 시 메시지 새로고침 - await fetchMessages() - } catch (err) { - console.error('Failed to send message:', err) - // 실패 시 임시 메시지 제거 - setMessages((prev) => prev.filter((m) => m.id !== tempMessage.id)) - setError('메시지 전송에 실패했습니다') - } finally { - setSendingMessage(false) + // /clear 명령어는 서버로 전송 (서버에서 처리) + // 나머지 명령어들도 서버로 전송하여 처리 + + // WebSocket으로 전송 + if (isConnected) { + wsSendMessage(messageContent, 'TEXT') + } else { + // WebSocket 연결이 안 된 경우 REST API fallback + const tempMessage = { + id: `temp-${Date.now()}`, + content: messageContent, + userId: currentUserId, + messageType: 'TEXT', + createdAt: new Date(), + isOwn: true, + } + setMessages((prev) => [...prev, tempMessage]) + + try { + await messageService.send(roomId, messageContent) + } catch (err) { + console.error('Failed to send message:', err) + setMessages((prev) => prev.filter((m) => m.id !== tempMessage.id)) + setError('메시지 전송에 실패했습니다') + } } } @@ -163,6 +229,7 @@ const ChatRoomPage = () => { // 채팅방 퇴장 const handleLeaveRoom = async () => { try { + wsDisconnect() await chatRoomService.leave(roomId) navigate('/freetalk/people') } catch (err) { @@ -171,40 +238,90 @@ const ChatRoomPage = () => { } } + // 투표하기 + const handleVote = (pollId, optionId) => { + if (isConnected) { + wsSendMessage(`/vote ${pollId} ${optionId}`, 'TEXT') + } + } + + // 투표 종료 + const handleEndPoll = (pollId) => { + if (isConnected) { + wsSendMessage(`/endpoll ${pollId}`, 'TEXT') + } + } + + // 명령어 자동완성 선택 + const handleCommandSelect = (command) => { + if (command) { + setNewMessage(command + ' ') + } + setShowCommandAutocomplete(false) + } + + // 입력값 변경 처리 + const handleInputChange = (e) => { + const value = e.target.value + setNewMessage(value) + + // "/" 입력 시 자동완성 표시 + if (value.startsWith('/') && value.length > 0) { + setShowCommandAutocomplete(true) + } else { + setShowCommandAutocomplete(false) + } + } + // 새로고침 const handleRefresh = () => { fetchMessages() } + // 에러 닫기 + const handleCloseError = () => { + setError(null) + wsClearError() + } + // 시간 포맷 const formatTime = (date) => { return new Intl.DateTimeFormat('ko-KR', { hour: '2-digit', minute: '2-digit', - }).format(date) + }).format(new Date(date)) } if (loading) { return ( - - + + ) } return ( - + {/* 헤더 */} - navigate('/freetalk/people')} sx={{mr: 1}}> - + navigate('/freetalk/people')} sx={{ mr: 1 }}> + - - - {room?.name || '채팅방'} - - + + + + {room?.name || '채팅방'} + + {/* 연결 상태 표시 */} + + + {room?.level && ( { {room?.currentMembers}/{room?.maxMembers}명 + + {isConnected ? '실시간' : '오프라인'} + - + - + {/* 에러 메시지 */} {error && ( - setError(null)} sx={{m: 1}}> + {error} )} @@ -251,91 +371,132 @@ const ChatRoomPage = () => { }} > {messages.length === 0 ? ( - + 아직 메시지가 없습니다. 첫 메시지를 보내보세요! ) : ( - messages.map((message) => ( - - {/* 아바타 (상대방만) */} - {!message.isOwn && ( - - {message.userId?.charAt(0)?.toUpperCase() || 'U'} - - )} + messages.map((message) => { + // 투표 메시지 + if (message.messageType === MessageType.POLL_CREATE) { + return ( + + + + ) + } + // 시스템 명령어 메시지 + if (message.messageType === MessageType.SYSTEM_COMMAND) { + return ( + + ) + } + + // 일반 메시지 + return ( - {/* 사용자 이름 (상대방만) */} - {!message.isOwn && ( - - {message.userId} - - )} - - {/* 메시지 버블 */} - - {message.isOwn && ( + {/* 시스템 메시지 */} + {message.isSystem ? ( + - {formatTime(message.createdAt)} - - )} - - - {message.content} - - - {!message.isOwn && ( - - handlePlayTTS(message.id)} - disabled={playingTTS === message.id} - sx={{p: 0.5}} - > - {playingTTS === message.id ? ( - - ) : ( - + + ) : ( + <> + {/* 아바타 (상대방만) */} + {!message.isOwn && ( + + {(message.nickname || message.userId)?.charAt(0)?.toUpperCase() || 'U'} + + )} + + + {/* 사용자 이름 (상대방만) */} + {!message.isOwn && ( + + {message.nickname || message.userId} + + )} + + {/* 메시지 버블 */} + + {message.isOwn && ( + + {formatTime(message.createdAt)} + + )} + + + + {message.content} + + + + {!message.isOwn && ( + + handlePlayTTS(message.id)} + disabled={playingTTS === message.id} + sx={{ p: 0.5 }} + > + {playingTTS === message.id ? ( + + ) : ( + + )} + + + {formatTime(message.createdAt)} + + )} - - - {formatTime(message.createdAt)} - + - )} - + + )} - - )) + ) + }) )} -
+
{/* 입력 영역 */} @@ -347,13 +508,21 @@ const ChatRoomPage = () => { alignItems: 'center', gap: 1, borderRadius: 0, + position: 'relative', }} > + {/* 명령어 자동완성 */} + + setNewMessage(e.target.value)} + onChange={handleInputChange} onKeyPress={handleKeyPress} size="small" multiline @@ -367,15 +536,15 @@ const ChatRoomPage = () => { - {sendingMessage ? : } + diff --git a/src/domains/freetalk/pages/FreetalkPeoplePage.jsx b/src/domains/freetalk/pages/FreetalkPeoplePage.jsx index 3d8ee47..66ca4f7 100644 --- a/src/domains/freetalk/pages/FreetalkPeoplePage.jsx +++ b/src/domains/freetalk/pages/FreetalkPeoplePage.jsx @@ -97,9 +97,18 @@ const FreetalkPeoplePage = () => { const transformedRooms = (responseData.rooms || []).map(transformRoomData) if (isLoadMore) { - setRooms((prev) => [...prev, ...transformedRooms]) + // 중복 제거 + setRooms((prev) => { + const existingIds = new Set(prev.map(r => r.id)) + const newRooms = transformedRooms.filter(r => !existingIds.has(r.id)) + return [...prev, ...newRooms] + }) } else { - setRooms(transformedRooms) + // 중복 제거 + const uniqueRooms = transformedRooms.filter( + (room, index, self) => index === self.findIndex(r => r.id === room.id) + ) + setRooms(uniqueRooms) } setCursor(responseData.nextCursor || null) @@ -131,7 +140,11 @@ const FreetalkPeoplePage = () => { const response = await chatRoomService.getList(params) const responseData = response.data || response const transformedRooms = (responseData.rooms || []).map(transformRoomData) - setRooms(transformedRooms) + // 중복 제거 + const uniqueRooms = transformedRooms.filter( + (room, index, self) => index === self.findIndex(r => r.id === room.id) + ) + setRooms(uniqueRooms) setCursor(responseData.nextCursor || null) setHasMore(!!responseData.nextCursor) } catch (err) { @@ -177,7 +190,11 @@ const FreetalkPeoplePage = () => { const response = await chatRoomService.getList(params) const responseData = response.data || response const transformedRooms = (responseData.rooms || []).map(transformRoomData) - setRooms(transformedRooms) + // 중복 제거 + const uniqueRooms = transformedRooms.filter( + (room, index, self) => index === self.findIndex(r => r.id === room.id) + ) + setRooms(uniqueRooms) setCursor(responseData.nextCursor || null) setHasMore(!!responseData.nextCursor) } catch (err) { @@ -308,11 +325,8 @@ const FreetalkPeoplePage = () => { {filteredRooms.map((room, index) => ( diff --git a/src/domains/freetalk/services/chatWebSocketService.js b/src/domains/freetalk/services/chatWebSocketService.js new file mode 100644 index 0000000..81f78e4 --- /dev/null +++ b/src/domains/freetalk/services/chatWebSocketService.js @@ -0,0 +1,484 @@ +/** + * Chat WebSocket Service + * 실시간 채팅 및 게임 명령을 위한 WebSocket 서비스 + * + * 연결 흐름: + * 1. REST API로 채팅방 입장: POST /chat/rooms/{roomId}/join → roomToken 발급 + * 2. roomToken으로 WebSocket 연결: wss://...?roomToken={roomToken} + */ + +const WS_URL = import.meta.env.VITE_WS_URL + +/** + * Chat WebSocket 연결 클래스 + */ +class ChatWebSocketConnection { + constructor() { + this.ws = null + this.callbacks = {} + this.isConnected = false + this.reconnectAttempts = 0 + this.maxReconnectAttempts = 5 + this.reconnectDelay = 1000 + this.roomId = null + this.userId = null + this.roomToken = null + } + + /** + * WebSocket 연결 + * @param {string} roomToken - POST /chat/rooms/{roomId}/join에서 발급받은 roomToken + * @param {string} roomId - 채팅방 ID + * @param {string} userId - 사용자 ID + */ + connect(roomToken, roomId, userId) { + return new Promise((resolve, reject) => { + try { + this.roomId = roomId + this.userId = userId + this.roomToken = roomToken + + // roomToken 파라미터로 연결 (token이 아님!) + const url = roomToken ? `${WS_URL}?roomToken=${roomToken}` : WS_URL + console.log('[ChatWebSocket] Connecting to:', url) + console.log('[ChatWebSocket] roomToken:', roomToken ? roomToken.substring(0, 30) + '...' : 'MISSING') + console.log('[ChatWebSocket] roomId:', roomId) + console.log('[ChatWebSocket] userId:', userId) + this.ws = new WebSocket(url) + + this.ws.onopen = () => { + this.isConnected = true + this.reconnectAttempts = 0 + console.log('[ChatWebSocket] Connected successfully!') + console.log('[ChatWebSocket] WebSocket readyState:', this.ws.readyState) + + // 연결 후 방 입장은 roomToken으로 이미 처리됨 + // joinRoom action은 서버에서 지원하지 않음 (Forbidden) + + resolve() + } + + this.ws.onclose = (event) => { + this.isConnected = false + console.log('[ChatWebSocket] Disconnected:', event.code, event.reason) + + this.callbacks.onClose?.(event) + + // 비정상 종료 시 재연결 시도 (roomToken 만료 시 재발급 필요할 수 있음) + if (event.code !== 1000 && this.reconnectAttempts < this.maxReconnectAttempts) { + this.attemptReconnect(roomToken, roomId, userId) + } + } + + this.ws.onerror = (error) => { + console.error('[ChatWebSocket] Error:', error) + console.error('[ChatWebSocket] WebSocket readyState:', this.ws?.readyState) + this.callbacks.onError?.({type: 'error', message: 'WebSocket connection error'}) + reject(error) + } + + this.ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data) + this.handleMessage(data) + } catch (e) { + console.error('[ChatWebSocket] Parse error:', e) + } + } + } catch (error) { + reject(error) + } + }) + } + + /** + * 재연결 시도 - 기존 토큰 무효화하고 onReconnectNeeded 콜백 호출 + */ + attemptReconnect(roomToken, roomId, userId) { + this.reconnectAttempts++ + console.log(`[ChatWebSocket] Reconnect needed (${this.reconnectAttempts}/${this.maxReconnectAttempts})`) + + // 최대 재연결 시도 횟수 초과 시 중단 + if (this.reconnectAttempts > this.maxReconnectAttempts) { + console.error('[ChatWebSocket] Max reconnect attempts reached') + this.callbacks.onError?.({ message: '연결이 끊어졌습니다. 페이지를 새로고침해주세요.' }) + return + } + + // 재연결이 필요함을 알림 (useChatWebSocket에서 새 토큰 발급 후 재연결) + if (this.callbacks.onReconnectNeeded) { + setTimeout(() => { + this.callbacks.onReconnectNeeded() + }, this.reconnectDelay * this.reconnectAttempts) + } + } + + /** + * 메시지 핸들링 + */ + handleMessage(data) { + const {type, messageType} = data + console.log('[ChatWebSocket] Received message:', {type, messageType, data}) + + // 메시지 타입에 따라 콜백 호출 (대소문자 모두 처리) + switch (type || messageType) { + case 'message': + case 'TEXT': + this.callbacks.onMessage?.(data) + break + case 'game_start': + case 'GAME_START': + this.callbacks.onGameStart?.(data) + break + case 'game_end': + case 'GAME_END': + this.callbacks.onGameEnd?.(data) + break + case 'round_start': + case 'ROUND_START': + console.log('[ChatWebSocket] Round start received:', data) + this.callbacks.onRoundStart?.(data) + break + case 'round_end': + case 'ROUND_END': + console.log('[ChatWebSocket] Round end received:', data) + this.callbacks.onRoundEnd?.(data) + break + case 'drawing': + case 'DRAWING': + console.log('[ChatWebSocket] Drawing data received:', data) + this.callbacks.onDrawing?.(data) + break + case 'drawing_clear': + case 'DRAWING_CLEAR': + console.log('[ChatWebSocket] Drawing clear received') + this.callbacks.onDrawingClear?.() + break + case 'correct_answer': + case 'CORRECT_ANSWER': + console.log('[ChatWebSocket] Correct answer received:', data) + this.callbacks.onCorrectAnswer?.(data) + break + case 'score_update': + case 'SCORE_UPDATE': + this.callbacks.onScoreUpdate?.(data) + break + case 'hint': + case 'HINT': + this.callbacks.onHint?.(data) + break + case 'user_join': + case 'USER_JOIN': + this.callbacks.onUserJoin?.(data) + break + case 'user_leave': + case 'USER_LEAVE': + this.callbacks.onUserLeave?.(data) + break + case 'system_command': + case 'SYSTEM_COMMAND': + // 시스템 명령어 응답 (예: /dice, /coin, /random, /members, /help 등) + console.log('[ChatWebSocket] System command received:', data) + console.log('[ChatWebSocket] onSystemCommand callback exists:', !!this.callbacks.onSystemCommand) + this.callbacks.onSystemCommand?.(data) + break + case 'poll_create': + case 'POLL_CREATE': + console.log('[ChatWebSocket] Poll create received:', data) + this.callbacks.onPollCreate?.(data) + break + case 'poll_vote': + case 'POLL_VOTE': + console.log('[ChatWebSocket] Poll vote received:', data) + this.callbacks.onPollVote?.(data) + break + case 'poll_end': + case 'POLL_END': + console.log('[ChatWebSocket] Poll end received:', data) + this.callbacks.onPollEnd?.(data) + break + case 'clear_chat': + case 'CLEAR_CHAT': + console.log('[ChatWebSocket] Clear chat received:', data) + this.callbacks.onClearChat?.(data) + break + case 'leave_room': + case 'LEAVE_ROOM': + console.log('[ChatWebSocket] Leave room received:', data) + this.callbacks.onLeaveRoom?.(data) + break + case 'error': + case 'ERROR': + this.callbacks.onError?.(data) + break + case 'GUESS': + // 추측 메시지 - 일반 메시지로 처리 + this.callbacks.onMessage?.(data) + break + case 'wordchain_start': + case 'WORDCHAIN_START': + console.log('[ChatWebSocket] Wordchain start received:', data) + this.callbacks.onWordchainStart?.(data) + break + case 'wordchain_correct': + case 'WORDCHAIN_CORRECT': + console.log('[ChatWebSocket] Wordchain correct received:', data) + this.callbacks.onWordchainCorrect?.(data) + break + case 'wordchain_wrong': + case 'WORDCHAIN_WRONG': + console.log('[ChatWebSocket] Wordchain wrong received:', data) + this.callbacks.onWordchainWrong?.(data) + break + case 'wordchain_timeout': + case 'WORDCHAIN_TIMEOUT': + console.log('[ChatWebSocket] Wordchain timeout received:', data) + this.callbacks.onWordchainTimeout?.(data) + break + case 'wordchain_end': + case 'WORDCHAIN_END': + console.log('[ChatWebSocket] Wordchain end received:', data) + this.callbacks.onWordchainEnd?.(data) + break + default: + console.log('[ChatWebSocket] Unknown message type:', type || messageType, data) + this.callbacks.onMessage?.(data) + } + } + + /** + * 방 입장 + */ + joinRoom(roomId) { + this.send({ + action: 'joinRoom', + roomId, + userId: this.userId, + }) + } + + /** + * 방 퇴장 + */ + leaveRoom(roomId) { + this.send({ + action: 'leaveRoom', + roomId, + userId: this.userId, + }) + } + + /** + * 메시지 전송 + */ + sendMessage(content, messageType = 'TEXT') { + if (!this.isConnected || !this.ws) { + console.error('[ChatWebSocket] Not connected') + return false + } + + console.log('[ChatWebSocket] Sending message with userId:', this.userId, 'content:', content) + this.send({ + action: 'sendMessage', + roomId: this.roomId, + userId: this.userId, + content, + messageType, + }) + return true + } + + /** + * 게임 시작 명령 + */ + startGame() { + return this.sendMessage('/start', 'TEXT') + } + + /** + * 게임 종료 명령 + */ + stopGame() { + return this.sendMessage('/stop', 'TEXT') + } + + /** + * 그리기 데이터 전송 - sendMessage action 사용 + */ + sendDrawing(drawingData) { + console.log('[ChatWebSocket] sendDrawing called:', drawingData) + // 서버가 sendMessage action만 지원하므로 messageType으로 구분 + // content는 반드시 문자열이어야 함 + const content = typeof drawingData === 'string' ? drawingData : JSON.stringify(drawingData) + console.log('[ChatWebSocket] sendDrawing content:', content) + this.send({ + action: 'sendMessage', + roomId: this.roomId, + userId: this.userId, + content: content, + messageType: 'DRAWING', + }) + } + + /** + * 캔버스 초기화 전송 + */ + clearDrawing() { + console.log('[ChatWebSocket] clearDrawing called') + this.send({ + action: 'sendMessage', + roomId: this.roomId, + userId: this.userId, + content: '', + messageType: 'DRAWING_CLEAR', + }) + } + + /** + * WebSocket 메시지 전송 + */ + send(data) { + if (!this.isConnected || !this.ws) { + console.error('[ChatWebSocket] Not connected') + return + } + + this.ws.send(JSON.stringify(data)) + } + + /** + * 콜백 설정 + */ + setCallbacks(callbacks) { + this.callbacks = callbacks + } + + /** + * 연결 종료 + */ + disconnect() { + if (this.roomId) { + this.leaveRoom(this.roomId) + } + + if (this.ws) { + this.ws.close(1000, 'Client disconnect') + this.ws = null + } + this.isConnected = false + this.roomId = null + this.userId = null + } + + /** + * 연결 상태 확인 + */ + getConnectionState() { + if (!this.ws) return 'disconnected' + + switch (this.ws.readyState) { + case WebSocket.CONNECTING: + return 'connecting' + case WebSocket.OPEN: + return 'connected' + case WebSocket.CLOSING: + return 'closing' + case WebSocket.CLOSED: + return 'disconnected' + default: + return 'unknown' + } + } +} + +// 싱글톤 인스턴스 +let wsInstance = null + +/** + * Chat WebSocket 서비스 + */ +export const chatWebSocketService = { + /** + * 인스턴스 가져오기 (싱글톤) + */ + getInstance() { + if (!wsInstance) { + wsInstance = new ChatWebSocketConnection() + } + return wsInstance + }, + + /** + * 연결 + */ + async connect(token, roomId, userId) { + const instance = this.getInstance() + return instance.connect(token, roomId, userId) + }, + + /** + * 메시지 전송 + */ + sendMessage(content, messageType = 'TEXT') { + const instance = this.getInstance() + return instance.sendMessage(content, messageType) + }, + + /** + * 게임 시작 + */ + startGame() { + const instance = this.getInstance() + return instance.startGame() + }, + + /** + * 게임 종료 + */ + stopGame() { + const instance = this.getInstance() + return instance.stopGame() + }, + + /** + * 그리기 데이터 전송 + */ + sendDrawing(drawingData) { + const instance = this.getInstance() + return instance.sendDrawing(drawingData) + }, + + /** + * 캔버스 초기화 전송 + */ + clearDrawing() { + const instance = this.getInstance() + return instance.clearDrawing() + }, + + /** + * 콜백 설정 + */ + setCallbacks(callbacks) { + const instance = this.getInstance() + instance.setCallbacks(callbacks) + }, + + /** + * 연결 종료 + */ + disconnect() { + const instance = this.getInstance() + instance.disconnect() + }, + + /** + * 연결 상태 + */ + getConnectionState() { + const instance = this.getInstance() + return instance.getConnectionState() + }, +} + +export default chatWebSocketService diff --git a/src/domains/freetalk/types/chatCommandTypes.js b/src/domains/freetalk/types/chatCommandTypes.js new file mode 100644 index 0000000..73d87c6 --- /dev/null +++ b/src/domains/freetalk/types/chatCommandTypes.js @@ -0,0 +1,219 @@ +/** + * 채팅 메시지 타입 상수 + * @enum {string} + */ +export const MessageType = { + TEXT: 'TEXT', + IMAGE: 'IMAGE', + VOICE: 'VOICE', + SYSTEM_COMMAND: 'SYSTEM_COMMAND', + POLL_CREATE: 'POLL_CREATE', + POLL_VOTE: 'POLL_VOTE', + POLL_END: 'POLL_END', + CLEAR_CHAT: 'CLEAR_CHAT', + LEAVE_ROOM: 'LEAVE_ROOM', +} + +/** + * 사용 가능한 채팅 명령어 목록 + */ +export const COMMANDS = [ + { + command: '/help', + description: '사용 가능한 명령어 목록 보기', + usage: '/help', + }, + { + command: '/members', + description: '현재 참여 중인 멤버 목록 보기', + usage: '/members', + }, + { + command: '/poll', + description: '투표 생성하기', + usage: '/poll [질문] [옵션1] [옵션2] ...', + }, + { + command: '/vote', + description: '투표하기', + usage: '/vote [투표ID] [옵션번호]', + }, + { + command: '/endpoll', + description: '투표 종료하기', + usage: '/endpoll [투표ID]', + }, + { + command: '/clear', + description: '내 메시지 모두 삭제', + usage: '/clear', + }, + { + command: '/leave', + description: '채팅방 나가기', + usage: '/leave', + }, + { + command: '/dice', + description: '주사위 굴리기 (1-6)', + usage: '/dice', + }, + { + command: '/coin', + description: '동전 던지기 (앞면/뒷면)', + usage: '/coin', + }, + { + command: '/random', + description: '무작위 숫자 생성', + usage: '/random [최소값] [최대값]', + }, +] + +/** + * @typedef {Object} PollOption + * @property {number} optionId - 옵션 ID + * @property {string} text - 옵션 텍스트 + * @property {number} voteCount - 투표 수 + * @property {string[]} voters - 투표한 사용자 ID 목록 + */ + +/** + * @typedef {Object} PollCreateData + * @property {string} pollId - 투표 ID + * @property {string} question - 투표 질문 + * @property {PollOption[]} options - 투표 옵션 목록 + * @property {string} creatorId - 생성자 ID + * @property {string} createdAt - ISO-8601 생성 시간 + * @property {boolean} isActive - 활성 상태 + */ + +/** + * @typedef {Object} PollVoteData + * @property {string} pollId - 투표 ID + * @property {number} optionId - 선택한 옵션 ID + * @property {string} userId - 투표한 사용자 ID + * @property {PollOption[]} updatedOptions - 업데이트된 옵션 목록 + */ + +/** + * @typedef {Object} PollEndData + * @property {string} pollId - 투표 ID + * @property {PollOption[]} finalResults - 최종 결과 + * @property {string} endedBy - 종료한 사용자 ID + * @property {string} endedAt - ISO-8601 종료 시간 + */ + +/** + * @typedef {Object} SystemCommandData + * @property {string} commandType - 명령어 타입 (dice, coin, random, members, help) + * @property {string} userId - 명령어 실행 사용자 ID + * @property {Object} result - 명령어 실행 결과 + * @property {string} displayText - 표시할 텍스트 + */ + +/** + * @typedef {Object} ClearChatData + * @property {string} userId - 삭제 요청 사용자 ID + * @property {string[]} messageIds - 삭제된 메시지 ID 목록 + */ + +/** + * @typedef {Object} LeaveRoomData + * @property {string} userId - 퇴장한 사용자 ID + * @property {string} roomId - 방 ID + * @property {string} leftAt - ISO-8601 퇴장 시간 + */ + +/** + * @typedef {Object} MembersListData + * @property {string[]} memberIds - 멤버 ID 목록 + * @property {number} totalCount - 총 멤버 수 + */ + +/** + * 명령어 파싱 유틸리티 + * @param {string} message - 입력된 메시지 + * @returns {{isCommand: boolean, command: string, args: string[]}} + */ +export function parseCommand(message) { + const trimmed = message.trim() + + if (!trimmed.startsWith('/')) { + return { isCommand: false, command: '', args: [] } + } + + const parts = trimmed.split(/\s+/) + const command = parts[0].toLowerCase() + const args = parts.slice(1) + + return { + isCommand: true, + command, + args, + } +} + +/** + * 명령어가 유효한지 확인 + * @param {string} command - 명령어 (예: '/help') + * @returns {boolean} + */ +export function isValidCommand(command) { + return COMMANDS.some(cmd => cmd.command === command.toLowerCase()) +} + +/** + * 명령어 검색 (자동완성용) + * @param {string} input - 사용자 입력 + * @returns {Array} 일치하는 명령어 목록 + */ +export function searchCommands(input) { + const lowerInput = input.toLowerCase() + return COMMANDS.filter(cmd => cmd.command.startsWith(lowerInput)) +} + +/** + * 투표 결과 계산 + * @param {PollOption[]} options - 투표 옵션 목록 + * @returns {{totalVotes: number, percentages: number[]}} + */ +export function calculatePollResults(options) { + const totalVotes = options.reduce((sum, opt) => sum + opt.voteCount, 0) + const percentages = options.map(opt => + totalVotes > 0 ? Math.round((opt.voteCount / totalVotes) * 100) : 0 + ) + + return { totalVotes, percentages } +} + +/** + * 시스템 명령어 아이콘 및 색상 설정 + */ +export const SystemCommandConfig = { + dice: { + icon: '🎲', + color: '#8b5cf6', + bgColor: '#f5f3ff', + }, + coin: { + icon: '🪙', + color: '#f59e0b', + bgColor: '#fffbeb', + }, + random: { + icon: '🔢', + color: '#06b6d4', + bgColor: '#ecfeff', + }, + members: { + icon: '👥', + color: '#10b981', + bgColor: '#ecfdf5', + }, + help: { + icon: '❓', + color: '#6366f1', + bgColor: '#eef2ff', + }, +} diff --git a/src/domains/games/components/BubbleOverlay.jsx b/src/domains/games/components/BubbleOverlay.jsx new file mode 100644 index 0000000..b41ed91 --- /dev/null +++ b/src/domains/games/components/BubbleOverlay.jsx @@ -0,0 +1,135 @@ +import {useEffect, useState} from 'react' +import {Box, Typography} from '@mui/material' +import {GAME_COLORS} from '../theme/gameTheme' + +const BubbleOverlay = ({ bubbles, containerWidth, containerHeight }) => { + const [activeBubbles, setActiveBubbles] = useState([]) + + useEffect(() => { + if (!bubbles || bubbles.length === 0) return + + // 새 버블 추가 - 항상 고유한 내부 ID 생성 + const now = Date.now() + const newBubbles = bubbles.map((bubble, index) => ({ + ...bubble, + internalId: `${now}-${index}-${Math.random().toString(36).substr(2, 9)}`, + x: Math.random() * (containerWidth - 120) + 60, + startTime: now, + })) + + setActiveBubbles(prev => [...prev, ...newBubbles]) + + // 3초 후 자동 제거 + const timeoutIds = newBubbles.map(bubble => + setTimeout(() => { + setActiveBubbles(prev => prev.filter(b => b.internalId !== bubble.internalId)) + }, 3000) + ) + + return () => { + timeoutIds.forEach(id => clearTimeout(id)) + } + }, [bubbles, containerWidth]) + + // 애니메이션 프레임 + const [, setFrame] = useState(0) + useEffect(() => { + if (activeBubbles.length === 0) return + + const interval = setInterval(() => { + setFrame(f => f + 1) + }, 50) + + return () => clearInterval(interval) + }, [activeBubbles.length]) + + return ( + + {activeBubbles.map(bubble => { + const elapsed = (Date.now() - bubble.startTime) / 1000 + const progress = Math.min(elapsed / 3, 1) + const y = containerHeight * (1 - progress * 0.8) + const opacity = 1 - progress * 0.7 + const scale = 1 + progress * 0.2 + + return ( + + + + {bubble.isCorrect && '🎉 '} + {bubble.nickname || bubble.userId} + + + {bubble.content} + + {bubble.isCorrect && bubble.score && ( + + +{bubble.score}점 + + )} + + + ) + })} + + ) +} + +export default BubbleOverlay diff --git a/src/domains/games/components/CreateGameRoomModal.jsx b/src/domains/games/components/CreateGameRoomModal.jsx new file mode 100644 index 0000000..fc03238 --- /dev/null +++ b/src/domains/games/components/CreateGameRoomModal.jsx @@ -0,0 +1,320 @@ +import {useState} from 'react' +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Slider, + TextField, + ToggleButton, + ToggleButtonGroup, + Typography, +} from '@mui/material' +import {Close as CloseIcon} from '@mui/icons-material' +import {GAME_COLORS} from '../theme/gameTheme' + +const levelOptions = [ + { value: 'BEGINNER', label: '초급', color: '#10B981' }, + { value: 'INTERMEDIATE', label: '중급', color: '#F59E0B' }, + { value: 'ADVANCED', label: '고급', color: '#EF4444' }, +] + +const CreateGameRoomModal = ({ open, onClose, onCreate, loading, gameType = 'CATCHMIND' }) => { + const isWordchain = gameType === 'WORDCHAIN' + + const [formData, setFormData] = useState({ + name: '', + description: '', + level: 'BEGINNER', + maxParticipants: isWordchain ? 6 : 4, + maxRounds: 5, + roundTimeLimit: 60, + turnTimeLimit: 15, // 끝말잇기용 + }) + + const handleChange = (field, value) => { + setFormData(prev => ({ ...prev, [field]: value })) + } + + const handleSubmit = () => { + if (!formData.name.trim()) return + onCreate?.(formData) + } + + const handleClose = () => { + setFormData({ + name: '', + description: '', + level: 'BEGINNER', + maxParticipants: isWordchain ? 6 : 4, + maxRounds: 5, + roundTimeLimit: 60, + turnTimeLimit: 15, + }) + onClose?.() + } + + return ( + + {/* 헤더 */} + + + 새 게임방 만들기 + + + + + + + + {/* 방 이름 */} + + + 방 이름 * + + handleChange('name', e.target.value)} + size="small" + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: '10px', + }, + }} + /> + + + {/* 설명 */} + + + 설명 (선택) + + handleChange('description', e.target.value)} + size="small" + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: '10px', + }, + }} + /> + + + {/* 난이도 */} + + + 난이도 + + value && handleChange('level', value)} + fullWidth + sx={{ + '& .MuiToggleButton-root': { + borderRadius: '10px', + textTransform: 'none', + fontWeight: 600, + py: 1, + }, + }} + > + {levelOptions.map((opt) => ( + + {opt.label} + + ))} + + + + {/* 최대 인원 */} + + + + 최대 인원 + + + {formData.maxParticipants}명 + + + handleChange('maxParticipants', value)} + min={2} + max={8} + step={1} + marks + sx={{ + color: GAME_COLORS.primary, + '& .MuiSlider-markLabel': { + fontSize: '0.7rem', + }, + }} + /> + + + {/* 캐치마인드: 라운드 수 */} + {!isWordchain && ( + + + + 라운드 수 + + + {formData.maxRounds}라운드 + + + handleChange('maxRounds', value)} + min={3} + max={10} + step={1} + marks + sx={{ + color: GAME_COLORS.primary, + }} + /> + + )} + + {/* 캐치마인드: 라운드 시간 */} + {!isWordchain && ( + + + + 라운드 시간 + + + {formData.roundTimeLimit}초 + + + handleChange('roundTimeLimit', value)} + min={30} + max={120} + step={15} + marks={[ + { value: 30, label: '30초' }, + { value: 60, label: '60초' }, + { value: 90, label: '90초' }, + { value: 120, label: '120초' }, + ]} + sx={{ + color: GAME_COLORS.primary, + '& .MuiSlider-markLabel': { + fontSize: '0.65rem', + }, + }} + /> + + )} + + {/* 끝말잇기: 턴 시간 */} + {isWordchain && ( + + + + 턴 시간 + + + {formData.turnTimeLimit}초 + + + handleChange('turnTimeLimit', value)} + min={10} + max={30} + step={5} + marks={[ + { value: 10, label: '10초' }, + { value: 15, label: '15초' }, + { value: 20, label: '20초' }, + { value: 30, label: '30초' }, + ]} + sx={{ + color: GAME_COLORS.primary, + '& .MuiSlider-markLabel': { + fontSize: '0.65rem', + }, + }} + /> + + )} + + + + + + + + ) +} + +export default CreateGameRoomModal diff --git a/src/domains/games/components/GameCanvas.jsx b/src/domains/games/components/GameCanvas.jsx new file mode 100644 index 0000000..5c854e3 --- /dev/null +++ b/src/domains/games/components/GameCanvas.jsx @@ -0,0 +1,312 @@ +import {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react' +import {Box, IconButton, Slider, Tooltip, Typography} from '@mui/material' +import {Delete as ClearIcon} from '@mui/icons-material' +import {GAME_COLORS, GAME_LAYOUT} from '../theme/gameTheme' + +const COLORS = [ + '#000000', '#FFFFFF', '#EF4444', '#F97316', '#EAB308', + '#22C55E', '#3B82F6', '#8B5CF6', '#EC4899', '#78716C', +] + +const GameCanvas = forwardRef(({ + isDrawer, + onDraw, + onClear, + disabled, + timerDanger, +}, ref) => { + const canvasRef = useRef(null) + const [isDrawing, setIsDrawing] = useState(false) + const [brushColor, setBrushColor] = useState('#000000') + const [brushSize, setBrushSize] = useState(4) + const [currentStroke, setCurrentStroke] = useState([]) + + // 외부에서 캔버스 조작 가능하도록 ref 노출 + useImperativeHandle(ref, () => ({ + clear: () => clearCanvas(false), + drawStroke: (strokeData) => drawRemoteStroke(strokeData), + getImageData: () => { + const canvas = canvasRef.current + return canvas?.toDataURL() + }, + })) + + // 캔버스 초기화 + const clearCanvas = useCallback((sendToOthers = true) => { + const canvas = canvasRef.current + if (!canvas) return + + const ctx = canvas.getContext('2d') + ctx.fillStyle = '#FFFFFF' + ctx.fillRect(0, 0, canvas.width, canvas.height) + + if (sendToOthers && onClear) { + onClear() + } + }, [onClear]) + + // 원격 스트로크 그리기 + const drawRemoteStroke = useCallback((strokeData) => { + const canvas = canvasRef.current + if (!canvas) return + + const ctx = canvas.getContext('2d') + let data = strokeData + + if (typeof data === 'string') { + try { + data = JSON.parse(data) + } catch (e) { + console.error('Failed to parse stroke data:', e) + return + } + } + + if (!Array.isArray(data)) return + + data.forEach((point) => { + if (point.type === 'start') { + ctx.beginPath() + ctx.moveTo(point.x, point.y) + ctx.lineWidth = point.width || 4 + ctx.lineCap = 'round' + ctx.lineJoin = 'round' + ctx.strokeStyle = point.color || '#000000' + } else if (point.type === 'move') { + ctx.lineTo(point.x, point.y) + ctx.stroke() + ctx.beginPath() + ctx.moveTo(point.x, point.y) + } else if (point.type === 'end') { + ctx.beginPath() + } + }) + }, []) + + // 캔버스 초기화 (마운트 시) + useEffect(() => { + clearCanvas(false) + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + // 그리기 시작 + const startDrawing = (e) => { + if (!isDrawer || disabled) return + + const canvas = canvasRef.current + if (!canvas) return + + const rect = canvas.getBoundingClientRect() + const scaleX = canvas.width / rect.width + const scaleY = canvas.height / rect.height + const x = (e.clientX - rect.left) * scaleX + const y = (e.clientY - rect.top) * scaleY + + setIsDrawing(true) + setCurrentStroke([{ x, y, type: 'start', color: brushColor, width: brushSize }]) + + const ctx = canvas.getContext('2d') + ctx.beginPath() + ctx.moveTo(x, y) + ctx.lineWidth = brushSize + ctx.lineCap = 'round' + ctx.lineJoin = 'round' + ctx.strokeStyle = brushColor + } + + // 그리기 + const draw = (e) => { + if (!isDrawing || !isDrawer || disabled) return + + const canvas = canvasRef.current + if (!canvas) return + + const ctx = canvas.getContext('2d') + const rect = canvas.getBoundingClientRect() + const scaleX = canvas.width / rect.width + const scaleY = canvas.height / rect.height + const x = (e.clientX - rect.left) * scaleX + const y = (e.clientY - rect.top) * scaleY + + ctx.lineTo(x, y) + ctx.stroke() + ctx.beginPath() + ctx.moveTo(x, y) + + setCurrentStroke(prev => [...prev, { x, y, type: 'move' }]) + } + + // 그리기 종료 + const stopDrawing = () => { + if (!isDrawing) return + + setIsDrawing(false) + + const canvas = canvasRef.current + if (canvas) { + const ctx = canvas.getContext('2d') + ctx.beginPath() + } + + // 스트로크 전송 + if (currentStroke.length > 0 && onDraw) { + const strokeData = [...currentStroke, { type: 'end' }] + onDraw(JSON.stringify(strokeData)) + } + + setCurrentStroke([]) + } + + // 터치 이벤트 핸들러 + const handleTouchStart = (e) => { + e.preventDefault() + const touch = e.touches[0] + startDrawing({ clientX: touch.clientX, clientY: touch.clientY }) + } + + const handleTouchMove = (e) => { + e.preventDefault() + const touch = e.touches[0] + draw({ clientX: touch.clientX, clientY: touch.clientY }) + } + + const handleTouchEnd = (e) => { + e.preventDefault() + stopDrawing() + } + + return ( + + {/* 캔버스 */} + + + + + + + {/* 그리기 도구 (출제자만) - 컴팩트 */} + {isDrawer && ( + + {/* 색상 팔레트 */} + + {COLORS.map((color) => ( + + setBrushColor(color)} + sx={{ + width: 22, + height: 22, + bgcolor: color, + borderRadius: '50%', + cursor: 'pointer', + border: brushColor === color + ? '2px solid #1F2937' + : '1px solid #E5E7EB', + transition: 'transform 0.15s ease', + '&:hover': { + transform: 'scale(1.1)', + }, + }} + /> + + ))} + + + {/* 브러시 크기 */} + + + 굵기 + + setBrushSize(value)} + min={2} + max={20} + size="small" + sx={{ + color: GAME_COLORS.primary, + width: 60, + '& .MuiSlider-thumb': { width: 12, height: 12 }, + }} + /> + + + {/* 지우기 버튼 */} + + clearCanvas(true)} + size="small" + sx={{ + bgcolor: '#FEE2E2', + color: '#EF4444', + '&:hover': { bgcolor: '#FECACA' }, + p: 0.5, + }} + > + + + + + )} + + ) +}) + +GameCanvas.displayName = 'GameCanvas' + +export default GameCanvas diff --git a/src/domains/games/components/GameChat.jsx b/src/domains/games/components/GameChat.jsx new file mode 100644 index 0000000..4ff6414 --- /dev/null +++ b/src/domains/games/components/GameChat.jsx @@ -0,0 +1,324 @@ +import {useEffect, useRef, useState} from 'react' +import {Avatar, Box, Chip, IconButton, Paper, TextField, Typography} from '@mui/material' +import {Send as SendIcon} from '@mui/icons-material' +import {GAME_COLORS} from '../theme/gameTheme' +import {useThemeMode} from '../../../contexts/ThemeContext' + +const GameChat = ({ + messages, + onSendMessage, + currentUserId, + isDrawer, + currentDrawerId, +}) => { + const {mode} = useThemeMode() + const isDark = mode === 'dark' + const [newMessage, setNewMessage] = useState('') + const messagesEndRef = useRef(null) + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + } + + useEffect(() => { + scrollToBottom() + }, [messages]) + + const handleSend = () => { + if (!newMessage.trim() || isDrawer) return + onSendMessage?.(newMessage.trim()) + setNewMessage('') + } + + const handleKeyPress = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSend() + } + } + + const formatTime = (date) => { + const d = new Date(date) + return d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' }) + } + + // 메시지 타입에 따른 스타일 + const getMessageStyle = (message) => { + if (message.isCorrect) { + return { + bgcolor: GAME_COLORS.answer.correctBg, + borderLeft: `4px solid ${GAME_COLORS.answer.correct}`, + } + } + if (message.isWrong) { + return { + opacity: 0.6, + } + } + if (message.isSystem) { + return { + bgcolor: 'transparent', + } + } + return {} + } + + return ( + + {/* 헤더 - 컴팩트 */} + + + 채팅 + + + 정답을 입력하세요 + + + + {/* 메시지 영역 - 컴팩트 */} + + {messages.map((message) => { + const isOwn = message.userId === currentUserId + const isSystem = message.isSystem + const isFromDrawer = message.userId === currentDrawerId + + // 시스템 메시지 + if (isSystem) { + return ( + + + + ) + } + + // 정답 메시지 + if (message.isCorrect) { + return ( + + + + {message.nickname || message.userId} + + + + + {message.content} + + + ) + } + + // 일반 메시지 + return ( + + {!isOwn && ( + + {(message.nickname || message.userId)?.charAt(0) || 'U'} + + )} + + + {!isOwn && ( + + + {message.nickname || message.userId} + + {isFromDrawer && ( + + )} + + )} + + + {isOwn && ( + + {formatTime(message.createdAt)} + + )} + + + + {message.isWrong && '❌ '} + {message.content} + + + + {!isOwn && ( + + {formatTime(message.createdAt)} + + )} + + + + ) + })} +
+ + + {/* 입력 영역 - 컴팩트 */} + + setNewMessage(e.target.value)} + onKeyPress={handleKeyPress} + disabled={isDrawer} + size="small" + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: '8px', + fontSize: '0.8rem', + }, + '& .MuiOutlinedInput-input': { + py: 0.5, + px: 1, + }, + }} + /> + + + + + + ) +} + +export default GameChat diff --git a/src/domains/games/components/GameRoomCard.jsx b/src/domains/games/components/GameRoomCard.jsx new file mode 100644 index 0000000..60899a4 --- /dev/null +++ b/src/domains/games/components/GameRoomCard.jsx @@ -0,0 +1,223 @@ +import {Avatar, AvatarGroup, Box, Button, Card, CardContent, Chip, Typography} from '@mui/material' +import { + Person as PersonIcon, + PlayArrow as PlayIcon, + Visibility as SpectateIcon, +} from '@mui/icons-material' +import {GAME_COLORS} from '../theme/gameTheme' + +const levelConfig = { + // 대문자 + BEGINNER: { label: '초급', color: '#10B981', bg: '#D1FAE5' }, + INTERMEDIATE: { label: '중급', color: '#F59E0B', bg: '#FEF3C7' }, + ADVANCED: { label: '고급', color: '#EF4444', bg: '#FEE2E2' }, + // 소문자 (백엔드 응답) + beginner: { label: '초급', color: '#10B981', bg: '#D1FAE5' }, + intermediate: { label: '중급', color: '#F59E0B', bg: '#FEF3C7' }, + advanced: { label: '고급', color: '#EF4444', bg: '#FEE2E2' }, +} + +const statusConfig = { + WAITING: { label: '대기중', color: GAME_COLORS.status.waiting, bg: GAME_COLORS.status.waitingBg }, + PLAYING: { label: '게임중', color: GAME_COLORS.status.playing, bg: GAME_COLORS.status.playingBg }, + FINISHED: { label: '종료', color: GAME_COLORS.status.finished, bg: GAME_COLORS.status.finishedBg }, +} + +const GameRoomCard = ({ room, onJoin, onSpectate }) => { + const level = levelConfig[room.level] || levelConfig.beginner + const status = statusConfig[room.status] || statusConfig.WAITING + + // 백엔드/프론트엔드 필드명 호환 + const maxParticipants = room.maxParticipants || room.maxMembers || 6 + const currentParticipants = room.currentParticipants || room.currentMembers || 1 + + const isFull = currentParticipants >= maxParticipants + const isPlaying = room.status === 'PLAYING' + const canJoin = !isFull && !isPlaying + + return ( + + + {/* 상단: 방 이름 + 상태 */} + + + + {room.name} + + + {room.description || '\u00A0'} + + + + + + {/* 중간: 방장 + 인원 + 레벨 */} + + {/* 방장 - 고정 너비 */} + + + {room.hostNickname?.charAt(0) || 'H'} + + + {room.hostNickname} + + + + {/* 인원 */} + + + + {currentParticipants}/{maxParticipants} + + + + {/* 레벨 */} + + + + {/* 참가자 아바타 - 항상 고정 높이 */} + + {room.participants && room.participants.length > 0 ? ( + + {room.participants.map((p) => ( + + {p.nickname?.charAt(0) || 'U'} + + ))} + + ) : ( + + 참가자 대기중... + + )} + + + {/* 하단: 버튼 */} + + {canJoin ? ( + + ) : isPlaying ? ( + + ) : ( + + )} + + + + ) +} + +export default GameRoomCard diff --git a/src/domains/games/components/ParticipantList.jsx b/src/domains/games/components/ParticipantList.jsx new file mode 100644 index 0000000..92c6dc3 --- /dev/null +++ b/src/domains/games/components/ParticipantList.jsx @@ -0,0 +1,134 @@ +import {Avatar, Box, Chip, List, ListItem, ListItemAvatar, ListItemText, Typography} from '@mui/material' +import {GAME_COLORS} from '../theme/gameTheme' + +const ParticipantList = ({ participants, maxParticipants, currentUserId }) => { + const emptySlots = maxParticipants - participants.length + + return ( + + + + 참가자 + + + + + + {participants.map((participant) => ( + + + + {participant.nickname?.charAt(0) || 'U'} + + + + + {participant.nickname} + + {participant.isHost && ( + + )} + {participant.userId === currentUserId && ( + + )} + + } + /> + + ))} + + {/* 빈 슬롯 */} + {Array.from({ length: emptySlots }).map((_, index) => ( + + + + ? + + + + 대기중... + + } + /> + + ))} + + + ) +} + +export default ParticipantList diff --git a/src/domains/games/components/WaitingChat.jsx b/src/domains/games/components/WaitingChat.jsx new file mode 100644 index 0000000..1c48981 --- /dev/null +++ b/src/domains/games/components/WaitingChat.jsx @@ -0,0 +1,243 @@ +import {useEffect, useRef, useState} from 'react' +import {Avatar, Box, IconButton, Paper, TextField, Typography} from '@mui/material' +import {Send as SendIcon} from '@mui/icons-material' +import {GAME_COLORS} from '../theme/gameTheme' +import {useThemeMode} from '../../../contexts/ThemeContext' +import SystemCommandMessage from '../../freetalk/components/SystemCommandMessage' +import {MessageType} from '../../freetalk/types/chatCommandTypes' + +const WaitingChat = ({ messages, onSendMessage, currentUserId, disabled }) => { + const {mode} = useThemeMode() + const isDark = mode === 'dark' + const [newMessage, setNewMessage] = useState('') + const messagesEndRef = useRef(null) + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + } + + useEffect(() => { + scrollToBottom() + }, [messages]) + + const handleSend = () => { + if (!newMessage.trim() || disabled) return + onSendMessage?.(newMessage.trim()) + setNewMessage('') + } + + const handleKeyPress = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSend() + } + } + + const formatTime = (date) => { + const d = new Date(date) + return d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' }) + } + + return ( + + {/* 채팅 메시지 영역 */} + + {messages.length === 0 ? ( + + + 대기 중 채팅을 시작해보세요! + + + ) : ( + messages.map((message) => { + const isOwn = message.userId === currentUserId + const isSystem = message.isSystem + + // 시스템 명령어 메시지 (SYSTEM_COMMAND) - /dice, /coin 등 + if (message.messageType === MessageType.SYSTEM_COMMAND || message.messageType === 'SYSTEM_COMMAND') { + return ( + + ) + } + + // 투표 관련 메시지 (POLL_CREATE, POLL_VOTE, POLL_END) + if (message.messageType === 'POLL_CREATE' || message.messageType === 'poll_create' || + message.messageType === 'POLL_VOTE' || message.messageType === 'poll_vote' || + message.messageType === 'POLL_END' || message.messageType === 'poll_end') { + return ( + + + + {message.content} + + + + ) + } + + if (isSystem) { + return ( + + + {message.content} + + + ) + } + + return ( + + {!isOwn && ( + + {message.nickname?.charAt(0) || message.userId?.charAt(0) || 'U'} + + )} + + + {!isOwn && ( + + {message.nickname || message.userId} + + )} + + + {isOwn && ( + + {formatTime(message.createdAt)} + + )} + + + + {message.content} + + + + {!isOwn && ( + + {formatTime(message.createdAt)} + + )} + + + + ) + }) + )} +
+ + + {/* 입력 영역 */} + + setNewMessage(e.target.value)} + onKeyPress={handleKeyPress} + disabled={disabled} + size="small" + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: '10px', + }, + }} + /> + + + + + + ) +} + +export default WaitingChat diff --git a/src/domains/games/components/wordchain/GameEndModal.jsx b/src/domains/games/components/wordchain/GameEndModal.jsx new file mode 100644 index 0000000..5b47a89 --- /dev/null +++ b/src/domains/games/components/wordchain/GameEndModal.jsx @@ -0,0 +1,179 @@ +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Box, + Typography, +} from '@mui/material' +import { + EmojiEvents as TrophyIcon, + ExitToApp as ExitIcon, + Replay as ReplayIcon, +} from '@mui/icons-material' +import { GAME_COLORS, GAME_TYPOGRAPHY } from '../../theme/gameTheme' + +/** + * GameEndModal - 게임 종료 모달 + * @param {Object} winner - { id, nickname } or { userId, nickname } + * @param {Array} finalPlayers - 백엔드 ranking 배열 또는 기존 players 배열 + * - 백엔드: [{ playerId, nickname, score, eliminated }] + * - 기존: [{ userId, nickname, isAlive, wordsSubmitted }] + */ +const GameEndModal = ({ open, winner, finalPlayers, ranking, onRestart, onExit, currentUserId }) => { + // 백엔드 ranking 또는 기존 finalPlayers 사용 + const players = ranking || finalPlayers || [] + + // 데이터 정규화 - 백엔드 형식과 기존 형식 모두 지원 + const sortedPlayers = [...players] + .map(player => ({ + userId: player.playerId || player.userId, + nickname: player.nickname || player.userId || player.playerId, + score: player.score || 0, + isAlive: player.eliminated !== undefined ? !player.eliminated : player.isAlive, + wordsSubmitted: player.wordsSubmitted || player.score || 0, + })) + .sort((a, b) => { + // 생존자가 우선 + if (a.isAlive && !b.isAlive) return -1 + if (!a.isAlive && b.isAlive) return 1 + // 점수로 정렬 + return (b.score || 0) - (a.score || 0) + }) + + // winner 정규화 + const normalizedWinner = winner ? { + userId: winner.id || winner.userId || winner.playerId, + nickname: winner.nickname || winner.id || winner.userId, + } : null + + return ( + + + + + 게임 종료! + + + + + {normalizedWinner && ( + + + 우승자 + + + {normalizedWinner.nickname || normalizedWinner.userId} + {normalizedWinner.userId === currentUserId && ' (나)'} + + + )} + + + 최종 순위 + + + + {sortedPlayers.map((player, index) => ( + + + + {player.isAlive ? ( + index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : `${index + 1}위` + ) : '❌'} + + + {player.nickname || player.userId} + {player.userId === currentUserId && ' (나)'} + + + + {player.score || 0}점 + + + ))} + + + + + + + + + ) +} + +export default GameEndModal diff --git a/src/domains/games/components/wordchain/PlayerList.jsx b/src/domains/games/components/wordchain/PlayerList.jsx new file mode 100644 index 0000000..2e31812 --- /dev/null +++ b/src/domains/games/components/wordchain/PlayerList.jsx @@ -0,0 +1,89 @@ +import { Box, Typography, Avatar } from '@mui/material' +import { CheckCircle as CheckIcon } from '@mui/icons-material' +import { GAME_COLORS } from '../../theme/gameTheme' +import { useThemeMode } from '../../../../contexts/ThemeContext' + +/** + * PlayerList - 플레이어 목록 (턴 & 생존 상태) + */ +const PlayerList = ({ players, currentTurnUserId, currentUserId }) => { + const { mode } = useThemeMode() + const isDark = mode === 'dark' + + return ( + + + 플레이어 + + + {players.map((player) => { + const isCurrentTurn = player.userId === currentTurnUserId + const isMe = player.userId === currentUserId + const isAlive = player.isAlive !== false + + return ( + + + {player.nickname?.[0] || player.userId?.[0] || '?'} + + + + {player.nickname || player.userId} + {isMe && ' (나)'} + + {isCurrentTurn && ( + + 턴 진행 중 + + )} + + {!isAlive && ( + + 탈락 + + )} + {isAlive && !isCurrentTurn && player.hasAnswered && ( + + )} + + ) + })} + + + ) +} + +export default PlayerList diff --git a/src/domains/games/components/wordchain/UsedWordsList.jsx b/src/domains/games/components/wordchain/UsedWordsList.jsx new file mode 100644 index 0000000..c366ec4 --- /dev/null +++ b/src/domains/games/components/wordchain/UsedWordsList.jsx @@ -0,0 +1,49 @@ +import { Box, Typography, Chip } from '@mui/material' +import { GAME_COLORS } from '../../theme/gameTheme' + +/** + * UsedWordsList - 사용된 단어 목록 + */ +const UsedWordsList = ({ words }) => { + return ( + + + 사용된 단어 ({words.length}) + + + {words.length === 0 ? ( + + 아직 사용된 단어가 없습니다 + + ) : ( + words.map((wordData, index) => ( + + )) + )} + + + ) +} + +export default UsedWordsList diff --git a/src/domains/games/components/wordchain/WordDisplay.jsx b/src/domains/games/components/wordchain/WordDisplay.jsx new file mode 100644 index 0000000..38a310e --- /dev/null +++ b/src/domains/games/components/wordchain/WordDisplay.jsx @@ -0,0 +1,73 @@ +import { Box, Typography } from '@mui/material' +import { GAME_COLORS, GAME_TYPOGRAPHY } from '../../theme/gameTheme' +import { useThemeMode } from '../../../../contexts/ThemeContext' + +/** + * WordDisplay - 현재 단어 & 다음 글자 표시 + */ +const WordDisplay = ({ currentWord, nextLetter, isMyTurn }) => { + const { mode } = useThemeMode() + const isDark = mode === 'dark' + + return ( + + {currentWord ? ( + <> + + 현재 단어 + + + {currentWord} + + + + 다음 시작 글자: + + + {nextLetter} + + + + ) : ( + + 첫 단어를 기다리는 중... + + )} + + ) +} + +export default WordDisplay diff --git a/src/domains/games/components/wordchain/WordInput.jsx b/src/domains/games/components/wordchain/WordInput.jsx new file mode 100644 index 0000000..c6d9587 --- /dev/null +++ b/src/domains/games/components/wordchain/WordInput.jsx @@ -0,0 +1,96 @@ +import { useState } from 'react' +import { Box, TextField, Button, Typography } from '@mui/material' +import { Send as SendIcon } from '@mui/icons-material' +import { GAME_COLORS } from '../../theme/gameTheme' + +/** + * WordInput - 단어 입력 필드 + */ +const WordInput = ({ onSubmit, disabled, nextLetter, isMyTurn }) => { + const [word, setWord] = useState('') + const [error, setError] = useState('') + + const handleSubmit = (e) => { + e.preventDefault() + + const trimmedWord = word.trim().toLowerCase() + + // 유효성 검사 + if (!trimmedWord) { + setError('단어를 입력하세요') + return + } + + if (nextLetter && trimmedWord[0] !== nextLetter.toLowerCase()) { + setError(`단어는 '${nextLetter}'로 시작해야 합니다`) + return + } + + // 알파벳만 허용 + if (!/^[a-z]+$/.test(trimmedWord)) { + setError('영어 알파벳만 입력하세요') + return + } + + setError('') + onSubmit(trimmedWord) + setWord('') + } + + const handleChange = (e) => { + setWord(e.target.value) + setError('') + } + + return ( + + {!isMyTurn && ( + + 다른 플레이어의 턴입니다 + + )} + + + + + + ) +} + +export default WordInput diff --git a/src/domains/games/components/wordchain/WordchainTimer.jsx b/src/domains/games/components/wordchain/WordchainTimer.jsx new file mode 100644 index 0000000..aac71cf --- /dev/null +++ b/src/domains/games/components/wordchain/WordchainTimer.jsx @@ -0,0 +1,59 @@ +import { Box, CircularProgress, Typography } from '@mui/material' +import { GAME_COLORS, GAME_TYPOGRAPHY } from '../../theme/gameTheme' + +/** + * WordchainTimer - 원형 타이머 + */ +const WordchainTimer = ({ timeLeft, timeLimit }) => { + const percentage = (timeLeft / timeLimit) * 100 + const isDanger = timeLeft <= 5 + + return ( + + + + + {timeLeft} + + + + ) +} + +export default WordchainTimer diff --git a/src/domains/games/pages/CatchmindLobbyPage.jsx b/src/domains/games/pages/CatchmindLobbyPage.jsx new file mode 100644 index 0000000..d44b063 --- /dev/null +++ b/src/domains/games/pages/CatchmindLobbyPage.jsx @@ -0,0 +1,303 @@ +import {useCallback, useEffect, useState} from 'react' +import {useNavigate} from 'react-router-dom' +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Container, + FormControlLabel, + Grid, + IconButton, + MenuItem, + Select, + Switch, + Typography, +} from '@mui/material' +import { + Add as AddIcon, + Brush as BrushIcon, + Refresh as RefreshIcon, +} from '@mui/icons-material' +import GameRoomCard from '../components/GameRoomCard' +import CreateGameRoomModal from '../components/CreateGameRoomModal' +import {gameService} from '../services/gameService' +import {GAME_COLORS} from '../theme/gameTheme' +import {useAuth} from '../../../contexts/AuthContext' + +const CatchmindLobbyPage = () => { + const navigate = useNavigate() + const {user} = useAuth() + + const [rooms, setRooms] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [createModalOpen, setCreateModalOpen] = useState(false) + const [creating, setCreating] = useState(false) + + // 필터 + const [filters, setFilters] = useState({ + level: '', + waitingOnly: true, + }) + + // 방 목록 조회 + const fetchRooms = useCallback(async () => { + try { + setLoading(true) + setError(null) + + const params = {} + if (filters.level) params.level = filters.level + if (filters.waitingOnly) params.status = 'WAITING' + + const response = await gameService.getRooms(params) + setRooms(response.data.rooms) + } catch (err) { + console.error('Failed to fetch rooms:', err) + setError('방 목록을 불러오는데 실패했습니다') + } finally { + setLoading(false) + } + }, [filters]) + + useEffect(() => { + fetchRooms() + }, [fetchRooms]) + + // 방 생성 + const handleCreateRoom = async (data) => { + try { + setCreating(true) + const response = await gameService.createRoom(data) + setCreateModalOpen(false) + // 생성된 방의 대기실로 이동 + navigate(`/games/catchmind/${response.data.roomId}/waiting`) + } catch (err) { + console.error('Failed to create room:', err) + setError('방 생성에 실패했습니다') + } finally { + setCreating(false) + } + } + + // 방 참가 + const handleJoinRoom = async (room) => { + try { + const response = await gameService.joinRoom(room.roomId) + // roomToken을 sessionStorage에 저장 (WebSocket 연결 시 사용) + if (response.data?.roomToken) { + sessionStorage.setItem(`roomToken_${room.roomId}`, response.data.roomToken) + } + navigate(`/games/catchmind/${room.roomId}/waiting`) + } catch (err) { + console.error('Failed to join room:', err) + setError(err.message || '방 참가에 실패했습니다') + } + } + + // 관전 + const handleSpectateRoom = (room) => { + navigate(`/games/catchmind/${room.roomId}/play?spectate=true`) + } + + return ( + + {/* 헤더 */} + + + + + + + + 캐치마인드 + + + 그림으로 단어를 맞춰보세요 + + + + + + {/* 필터 + 액션 */} + + {/* 필터 */} + + + + setFilters(prev => ({ ...prev, waitingOnly: e.target.checked }))} + sx={{ + '& .MuiSwitch-switchBase.Mui-checked': { + color: GAME_COLORS.primary, + }, + '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { + bgcolor: GAME_COLORS.primary, + }, + }} + /> + } + label={대기중만} + /> + + + + + + + + + {/* 방 만들기 버튼 */} + + + + {/* 에러 */} + {error && ( + setError(null)} sx={{ mb: 3, borderRadius: '12px' }}> + {error} + + )} + + {/* 방 목록 */} + {loading ? ( + + + + ) : rooms.length === 0 ? ( + + + + + + 대기 중인 방이 없습니다 + + + 새로운 게임방을 만들어보세요! + + + + ) : ( + + {rooms.map((room) => ( + + + + ))} + + )} + + {/* 방 생성 모달 */} + setCreateModalOpen(false)} + onCreate={handleCreateRoom} + loading={creating} + /> + + ) +} + +export default CatchmindLobbyPage diff --git a/src/domains/games/pages/CatchmindPlayPage.jsx b/src/domains/games/pages/CatchmindPlayPage.jsx new file mode 100644 index 0000000..b256539 --- /dev/null +++ b/src/domains/games/pages/CatchmindPlayPage.jsx @@ -0,0 +1,937 @@ +import {useCallback, useEffect, useRef, useState} from 'react' +import {useNavigate, useParams} from 'react-router-dom' +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + LinearProgress, + Typography, +} from '@mui/material' +import { + EmojiEvents as TrophyIcon, + ExitToApp as ExitIcon, + Lightbulb as HintIcon, + Replay as ReplayIcon, + Stop as StopIcon, +} from '@mui/icons-material' +import GameCanvas from '../components/GameCanvas' +import GameChat from '../components/GameChat' +import BubbleOverlay from '../components/BubbleOverlay' +import {gameService} from '../services/gameService' +import {GAME_COLORS, GAME_LAYOUT, GAME_TYPOGRAPHY} from '../theme/gameTheme' +import {useAuth} from '../../../contexts/AuthContext' +import {useThemeMode} from '../../../contexts/ThemeContext' +import {useChatWebSocket} from '../../freetalk/hooks/useChatWebSocket' + +const CatchmindPlayPage = () => { + const { roomId } = useParams() + const navigate = useNavigate() + const { user } = useAuth() + const { mode } = useThemeMode() + const isDark = mode === 'dark' + const currentUserId = user?.userId || user?.username || user?.sub + const canvasRef = useRef(null) + + // WebSocket 연결 + const { + isConnected, + messages: wsMessages, + gameState: wsGameState, + error: wsError, + receivedDrawing, + shouldClearCanvas, + correctAnswerBubble, + connect, + disconnect, + sendMessage: wsSendMessage, + sendDrawing, + clearDrawing, + setReceivedDrawing, + setShouldClearCanvas, + setCorrectAnswerBubble, + } = useChatWebSocket(roomId, currentUserId) + + // 로컬 게임 상태 (WebSocket에서 받은 값으로 업데이트) + const [localGameState, setLocalGameState] = useState({ + status: 'PLAYING', + currentRound: 1, + totalRounds: 5, + currentDrawerId: null, + currentWord: null, + roundStartTime: Date.now(), + roundTimeLimit: 60, + scores: {}, + hintUsed: false, + hint: null, + }) + + // WebSocket gameState가 업데이트되면 로컬 상태에 병합 + useEffect(() => { + if (wsGameState) { + console.log('[CatchmindPlayPage] Received wsGameState:', wsGameState) + + // 라운드가 변경되면 전환 상태 해제 및 타이머 리셋 + setLocalGameState(prev => { + // undefined 값은 무시하고 기존 값 유지 + const newRound = wsGameState.currentRound ?? prev.currentRound + const newDrawer = wsGameState.currentDrawerId ?? prev.currentDrawerId + const newRoundTimeLimit = wsGameState.roundTimeLimit ?? prev.roundTimeLimit ?? 60 + + // 실제로 값이 변경되었는지 확인 (undefined가 아닌 새로운 값이 왔을 때만) + const roundChanged = wsGameState.currentRound !== undefined && prev.currentRound !== wsGameState.currentRound + const drawerChanged = wsGameState.currentDrawerId !== undefined && prev.currentDrawerId !== wsGameState.currentDrawerId + + if (roundChanged || drawerChanged) { + console.log('[CatchmindPlayPage] Round or drawer changed:', { + prevRound: prev.currentRound, + newRound: newRound, + prevDrawer: prev.currentDrawerId, + newDrawer: newDrawer, + }) + // 라운드 전환 완료 - 타이머 리셋 + setIsRoundTransitioning(false) + setTimeLeft(newRoundTimeLimit) + } + + // undefined 값은 기존 값으로 유지 + return { + ...prev, + status: wsGameState.status ?? prev.status, + currentRound: newRound, + totalRounds: wsGameState.totalRounds ?? prev.totalRounds, + currentDrawerId: newDrawer, + currentWord: wsGameState.currentWord ?? prev.currentWord, + roundStartTime: wsGameState.roundStartTime ?? prev.roundStartTime, + roundTimeLimit: newRoundTimeLimit, + scores: wsGameState.scores ?? prev.scores, + hintUsed: wsGameState.hintUsed ?? prev.hintUsed, + hint: wsGameState.hint ?? prev.hint, + correctGuessers: wsGameState.correctGuessers ?? prev.correctGuessers, + drawerOrder: wsGameState.drawerOrder ?? prev.drawerOrder, + lastAnswer: wsGameState.lastAnswer ?? prev.lastAnswer, + finalScores: wsGameState.finalScores ?? prev.finalScores, + } + }) + } + }, [wsGameState]) + + const gameState = localGameState + + const [room, setRoom] = useState(null) + const [loading, setLoading] = useState(true) + const [timeLeft, setTimeLeft] = useState(60) + const [messages, setMessages] = useState([]) + const [bubbles, setBubbles] = useState([]) + const [showEndModal, setShowEndModal] = useState(false) + const [finalRanking, setFinalRanking] = useState([]) + const [isRoundTransitioning, setIsRoundTransitioning] = useState(false) + + const isDrawer = gameState.currentDrawerId === currentUserId + const timerDanger = timeLeft <= 10 + // 라운드 전환 중이거나 타이머가 0이면 그리기 비활성화 + const canDraw = isDrawer && !isRoundTransitioning && timeLeft > 0 + + // WebSocket 메시지를 로컬 messages에 동기화 (중복 제거) + useEffect(() => { + if (wsMessages && wsMessages.length > 0) { + setMessages(prev => { + // 로컬 시스템 메시지 유지 (ID가 system-으로 시작하는 것) + const localSystemMessages = prev.filter(m => + m.isSystem && m.id && m.id.startsWith('system-') + ) + + // wsMessages와 로컬 시스템 메시지 병합 후 ID 기준 중복 제거 + const merged = [...localSystemMessages, ...wsMessages] + const seen = new Set() + return merged.filter(m => { + if (!m.id || seen.has(m.id)) return false + seen.add(m.id) + return true + }).sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)) + }) + } + }, [wsMessages]) + + // 수신된 그리기 데이터 캔버스에 적용 + useEffect(() => { + if (receivedDrawing && canvasRef.current) { + console.log('[CatchmindPlayPage] Applying received drawing:', receivedDrawing) + canvasRef.current.drawStroke(receivedDrawing) + setReceivedDrawing(null) + } + }, [receivedDrawing, setReceivedDrawing]) + + // 캔버스 클리어 명령 처리 + useEffect(() => { + if (shouldClearCanvas && canvasRef.current) { + console.log('[CatchmindPlayPage] Clearing canvas from WebSocket') + canvasRef.current.clear() + setShouldClearCanvas(false) + } + }, [shouldClearCanvas, setShouldClearCanvas]) + + // 정답 비눗방울 처리 + useEffect(() => { + if (correctAnswerBubble) { + const participant = room?.participants?.find(p => p.userId === correctAnswerBubble.userId) + setBubbles([{ + id: `bubble-${correctAnswerBubble.timestamp}`, + userId: correctAnswerBubble.userId, + nickname: participant?.nickname || correctAnswerBubble.userId, + content: correctAnswerBubble.content, + isCorrect: true, + }]) + setCorrectAnswerBubble(null) + } + }, [correctAnswerBubble, room?.participants, setCorrectAnswerBubble]) + + // 게임 종료 처리 (WebSocket에서 GAME_END 이벤트 수신 시) + useEffect(() => { + if (gameState.status === 'FINISHED' && !showEndModal) { + console.log('[CatchmindPlayPage] Game finished, showing end modal') + const ranking = Object.entries(gameState.scores || {}) + .sort(([, a], [, b]) => b - a) + .map(([odUserId, score], index) => ({ + rank: index + 1, + userId: odUserId, + nickname: room?.participants?.find(p => p.userId === odUserId)?.nickname || odUserId, + score, + })) + + setFinalRanking(ranking) + setShowEndModal(true) + } + }, [gameState.status, gameState.scores, room?.participants, showEndModal]) + + // 라운드 변경 시 타이머 리셋 및 캔버스 초기화 + useEffect(() => { + if (gameState.currentRound > 1) { + setTimeLeft(gameState.roundTimeLimit || 60) + canvasRef.current?.clear() + } + }, [gameState.currentRound, gameState.roundTimeLimit]) + + // 초기 로드 및 WebSocket 연결 + useEffect(() => { + const initGame = async () => { + try { + setLoading(true) + + // 방 정보 조회 + const roomResponse = await gameService.getRoom(roomId) + setRoom(roomResponse.data) + + // 게임 상태 조회 (이미 시작된 게임인 경우) + let gameData + try { + const statusResponse = await gameService.getGameStatus(roomId) + gameData = statusResponse.data + console.log('[CatchmindPlayPage] Game status from API:', JSON.stringify(gameData, null, 2)) + } catch { + // 게임 상태가 없으면 시작 + const gameResponse = await gameService.startGame(roomId) + gameData = gameResponse.data + console.log('[CatchmindPlayPage] Game start response:', JSON.stringify(gameData, null, 2)) + } + + // 제시어 추출 (다양한 필드명 시도) + const extractedWord = gameData.currentWord?.word + || gameData.currentWord?.korean + || gameData.currentWord + || gameData.word + || gameData.answer + || null + console.log('[CatchmindPlayPage] Extracted word:', extractedWord, 'from gameData.currentWord:', gameData.currentWord) + + setLocalGameState({ + status: 'PLAYING', + currentRound: gameData.currentRound || 1, + totalRounds: gameData.totalRounds || 5, + currentDrawerId: gameData.currentDrawerId, + currentWord: extractedWord, + roundStartTime: gameData.roundStartTime, + roundTimeLimit: gameData.roundDuration || 60, + scores: {}, + hintUsed: false, + hint: null, + }) + + setTimeLeft(gameData.roundDuration || 60) + + // 시스템 메시지 + setMessages([ + { + id: 'system-start', + content: `🎮 게임 시작! 총 ${gameData.totalRounds || 5}라운드`, + isSystem: true, + createdAt: new Date().toISOString(), + }, + { + id: 'system-round', + content: `라운드 ${gameData.currentRound || 1} 시작! 출제자: ${gameData.currentDrawerId}`, + isSystem: true, + createdAt: new Date().toISOString(), + }, + ]) + + // WebSocket 연결 + console.log('[CatchmindPlayPage] Connecting WebSocket...') + await connect() + console.log('[CatchmindPlayPage] WebSocket connected') + } catch (err) { + console.error('Failed to init game:', err) + } finally { + setLoading(false) + } + } + + initGame() + + // 컴포넌트 언마운트 시 WebSocket 연결 해제 + return () => { + console.log('[CatchmindPlayPage] Disconnecting WebSocket...') + disconnect() + } + }, [roomId, currentUserId, user, connect, disconnect]) + + // 라운드 종료 처리 (WebSocket에서 ROUND_END/GAME_END 이벤트를 받으면 자동 처리됨) + // 이 함수는 클라이언트 측 타이머 만료 시 fallback으로만 사용 + const handleRoundEnd = useCallback((answer, reason) => { + // WebSocket 연결 시에는 서버에서 ROUND_END 이벤트를 받으므로 + // 클라이언트 측 라운드 종료 처리는 최소화 + if (isConnected) { + console.log('[CatchmindPlayPage] Round end triggered locally, waiting for server event') + return + } + + const isLastRound = gameState.currentRound >= gameState.totalRounds + + if (isLastRound) { + // 게임 종료 + const ranking = Object.entries(gameState.scores) + .sort(([, a], [, b]) => b - a) + .map(([userId, score], index) => ({ + rank: index + 1, + userId, + nickname: room?.participants?.find(p => p.userId === userId)?.nickname || userId, + score, + })) + + setFinalRanking(ranking) + setShowEndModal(true) + setLocalGameState(prev => ({ ...prev, status: 'FINISHED' })) + + setMessages(prev => [...prev, { + id: `system-end-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + content: '🎮 게임 종료!', + isSystem: true, + createdAt: new Date().toISOString(), + }]) + } else { + // 다음 라운드 (WebSocket 연결 없을 때 fallback) + const nextRound = gameState.currentRound + 1 + const participants = room?.participants || [] + const nextDrawerIndex = (nextRound - 1) % participants.length + const nextDrawer = participants[nextDrawerIndex]?.userId || currentUserId + const uniqueSuffix = Math.random().toString(36).substr(2, 9) + + setMessages(prev => [...prev, { + id: `system-round-end-${Date.now()}-${uniqueSuffix}`, + content: `${reason || '라운드 종료!'} 정답: ${gameState.currentWord || '???'}`, + isSystem: true, + createdAt: new Date().toISOString(), + }]) + + // 캔버스 초기화 + canvasRef.current?.clear() + + setTimeout(() => { + const uniqueSuffix2 = Math.random().toString(36).substr(2, 9) + setLocalGameState(prev => ({ + ...prev, + currentRound: nextRound, + currentDrawerId: nextDrawer, + currentWord: null, + roundStartTime: Date.now(), + hintUsed: false, + hint: null, + })) + + setTimeLeft(gameState.roundTimeLimit) + setIsRoundTransitioning(false) + + setMessages(prev => [...prev, { + id: `system-next-${Date.now()}-${uniqueSuffix2}`, + content: `라운드 ${nextRound} 시작! 출제자: ${nextDrawer}`, + isSystem: true, + createdAt: new Date().toISOString(), + }]) + }, 2000) + } + }, [gameState, room, currentUserId, isConnected]) + + // 타이머 + useEffect(() => { + if (gameState.status !== 'PLAYING') return + if (isRoundTransitioning) return // 라운드 전환 중에는 타이머 중지 + + const interval = setInterval(() => { + setTimeLeft(prev => { + if (prev <= 1) { + // 시간 초과 - 서버에 ROUND_TIMEOUT 전송 + if (isConnected) { + console.log('[CatchmindPlayPage] Timer expired, sending ROUND_TIMEOUT to server') + setIsRoundTransitioning(true) // 라운드 전환 상태로 변경 + // 서버에 타임아웃 알림 전송 + wsSendMessage('', 'ROUND_TIMEOUT') + return 0 // 타이머를 0으로 유지, 서버 ROUND_END 이벤트 대기 + } + // 연결 안 됐으면 로컬 fallback + handleRoundEnd(null, '시간 초과!') + return gameState.roundTimeLimit + } + return prev - 1 + }) + }, 1000) + + return () => clearInterval(interval) + }, [gameState.status, gameState.roundTimeLimit, isConnected, isRoundTransitioning, handleRoundEnd, wsSendMessage]) + + // 메시지 전송 (WebSocket으로 전송, 서버에서 정답 체크) + const handleSendMessage = useCallback((content) => { + if (isConnected) { + // WebSocket 연결 시: 서버로 메시지 전송 (GUESS 타입) + // 서버에서 정답 체크 후 CORRECT_ANSWER 또는 일반 메시지로 브로드캐스트 + console.log('[CatchmindPlayPage] Sending message via WebSocket:', content) + wsSendMessage(content, 'GUESS') + + // 오답 비눗방울은 로컬에서 표시 (서버 응답 기다리지 않음) + setBubbles([{ + id: `bubble-${Date.now()}`, + userId: currentUserId, + nickname: user?.nickname || '플레이어', + content, + isCorrect: false, + }]) + } else { + // WebSocket 연결 안 됐을 때: 로컬에서 처리 (fallback) + const newMessage = { + id: `msg-${Date.now()}`, + userId: currentUserId, + nickname: user?.nickname || user?.username || '플레이어', + content, + createdAt: new Date().toISOString(), + } + + // 정답 체크 (로컬 fallback) + const isCorrect = gameState.currentWord && + content.toLowerCase().includes(gameState.currentWord.toLowerCase()) + + if (isCorrect) { + const score = Math.max(10, Math.floor(timeLeft * 0.5) + 10) + + newMessage.isCorrect = true + newMessage.score = score + + // 점수 업데이트 + setLocalGameState(prev => ({ + ...prev, + scores: { + ...prev.scores, + [currentUserId]: (prev.scores[currentUserId] || 0) + score, + }, + })) + + // 비눗방울 (정답) + setBubbles([{ + id: `bubble-${Date.now()}`, + userId: currentUserId, + nickname: user?.nickname || '플레이어', + content, + isCorrect: true, + score, + }]) + + // 시스템 메시지 + setMessages(prev => [...prev, { + id: `system-correct-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + content: `🎉 ${user?.nickname || currentUserId}님이 정답을 맞혔습니다! (+${score}점)`, + isSystem: true, + isCorrect: true, + createdAt: new Date().toISOString(), + }]) + + // 다음 라운드로 + setTimeout(() => { + handleRoundEnd(content, '정답!') + }, 1500) + } else { + newMessage.isWrong = true + + // 비눗방울 (오답) + setBubbles([{ + id: `bubble-${Date.now()}`, + userId: currentUserId, + nickname: user?.nickname || '플레이어', + content, + isCorrect: false, + }]) + } + + setMessages(prev => [...prev, newMessage]) + } + }, [currentUserId, user, gameState.currentWord, timeLeft, handleRoundEnd, isConnected, wsSendMessage]) + + // 그리기 데이터 전송 (WebSocket으로 전송) + const handleDraw = useCallback((strokeData) => { + if (isConnected && canDraw) { + console.log('[CatchmindPlayPage] Sending drawing via WebSocket') + sendDrawing(strokeData) + } + }, [isConnected, canDraw, sendDrawing]) + + // 캔버스 클리어 (WebSocket으로 전송) + const handleClearCanvas = useCallback(() => { + if (isConnected && canDraw) { + console.log('[CatchmindPlayPage] Sending clear canvas via WebSocket') + clearDrawing() + } + }, [isConnected, canDraw, clearDrawing]) + + // 게임 종료 + const handleStopGame = async () => { + try { + disconnect() + await gameService.stopGame(roomId) + sessionStorage.removeItem(`roomToken_${roomId}`) + navigate('/games/catchmind') + } catch (err) { + console.error('Failed to stop game:', err) + } + } + + // 재시작 + const handleRestart = async () => { + try { + disconnect() + await gameService.restartGame(roomId) + setShowEndModal(false) + navigate(`/games/catchmind/${roomId}/waiting`) + } catch (err) { + console.error('Failed to restart:', err) + // 실패해도 대기실로 이동 + setShowEndModal(false) + navigate(`/games/catchmind/${roomId}/waiting`) + } + } + + // 나가기 + const handleLeave = async () => { + try { + disconnect() + await gameService.leaveRoom(roomId) + } catch (err) { + console.error('Failed to leave room:', err) + } + sessionStorage.removeItem(`roomToken_${roomId}`) + navigate('/games/catchmind') + } + + // 타이머 포맷 + const formatTime = (seconds) => { + const min = Math.floor(seconds / 60) + const sec = seconds % 60 + return `${min}:${sec.toString().padStart(2, '0')}` + } + + // 점수 순위 - 모든 참가자를 0점부터 시작하여 표시 + const sortedScores = (room?.participants || []) + .map(participant => ({ + userId: participant.userId, + nickname: participant.nickname || participant.userId, + score: (gameState.scores || {})[participant.userId] || 0, + })) + .sort((a, b) => b.score - a.score) + .map((item, index) => ({ + ...item, + rank: index + 1, + })) + + if (loading) { + return ( + + + + ) + } + + return ( + + {/* 헤더 - 컴팩트 */} + + + + {room?.name || '캐치마인드'} + + + + 출제자: {room?.participants?.find(p => p.userId === gameState.currentDrawerId)?.nickname || gameState.currentDrawerId} + + + + + {/* 타이머 */} + + + {formatTime(timeLeft)} + + + + + + + + + + {/* 타이머 프로그레스 */} + + + {/* WebSocket 에러 */} + {wsError && ( + + {wsError} + + )} + + {/* 제시어 배너 (출제자만) - 컴팩트 */} + {isDrawer && gameState.currentWord && ( + + + 제시어: + + + {gameState.currentWord} + + + )} + + {/* 메인 영역: Split View */} + + {/* 좌측: 캔버스 영역 (65%) */} + + + + {/* 비눗방울 오버레이 */} + + + {/* 힌트 영역 - 컴팩트 */} + {!isDrawer && ( + + + + )} + + + {/* 우측: 채팅 영역 (35%) */} + + {/* 스코어 대시보드 - 컴팩트 */} + + + + + 점수판 + + + + {sortedScores.map((item) => ( + + + + {item.rank === 1 ? '🥇' : item.rank === 2 ? '🥈' : item.rank === 3 ? '🥉' : `${item.rank}.`} + + + {item.nickname} + {item.userId === currentUserId && ' (나)'} + + + + {item.score}점 + + + ))} + + + + {/* 채팅 영역 */} + + + + + + + {/* 게임 종료 모달 */} + + + + + 게임 종료! + + + + + + 최종 순위 + + + + {finalRanking.map((item) => ( + + + + {item.rank === 1 ? '🥇' : item.rank === 2 ? '🥈' : item.rank === 3 ? '🥉' : `${item.rank}위`} + + + {item.nickname} + {item.userId === currentUserId && ' (나)'} + + + + {item.score}점 + + + ))} + + + + + + + + + + ) +} + +export default CatchmindPlayPage diff --git a/src/domains/games/pages/CatchmindWaitingPage.jsx b/src/domains/games/pages/CatchmindWaitingPage.jsx new file mode 100644 index 0000000..5b06f14 --- /dev/null +++ b/src/domains/games/pages/CatchmindWaitingPage.jsx @@ -0,0 +1,406 @@ +import {useCallback, useEffect, useState} from 'react' +import {useNavigate, useParams} from 'react-router-dom' +import { + Alert, + Box, + Button, + Card, + Chip, + CircularProgress, + Container, + IconButton, + Typography, +} from '@mui/material' +import { + ArrowBack as ArrowBackIcon, + PlayArrow as PlayIcon, + Settings as SettingsIcon, +} from '@mui/icons-material' +import ParticipantList from '../components/ParticipantList' +import WaitingChat from '../components/WaitingChat' +import {gameService} from '../services/gameService' +import {GAME_COLORS} from '../theme/gameTheme' +import {useAuth} from '../../../contexts/AuthContext' +import {useThemeMode} from '../../../contexts/ThemeContext' +import {useChatWebSocket} from '../../freetalk/hooks/useChatWebSocket' + +const CatchmindWaitingPage = () => { + const { roomId } = useParams() + const navigate = useNavigate() + const { user } = useAuth() + const { mode } = useThemeMode() + const isDark = mode === 'dark' + const currentUserId = user?.userId || user?.username || user?.sub + + // WebSocket 연결 + const { + isConnected, + messages: wsMessages, + gameState: wsGameState, + error: wsError, + connect, + disconnect, + sendMessage: wsSendMessage, + } = useChatWebSocket(roomId, currentUserId) + + const [room, setRoom] = useState(null) + const [loading, setLoading] = useState(true) + const [isInitialLoad, setIsInitialLoad] = useState(true) + const [error, setError] = useState(null) + const [starting, setStarting] = useState(false) + + // 채팅 메시지 (WebSocket 연동) + const [messages, setMessages] = useState([ + { + id: 'system-1', + content: '게임 대기실에 입장했습니다.', + isSystem: true, + createdAt: new Date().toISOString(), + }, + ]) + + // WebSocket 메시지 동기화 + useEffect(() => { + if (wsMessages && wsMessages.length > 0) { + setMessages(prev => { + // 시스템 메시지는 유지 + const systemMessages = prev.filter(m => m.isSystem && m.id === 'system-1') + // wsMessages에서 중복 제거 (ID 기준) + const existingIds = new Set(systemMessages.map(m => m.id)) + const uniqueWsMessages = wsMessages.filter(m => !existingIds.has(m.id)) + // ID 기준으로 중복 제거된 최종 배열 + const allMessages = [...systemMessages, ...uniqueWsMessages] + // 혹시 wsMessages 내에서도 중복이 있을 수 있으므로 다시 한번 ID 기준 중복 제거 + const seen = new Set() + return allMessages.filter(m => { + if (seen.has(m.id)) return false + seen.add(m.id) + return true + }) + }) + } + }, [wsMessages]) + + // 게임 시작 감지 (WebSocket GAME_START 이벤트) + useEffect(() => { + if (wsGameState?.status === 'PLAYING') { + console.log('[CatchmindWaitingPage] Game started via WebSocket, navigating to play page') + navigate(`/games/catchmind/${roomId}/play`) + } + }, [wsGameState?.status, roomId, navigate]) + + // 방 정보 조회 + const fetchRoom = useCallback(async (showLoading = false) => { + try { + if (showLoading) { + setLoading(true) + } + const response = await gameService.getRoom(roomId) + setRoom(response.data) + + // 게임이 시작되면 플레이 페이지로 이동 + if (response.data.status === 'PLAYING') { + navigate(`/games/catchmind/${roomId}/play`) + } + } catch (err) { + console.error('Failed to fetch room:', err) + setError('방 정보를 불러오는데 실패했습니다') + } finally { + if (showLoading) { + setLoading(false) + } + setIsInitialLoad(false) + } + }, [roomId, navigate]) + + // 초기 로딩 및 WebSocket 연결 + useEffect(() => { + const init = async () => { + await fetchRoom(true) + // WebSocket 연결 + console.log('[CatchmindWaitingPage] Connecting WebSocket...') + try { + await connect() + console.log('[CatchmindWaitingPage] WebSocket connected') + } catch (err) { + console.error('[CatchmindWaitingPage] WebSocket connection failed:', err) + } + } + init() + + // 컴포넌트 언마운트 시 WebSocket 연결 해제 + return () => { + console.log('[CatchmindWaitingPage] Disconnecting WebSocket...') + disconnect() + } + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + // 주기적 새로고침 (로딩 표시 없이 백그라운드 갱신) + useEffect(() => { + const interval = setInterval(() => fetchRoom(false), 3000) + return () => clearInterval(interval) + }, [fetchRoom]) + + // 게임 시작 + const handleStartGame = async () => { + try { + setStarting(true) + await gameService.startGame(roomId) + // WebSocket GAME_START 이벤트로 자동 이동하지만, fallback으로 직접 이동 + navigate(`/games/catchmind/${roomId}/play`) + } catch (err) { + console.error('Failed to start game:', err) + // 백엔드 에러 메시지 추출 + const errorMessage = err.response?.data?.message || err.message || '게임 시작에 실패했습니다' + setError(errorMessage) + } finally { + setStarting(false) + } + } + + // 방 나가기 + const handleLeaveRoom = async () => { + try { + disconnect() + await gameService.leaveRoom(roomId) + // 저장된 roomToken 삭제 + sessionStorage.removeItem(`roomToken_${roomId}`) + navigate('/games/catchmind') + } catch (err) { + console.error('Failed to leave room:', err) + } + } + + // 채팅 메시지 전송 (WebSocket 연동) + const handleSendMessage = (content) => { + if (isConnected) { + // WebSocket으로 메시지 전송 + console.log('[CatchmindWaitingPage] Sending message via WebSocket:', content) + wsSendMessage(content, 'TEXT') + } else { + // WebSocket 연결 안 됐을 때 fallback (로컬만) + const newMessage = { + id: `msg-${Date.now()}`, + userId: currentUserId, + nickname: user?.nickname || user?.username || '플레이어', + content, + createdAt: new Date().toISOString(), + } + setMessages(prev => [...prev, newMessage]) + } + } + + const isHost = room?.hostId === currentUserId + const canStart = isHost && room?.currentParticipants >= 2 + + if (loading) { + return ( + + + + ) + } + + if (!room) { + return ( + + + 방을 찾을 수 없습니다 + + + + ) + } + + return ( + + {/* 헤더 */} + + + + + + + + + + {room.name} + + + + + + + + + + {isHost && ( + + + + )} + + + + + {/* 에러 */} + {(error || wsError) && ( + + setError(null)} sx={{ borderRadius: '12px' }}> + {error || wsError} + + + )} + + {/* 메인 컨텐츠 */} + + + {/* 좌측: 참가자 목록 */} + + + + + {/* 우측: 대기 채팅 */} + + + + 대기 채팅 + + + + + + + + + {/* 하단: 게임 설정 + 시작 버튼 */} + + + + + 라운드 + + + {room.gameSettings?.maxRounds || 5}라운드 + + + + + 시간 제한 + + + {room.gameSettings?.roundTimeLimit || 60}초 + + + + + {isHost ? ( + + ) : ( + + + 방장이 게임을 시작할 때까지 기다려주세요 + + + + )} + + + + ) +} + +export default CatchmindWaitingPage diff --git a/src/domains/games/pages/WordchainLobbyPage.jsx b/src/domains/games/pages/WordchainLobbyPage.jsx new file mode 100644 index 0000000..1298b07 --- /dev/null +++ b/src/domains/games/pages/WordchainLobbyPage.jsx @@ -0,0 +1,310 @@ +import { useCallback, useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Container, + FormControlLabel, + Grid, + IconButton, + MenuItem, + Select, + Switch, + Typography, +} from '@mui/material' +import { + Add as AddIcon, + Link as LinkIcon, + Refresh as RefreshIcon, +} from '@mui/icons-material' +import GameRoomCard from '../components/GameRoomCard' +import CreateGameRoomModal from '../components/CreateGameRoomModal' +import { gameService } from '../services/gameService' +import { GAME_COLORS } from '../theme/gameTheme' +import { useAuth } from '../../../contexts/AuthContext' + +const WordchainLobbyPage = () => { + const navigate = useNavigate() + const { user } = useAuth() + + const [rooms, setRooms] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [createModalOpen, setCreateModalOpen] = useState(false) + const [creating, setCreating] = useState(false) + + // 필터 + const [filters, setFilters] = useState({ + level: '', + waitingOnly: true, + }) + + // 방 목록 조회 - gameType을 WORDCHAIN으로 필터링 + const fetchRooms = useCallback(async () => { + try { + setLoading(true) + setError(null) + + const params = { + gameType: 'WORDCHAIN', + } + if (filters.level) params.level = filters.level + if (filters.waitingOnly) params.status = 'WAITING' + + const response = await gameService.getRooms(params) + setRooms(response.data.rooms || []) + } catch (err) { + console.error('Failed to fetch rooms:', err) + setError('방 목록을 불러오는데 실패했습니다') + } finally { + setLoading(false) + } + }, [filters]) + + useEffect(() => { + fetchRooms() + }, [fetchRooms]) + + // 방 생성 + const handleCreateRoom = async (data) => { + try { + setCreating(true) + // gameType을 WORDCHAIN으로 설정 + const response = await gameService.createRoom({ + ...data, + gameType: 'WORDCHAIN', + }) + setCreateModalOpen(false) + // 생성된 방의 대기실로 이동 + navigate(`/games/wordchain/${response.data.roomId}/waiting`) + } catch (err) { + console.error('Failed to create room:', err) + setError('방 생성에 실패했습니다') + } finally { + setCreating(false) + } + } + + // 방 참가 + const handleJoinRoom = async (room) => { + try { + const response = await gameService.joinRoom(room.roomId) + // roomToken을 sessionStorage에 저장 (WebSocket 연결 시 사용) + if (response.data?.roomToken) { + sessionStorage.setItem(`roomToken_${room.roomId}`, response.data.roomToken) + } + navigate(`/games/wordchain/${room.roomId}/waiting`) + } catch (err) { + console.error('Failed to join room:', err) + setError(err.message || '방 참가에 실패했습니다') + } + } + + // 관전 + const handleSpectateRoom = (room) => { + navigate(`/games/wordchain/${room.roomId}/play?spectate=true`) + } + + return ( + + {/* 헤더 */} + + + + + + + + 끝말잇기 + + + 영어 단어로 끝말잇기를 즐겨보세요 + + + + + + {/* 필터 + 액션 */} + + {/* 필터 */} + + + + setFilters(prev => ({ ...prev, waitingOnly: e.target.checked }))} + sx={{ + '& .MuiSwitch-switchBase.Mui-checked': { + color: GAME_COLORS.primary, + }, + '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { + bgcolor: GAME_COLORS.primary, + }, + }} + /> + } + label={대기중만} + /> + + + + + + + + + {/* 방 만들기 버튼 */} + + + + {/* 에러 */} + {error && ( + setError(null)} sx={{ mb: 3, borderRadius: '12px' }}> + {error} + + )} + + {/* 방 목록 */} + {loading ? ( + + + + ) : rooms.length === 0 ? ( + + + + + + 대기 중인 방이 없습니다 + + + 새로운 게임방을 만들어보세요! + + + + ) : ( + + {rooms.map((room) => ( + + + + ))} + + )} + + {/* 방 생성 모달 */} + setCreateModalOpen(false)} + onCreate={handleCreateRoom} + loading={creating} + gameType="WORDCHAIN" + /> + + ) +} + +export default WordchainLobbyPage diff --git a/src/domains/games/pages/WordchainPlayPage.jsx b/src/domains/games/pages/WordchainPlayPage.jsx new file mode 100644 index 0000000..f248609 --- /dev/null +++ b/src/domains/games/pages/WordchainPlayPage.jsx @@ -0,0 +1,477 @@ +import { useCallback, useEffect, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Container, + Grid, + IconButton, + Typography, +} from '@mui/material' +import { + ExitToApp as ExitIcon, + Stop as StopIcon, +} from '@mui/icons-material' +import WordDisplay from '../components/wordchain/WordDisplay' +import WordchainTimer from '../components/wordchain/WordchainTimer' +import PlayerList from '../components/wordchain/PlayerList' +import UsedWordsList from '../components/wordchain/UsedWordsList' +import WordInput from '../components/wordchain/WordInput' +import GameEndModal from '../components/wordchain/GameEndModal' +import { gameService } from '../services/gameService' +import wordchainService from '../services/wordchainService' +import { GAME_COLORS } from '../theme/gameTheme' +import { useAuth } from '../../../contexts/AuthContext' +import { useThemeMode } from '../../../contexts/ThemeContext' +import { useChatWebSocket } from '../../freetalk/hooks/useChatWebSocket' + +const WordchainPlayPage = () => { + const { roomId } = useParams() + const navigate = useNavigate() + const { user } = useAuth() + const { mode } = useThemeMode() + const isDark = mode === 'dark' + const currentUserId = user?.userId || user?.username || user?.sub + + // WebSocket 연결 + const { + isConnected, + gameState: wsGameState, + error: wsError, + connect, + disconnect, + } = useChatWebSocket(roomId, currentUserId) + + // 로컬 게임 상태 + const [gameState, setGameState] = useState({ + status: 'PLAYING', + currentWord: null, + nextLetter: null, + currentTurnUserId: null, + turnStartTime: Date.now(), + turnTimeLimit: 15, + players: [], + usedWords: [], + winner: null, + ranking: null, + finalScores: null, + }) + + const [room, setRoom] = useState(null) + const [loading, setLoading] = useState(true) + const [timeLeft, setTimeLeft] = useState(15) + const [showEndModal, setShowEndModal] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [timeoutSent, setTimeoutSent] = useState(false) // 타임아웃 중복 방지 + + const isMyTurn = gameState.currentTurnUserId === currentUserId + console.log('[WordchainPlayPage] Turn check:', { currentUserId, currentTurnUserId: gameState.currentTurnUserId, isMyTurn }) + + // WebSocket에서 온 players 배열을 객체 배열로 변환 (nickname 매핑) + const mapPlayersWithNickname = (playerIds, existingPlayers, participants) => { + if (!playerIds || !Array.isArray(playerIds)) return existingPlayers + + // playerIds가 이미 객체 배열이면 그대로 반환 + if (playerIds.length > 0 && typeof playerIds[0] === 'object') { + return playerIds + } + + // 문자열 배열이면 nickname 매핑 + return playerIds.map(userId => { + // 기존 players에서 찾기 + const existing = existingPlayers?.find(p => p.userId === userId) + if (existing) return existing + + // room.participants에서 찾기 + const participant = participants?.find(p => p.id === userId || p.participantId === userId || p.userId === userId) + if (participant) { + return { + userId, + nickname: participant.nickname || participant.name || userId.substring(0, 8), + isAlive: true, + } + } + + // 못 찾으면 userId로 표시 + return { + userId, + nickname: userId.substring(0, 8), + isAlive: true, + } + }) + } + + // WebSocket gameState 업데이트 반영 + useEffect(() => { + if (wsGameState) { + console.log('[WordchainPlayPage] Received wsGameState:', wsGameState) + + setGameState(prev => { + // 턴이 변경되면 타이머 리셋 + if (wsGameState.currentTurnUserId && wsGameState.currentTurnUserId !== prev.currentTurnUserId) { + setTimeLeft(wsGameState.turnTimeLimit ?? prev.turnTimeLimit ?? 15) + } + + // players 매핑 + const mappedPlayers = wsGameState.players + ? mapPlayersWithNickname(wsGameState.players, prev.players, room?.participants) + : prev.players + + return { + ...prev, + status: wsGameState.status ?? prev.status, + currentWord: wsGameState.currentWord ?? prev.currentWord, + nextLetter: wsGameState.nextLetter ?? prev.nextLetter, + currentTurnUserId: wsGameState.currentTurnUserId ?? prev.currentTurnUserId, + turnStartTime: wsGameState.turnStartTime ?? prev.turnStartTime, + turnTimeLimit: wsGameState.turnTimeLimit ?? prev.turnTimeLimit ?? 15, + players: mappedPlayers, + usedWords: wsGameState.usedWords ?? prev.usedWords, + winner: wsGameState.winner ?? prev.winner, + ranking: wsGameState.ranking ?? prev.ranking, + finalScores: wsGameState.finalScores ?? prev.finalScores, + } + }) + + // 게임 종료 처리 + if (wsGameState.status === 'FINISHED' && !showEndModal) { + setShowEndModal(true) + } + } + }, [wsGameState, showEndModal, room]) + + // 초기 로드 및 WebSocket 연결 + useEffect(() => { + const initGame = async () => { + try { + setLoading(true) + + // 방 정보 조회 + const roomResponse = await gameService.getRoom(roomId) + setRoom(roomResponse.data) + + // sessionStorage에서 WORDCHAIN_START 데이터 확인 + const savedState = sessionStorage.getItem(`wordchainState_${roomId}`) + let gameData = null + + if (savedState) { + gameData = JSON.parse(savedState) + console.log('[WordchainPlayPage] Got saved wordchain state:', gameData) + // 페이지 이탈 시 삭제하도록 변경 (StrictMode 두 번 마운트 대응) + } else { + // sessionStorage에 없으면 API 조회 시도 + try { + const statusResponse = await wordchainService.getStatus(roomId) + gameData = statusResponse.data || statusResponse + console.log('[WordchainPlayPage] Got game status:', gameData) + } catch (err) { + // 게임 상태 조회 실패 시 WebSocket 이벤트 대기 + console.log('[WordchainPlayPage] Failed to get status, waiting for WebSocket:', err.message) + // 기본 상태로 시작하고 WebSocket에서 업데이트 받음 + gameData = { + currentWord: null, + nextLetter: null, + currentTurnUserId: null, + turnTimeLimit: 15, + players: roomResponse.data.participants || [], + usedWords: [], + } + } + } + + setGameState({ + status: 'PLAYING', + currentWord: gameData.currentWord || null, + nextLetter: gameData.nextLetter || null, + currentTurnUserId: gameData.currentTurnUserId, + turnStartTime: gameData.turnStartTime || Date.now(), + turnTimeLimit: gameData.turnTimeLimit || 15, + players: gameData.players || roomResponse.data.participants || [], + usedWords: gameData.usedWords || [], + winner: null, + }) + + setTimeLeft(gameData.turnTimeLimit || 15) + + // WebSocket 연결 + console.log('[WordchainPlayPage] Connecting WebSocket...') + await connect() + console.log('[WordchainPlayPage] WebSocket connected') + } catch (err) { + console.error('Failed to init game:', err) + } finally { + setLoading(false) + } + } + + initGame() + + return () => { + console.log('[WordchainPlayPage] Disconnecting WebSocket...') + disconnect() + } + }, [roomId, currentUserId, connect, disconnect]) + + // 타이머 + useEffect(() => { + if (gameState.status !== 'PLAYING') return + + const interval = setInterval(() => { + setTimeLeft(prev => { + if (prev <= 1) { + return 0 + } + return prev - 1 + }) + }, 1000) + + return () => clearInterval(interval) + }, [gameState.status]) + + // 타임아웃 처리 (한 번만 전송) + useEffect(() => { + if (timeLeft === 0 && isMyTurn && isConnected && !timeoutSent) { + console.log('[WordchainPlayPage] Timer expired, sending timeout') + setTimeoutSent(true) + wordchainService.timeout(roomId).catch(err => { + console.error('Failed to send timeout:', err) + }) + } + }, [timeLeft, isMyTurn, isConnected, timeoutSent, roomId]) + + // 턴 변경 시 타임아웃 플래그 리셋 + useEffect(() => { + setTimeoutSent(false) + }, [gameState.currentTurnUserId]) + + // 단어 제출 + const handleSubmitWord = async (word) => { + if (!isMyTurn || submitting) return + + try { + setSubmitting(true) + await wordchainService.submit(roomId, word) + // 서버에서 WebSocket으로 결과 브로드캐스트 + } catch (err) { + console.error('Failed to submit word:', err) + alert(err.response?.data?.message || err.message || '단어 제출에 실패했습니다') + } finally { + setSubmitting(false) + } + } + + // 게임 종료 + const handleStopGame = async () => { + disconnect() + try { + await wordchainService.stop(roomId) + } catch (err) { + console.error('Failed to stop game:', err) + // 에러가 나도 무시하고 진행 + } + sessionStorage.removeItem(`roomToken_${roomId}`) + sessionStorage.removeItem(`wordchainState_${roomId}`) + navigate('/games/wordchain') + } + + // 재시작 + const handleRestart = async () => { + try { + disconnect() + await gameService.restartGame(roomId) + setShowEndModal(false) + navigate(`/games/wordchain/${roomId}/waiting`) + } catch (err) { + console.error('Failed to restart:', err) + setShowEndModal(false) + navigate(`/games/wordchain/${roomId}/waiting`) + } + } + + // 나가기 + const handleLeave = async () => { + try { + disconnect() + await gameService.leaveRoom(roomId) + } catch (err) { + console.error('Failed to leave room:', err) + } + sessionStorage.removeItem(`roomToken_${roomId}`) + navigate('/games/wordchain') + } + + if (loading) { + return ( + + + + ) + } + + return ( + + {/* 헤더 */} + + + + + + {room?.name || '끝말잇기'} + + + + + + + + + + + + {/* WebSocket 에러 */} + {wsError && ( + + + {wsError} + + + )} + + {/* 메인 컨텐츠 */} + + + {/* 좌측: 게임 영역 */} + + {/* 타이머 & 현재 턴 */} + + + + 현재 턴 + + + {gameState.players.find(p => p.userId === gameState.currentTurnUserId)?.nickname || + gameState.currentTurnUserId || + '대기 중'} + + {isMyTurn && ( + + )} + + + + + {/* 현재 단어 & 다음 글자 */} + + + + + {/* 단어 입력 */} + + + + + + {/* 우측: 플레이어 & 사용된 단어 */} + + {/* 플레이어 목록 */} + + + + + {/* 사용된 단어 */} + + + + + + + + {/* 게임 종료 모달 */} + + + ) +} + +export default WordchainPlayPage diff --git a/src/domains/games/pages/WordchainWaitingPage.jsx b/src/domains/games/pages/WordchainWaitingPage.jsx new file mode 100644 index 0000000..d8c8102 --- /dev/null +++ b/src/domains/games/pages/WordchainWaitingPage.jsx @@ -0,0 +1,394 @@ +import { useCallback, useEffect, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { + Alert, + Box, + Button, + Card, + Chip, + CircularProgress, + Container, + IconButton, + Typography, +} from '@mui/material' +import { + ArrowBack as ArrowBackIcon, + PlayArrow as PlayIcon, + Settings as SettingsIcon, +} from '@mui/icons-material' +import ParticipantList from '../components/ParticipantList' +import WaitingChat from '../components/WaitingChat' +import { gameService } from '../services/gameService' +import { wordchainService } from '../services/wordchainService' +import { GAME_COLORS } from '../theme/gameTheme' +import { useAuth } from '../../../contexts/AuthContext' +import { useThemeMode } from '../../../contexts/ThemeContext' +import { useChatWebSocket } from '../../freetalk/hooks/useChatWebSocket' + +const WordchainWaitingPage = () => { + const { roomId } = useParams() + const navigate = useNavigate() + const { user } = useAuth() + const { mode } = useThemeMode() + const isDark = mode === 'dark' + const currentUserId = user?.userId || user?.username || user?.sub + + // WebSocket 연결 + const { + isConnected, + messages: wsMessages, + gameState: wsGameState, + error: wsError, + connect, + disconnect, + sendMessage: wsSendMessage, + } = useChatWebSocket(roomId, currentUserId) + + const [room, setRoom] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [starting, setStarting] = useState(false) + + // 채팅 메시지 (WebSocket 연동) + const [messages, setMessages] = useState([ + { + id: 'system-1', + content: '게임 대기실에 입장했습니다.', + isSystem: true, + createdAt: new Date().toISOString(), + }, + ]) + + // WebSocket 메시지 동기화 + useEffect(() => { + if (wsMessages && wsMessages.length > 0) { + setMessages(prev => { + const systemMessages = prev.filter(m => m.isSystem && m.id === 'system-1') + const existingIds = new Set(systemMessages.map(m => m.id)) + const uniqueWsMessages = wsMessages.filter(m => !existingIds.has(m.id)) + const allMessages = [...systemMessages, ...uniqueWsMessages] + const seen = new Set() + return allMessages.filter(m => { + if (seen.has(m.id)) return false + seen.add(m.id) + return true + }) + }) + } + }, [wsMessages]) + + // 게임 시작 감지 (WebSocket GAME_START 이벤트) + useEffect(() => { + if (wsGameState?.status === 'PLAYING') { + console.log('[WordchainWaitingPage] Game started via WebSocket, navigating to play page') + navigate(`/games/wordchain/${roomId}/play`) + } + }, [wsGameState?.status, roomId, navigate]) + + // 방 정보 조회 + const fetchRoom = useCallback(async (showLoading = false) => { + try { + if (showLoading) { + setLoading(true) + } + const response = await gameService.getRoom(roomId) + setRoom(response.data) + + // 게임이 시작되면 플레이 페이지로 이동 + if (response.data.status === 'PLAYING') { + navigate(`/games/wordchain/${roomId}/play`) + } + } catch (err) { + console.error('Failed to fetch room:', err) + setError('방 정보를 불러오는데 실패했습니다') + } finally { + if (showLoading) { + setLoading(false) + } + } + }, [roomId, navigate]) + + // 초기 로딩 및 WebSocket 연결 + useEffect(() => { + const init = async () => { + await fetchRoom(true) + console.log('[WordchainWaitingPage] Connecting WebSocket...') + try { + await connect() + console.log('[WordchainWaitingPage] WebSocket connected') + } catch (err) { + console.error('[WordchainWaitingPage] WebSocket connection failed:', err) + } + } + init() + + return () => { + console.log('[WordchainWaitingPage] Disconnecting WebSocket...') + disconnect() + } + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + // 주기적 새로고침 + useEffect(() => { + const interval = setInterval(() => fetchRoom(false), 3000) + return () => clearInterval(interval) + }, [fetchRoom]) + + // 게임 시작 + const handleStartGame = async () => { + try { + setStarting(true) + await wordchainService.start(roomId) + navigate(`/games/wordchain/${roomId}/play`) + } catch (err) { + console.error('Failed to start game:', err) + const errorMessage = err.response?.data?.message || err.message || '게임 시작에 실패했습니다' + setError(errorMessage) + } finally { + setStarting(false) + } + } + + // 방 나가기 + const handleLeaveRoom = async () => { + try { + disconnect() + await gameService.leaveRoom(roomId) + sessionStorage.removeItem(`roomToken_${roomId}`) + navigate('/games/wordchain') + } catch (err) { + console.error('Failed to leave room:', err) + } + } + + // 채팅 메시지 전송 + const handleSendMessage = (content) => { + if (isConnected) { + console.log('[WordchainWaitingPage] Sending message via WebSocket:', content) + wsSendMessage(content, 'TEXT') + } else { + const newMessage = { + id: `msg-${Date.now()}`, + userId: currentUserId, + nickname: user?.nickname || user?.username || '플레이어', + content, + createdAt: new Date().toISOString(), + } + setMessages(prev => [...prev, newMessage]) + } + } + + const isHost = room?.hostId === currentUserId + const canStart = isHost && room?.currentParticipants >= 2 + + if (loading) { + return ( + + + + ) + } + + if (!room) { + return ( + + + 방을 찾을 수 없습니다 + + + + ) + } + + return ( + + {/* 헤더 */} + + + + + + + + + + {room.name} + + + + + + + + + + {isHost && ( + + + + )} + + + + + {/* 에러 */} + {(error || wsError) && ( + + setError(null)} sx={{ borderRadius: '12px' }}> + {error || wsError} + + + )} + + {/* 메인 컨텐츠 */} + + + {/* 좌측: 참가자 목록 */} + + + + + {/* 우측: 대기 채팅 */} + + + + 대기 채팅 + + + + + + + + + {/* 하단: 게임 설정 + 시작 버튼 */} + + + + + 턴 시간 + + + {room.gameSettings?.turnTimeLimit || 15}초 + + + + + 난이도 + + + {room.level || 'INTERMEDIATE'} + + + + + {isHost ? ( + + ) : ( + + + 방장이 게임을 시작할 때까지 기다려주세요 + + + + )} + + + + ) +} + +export default WordchainWaitingPage diff --git a/src/domains/games/services/gameService.js b/src/domains/games/services/gameService.js new file mode 100644 index 0000000..03844d4 --- /dev/null +++ b/src/domains/games/services/gameService.js @@ -0,0 +1,353 @@ +/** + * Game Service - 백엔드 API 연동 + * 캐치마인드 게임 관련 REST API 호출 + */ +import chatApi from '../../../api/chatApi' + +/** + * 게임방 관련 API + */ +export const gameRoomService = { + /** + * 게임방 목록 조회 + * @param {Object} filters - 필터 옵션 + * @param {string} filters.status - WAITING, PLAYING, FINISHED + * @param {string} filters.level - BEGINNER, INTERMEDIATE, ADVANCED + * @param {number} filters.limit - 조회 개수 (기본 20) + * @param {string} filters.cursor - 페이지네이션 커서 + */ + getList: async (filters = {}) => { + const params = new URLSearchParams() + // 게임방 필터 + params.append('type', 'GAME') + const gameType = filters.gameType || 'CATCHMIND' + params.append('gameType', gameType) + + // 백엔드는 소문자 level 값 사용 + if (filters.status) params.append('status', filters.status) + if (filters.level) params.append('level', filters.level.toLowerCase()) + if (filters.limit) params.append('limit', filters.limit || 20) + if (filters.cursor) params.append('cursor', filters.cursor) + + console.log('[gameService] getList params:', params.toString()) + const response = await chatApi.get(`/chat/rooms?${params.toString()}`) + + // 백엔드가 type 필터를 지원하지 않을 경우, 클라이언트 사이드 필터링 + let data = response.data + if (data?.rooms) { + data.rooms = data.rooms.filter(room => + room.type === 'GAME' || room.gameType === gameType + ) + } else if (Array.isArray(data)) { + data = data.filter(room => + room.type === 'GAME' || room.gameType === gameType + ) + } + + return data + }, + + /** + * 게임방 상세 조회 + * @param {string} roomId + */ + getDetail: async (roomId) => { + const response = await chatApi.get(`/chat/rooms/${roomId}`) + return response.data + }, + + /** + * 게임방 생성 + * @param {Object} data + * @param {string} data.name - 방 이름 + * @param {string} data.description - 방 설명 + * @param {string} data.level - BEGINNER, INTERMEDIATE, ADVANCED + * @param {number} data.maxMembers - 최대 인원 (2-10) + * @param {boolean} data.isPrivate - 비공개 여부 + * @param {string} data.password - 비공개 방 비밀번호 + * @param {Object} data.gameSettings - 게임 설정 + */ + create: async (data) => { + const gameType = data.gameType || 'CATCHMIND' + const isWordchain = gameType === 'WORDCHAIN' + + // 게임 타입별 gameSettings 설정 + const gameSettings = isWordchain + ? { + turnTimeLimit: data.turnTimeLimit || 15, + } + : { + maxRounds: data.maxRounds || 5, + roundTimeLimit: data.roundTimeLimit || 60, + } + + const payload = { + name: data.name, + description: data.description || '', + level: (data.level || 'beginner').toLowerCase(), // 백엔드는 소문자 사용 + maxMembers: data.maxParticipants || data.maxMembers || 6, + isPrivate: data.isPrivate || false, + password: data.password, + type: 'GAME', + gameType: gameType, + gameSettings: gameSettings, + } + + console.log('[gameService] create payload:', payload) + const response = await chatApi.post('/chat/rooms', payload) + console.log('[gameService] create response:', response) + return response.data + }, + + /** + * 게임방 참가 + * @param {string} roomId + * @param {string} password - 비공개 방인 경우 + * @returns {Promise<{room: Object, roomToken: string, tokenExpiresAt: number}>} + */ + join: async (roomId, password) => { + const payload = password ? { password } : {} + const response = await chatApi.post(`/chat/rooms/${roomId}/join`, payload) + return response.data + }, + + /** + * 게임방 나가기 + * @param {string} roomId + */ + leave: async (roomId) => { + const response = await chatApi.post(`/chat/rooms/${roomId}/leave`, {}) + return response.data + }, +} + +/** + * 게임 진행 관련 API + */ +export const gamePlayService = { + /** + * 게임 시작 + * @param {string} roomId + */ + start: async (roomId) => { + const response = await chatApi.post(`/chat/rooms/${roomId}/game/start`, {}) + return response.data + }, + + /** + * 게임 중단 + * @param {string} roomId + */ + stop: async (roomId) => { + const response = await chatApi.post(`/chat/rooms/${roomId}/game/stop`, {}) + return response.data + }, + + /** + * 게임 상태 조회 + * @param {string} roomId + */ + getStatus: async (roomId) => { + const response = await chatApi.get(`/chat/rooms/${roomId}/game/status`) + return response.data + }, + + /** + * 점수 조회 + * @param {string} roomId + */ + getScores: async (roomId) => { + const response = await chatApi.get(`/chat/rooms/${roomId}/game/scores`) + return response.data + }, + + /** + * 게임 재시작 + * @param {string} roomId + */ + restart: async (roomId) => { + const response = await chatApi.post(`/chat/rooms/${roomId}/game/restart`, {}) + return response.data + }, +} + +/** + * 통합 게임 서비스 (mockGameService 호환 인터페이스) + */ +export const gameService = { + // 방 목록 조회 + getRooms: async (filters = {}) => { + try { + const data = await gameRoomService.getList(filters) + console.log('[gameService] getRooms data:', data) + // 백엔드 응답: { rooms: [...], nextCursor, hasMore } 또는 직접 배열 + const rooms = Array.isArray(data) ? data : (data?.rooms || []) + return { + success: true, + data: { + rooms, + totalCount: rooms.length, + nextCursor: data?.nextCursor, + hasMore: data?.hasMore, + }, + } + } catch (error) { + console.error('[gameService] getRooms error:', error) + throw error + } + }, + + // 방 상세 조회 + getRoom: async (roomId) => { + try { + const data = await gameRoomService.getDetail(roomId) + // 백엔드 응답을 프론트엔드 형식으로 변환 + const room = data.room || data + return { + success: true, + data: { + roomId: room.roomId, + name: room.name, + description: room.description, + type: room.type, + gameType: room.gameType, + level: room.level, + status: room.status, + hostId: room.hostId, + hostNickname: data.hostNickname, + maxParticipants: room.maxMembers, + currentParticipants: room.currentMembers, + participants: data.participants || room.memberIds?.map(id => ({ userId: id })) || [], + gameSettings: room.gameSettings || { maxRounds: 5, roundTimeLimit: 60 }, + createdAt: room.createdAt, + }, + } + } catch (error) { + console.error('[gameService] getRoom error:', error) + throw error + } + }, + + // 방 생성 + createRoom: async (data) => { + try { + const response = await gameRoomService.create(data) + console.log('[gameService] createRoom response:', response) + // 백엔드 응답 구조에 따라 room 추출 + const room = response?.roomId ? response : response + return { + success: true, + data: { + ...room, + // 프론트엔드 필드명 맞추기 + maxParticipants: room.maxMembers || room.maxParticipants, + currentParticipants: room.currentMembers || room.currentParticipants || 1, + }, + } + } catch (error) { + console.error('[gameService] createRoom error:', error) + throw error + } + }, + + // 방 참가 + joinRoom: async (roomId, password) => { + try { + const data = await gameRoomService.join(roomId, password) + return { + success: true, + data: { + room: data.room, + roomToken: data.roomToken, + tokenExpiresAt: data.tokenExpiresAt, + }, + } + } catch (error) { + console.error('[gameService] joinRoom error:', error) + throw error + } + }, + + // 방 나가기 + leaveRoom: async (roomId) => { + try { + await gameRoomService.leave(roomId) + return { success: true } + } catch (error) { + console.error('[gameService] leaveRoom error:', error) + throw error + } + }, + + // 게임 시작 + startGame: async (roomId) => { + try { + const data = await gamePlayService.start(roomId) + return { + success: true, + data, + } + } catch (error) { + console.error('[gameService] startGame error:', error) + throw error + } + }, + + // 게임 중단 + stopGame: async (roomId) => { + try { + const data = await gamePlayService.stop(roomId) + return { + success: true, + data, + } + } catch (error) { + console.error('[gameService] stopGame error:', error) + throw error + } + }, + + // 게임 상태 조회 + getGameStatus: async (roomId) => { + try { + const data = await gamePlayService.getStatus(roomId) + return { + success: true, + data, + } + } catch (error) { + console.error('[gameService] getGameStatus error:', error) + throw error + } + }, + + // 점수 조회 + getScores: async (roomId) => { + try { + const data = await gamePlayService.getScores(roomId) + return { + success: true, + data, + } + } catch (error) { + console.error('[gameService] getScores error:', error) + throw error + } + }, + + // 게임 재시작 + restartGame: async (roomId) => { + try { + const data = await gamePlayService.restart(roomId) + return { + success: true, + data, + } + } catch (error) { + console.error('[gameService] restartGame error:', error) + throw error + } + }, +} + +export default gameService diff --git a/src/domains/games/services/mockGameService.js b/src/domains/games/services/mockGameService.js new file mode 100644 index 0000000..ea08eda --- /dev/null +++ b/src/domains/games/services/mockGameService.js @@ -0,0 +1,321 @@ +/** + * Mock Game Service + * 백엔드 API 구현 전 테스트용 목 서비스 + */ + +// 목 데이터 +const mockRooms = [ + { + roomId: 'game-room-1', + name: '초보 환영방', + description: '편하게 오세요~', + type: 'GAME', + gameType: 'CATCHMIND', + level: 'BEGINNER', + status: 'WAITING', + hostId: 'user-1', + hostNickname: '홍길동', + maxParticipants: 6, + currentParticipants: 2, + participants: [ + { userId: 'user-1', nickname: '홍길동', isHost: true }, + { userId: 'user-2', nickname: '김철수', isHost: false }, + ], + gameSettings: { + maxRounds: 5, + roundTimeLimit: 60, + }, + createdAt: new Date().toISOString(), + }, + { + roomId: 'game-room-2', + name: '고수만 오세요', + description: '빠른 게임 진행', + type: 'GAME', + gameType: 'CATCHMIND', + level: 'ADVANCED', + status: 'PLAYING', + hostId: 'user-3', + hostNickname: '이영희', + maxParticipants: 4, + currentParticipants: 4, + participants: [ + { userId: 'user-3', nickname: '이영희', isHost: true }, + { userId: 'user-4', nickname: '박민수', isHost: false }, + { userId: 'user-5', nickname: '정수진', isHost: false }, + { userId: 'user-6', nickname: '최동욱', isHost: false }, + ], + gameSettings: { + maxRounds: 3, + roundTimeLimit: 45, + }, + createdAt: new Date(Date.now() - 600000).toISOString(), + }, + { + roomId: 'game-room-3', + name: '아무나 ㄱㄱ', + description: '', + type: 'GAME', + gameType: 'CATCHMIND', + level: 'INTERMEDIATE', + status: 'WAITING', + hostId: 'user-7', + hostNickname: '강지훈', + maxParticipants: 6, + currentParticipants: 1, + participants: [ + { userId: 'user-7', nickname: '강지훈', isHost: true }, + ], + gameSettings: { + maxRounds: 5, + roundTimeLimit: 60, + }, + createdAt: new Date(Date.now() - 300000).toISOString(), + }, +] + +const mockWords = [ + '사과', '바나나', '호랑이', '자동차', '비행기', + '컴퓨터', '햄버거', '피자', '축구', '농구', + '기타', '피아노', '나비', '꽃', '태양', +] + +// 지연 시뮬레이션 +const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)) + +// 현재 사용자 (테스트용) +let currentUserId = 'test-user' +let currentUserNickname = '테스트유저' + +export const mockGameService = { + // 현재 사용자 설정 (테스트용) + setCurrentUser: (userId, nickname) => { + currentUserId = userId + currentUserNickname = nickname + }, + + // 방 목록 조회 + getRooms: async (filters = {}) => { + await delay(300) + + let rooms = [...mockRooms] + + if (filters.status) { + rooms = rooms.filter(r => r.status === filters.status) + } + if (filters.level) { + rooms = rooms.filter(r => r.level === filters.level) + } + + return { + success: true, + data: { + rooms, + totalCount: rooms.length, + }, + } + }, + + // 방 상세 조회 + getRoom: async (roomId) => { + await delay(200) + + const room = mockRooms.find(r => r.roomId === roomId) + if (!room) { + throw new Error('방을 찾을 수 없습니다') + } + + return { + success: true, + data: room, + } + }, + + // 방 생성 + createRoom: async (data) => { + await delay(500) + + const newRoom = { + roomId: `game-room-${Date.now()}`, + name: data.name, + description: data.description || '', + type: 'GAME', + gameType: 'CATCHMIND', + level: data.level || 'BEGINNER', + status: 'WAITING', + hostId: currentUserId, + hostNickname: currentUserNickname, + maxParticipants: data.maxParticipants || 6, + currentParticipants: 1, + participants: [ + { userId: currentUserId, nickname: currentUserNickname, isHost: true }, + ], + gameSettings: { + maxRounds: data.maxRounds || 5, + roundTimeLimit: data.roundTimeLimit || 60, + }, + createdAt: new Date().toISOString(), + } + + mockRooms.unshift(newRoom) + + return { + success: true, + data: newRoom, + } + }, + + // 방 참가 + joinRoom: async (roomId) => { + await delay(300) + + const room = mockRooms.find(r => r.roomId === roomId) + if (!room) { + throw new Error('방을 찾을 수 없습니다') + } + + if (room.currentParticipants >= room.maxParticipants) { + throw new Error('방이 가득 찼습니다') + } + + if (room.status === 'PLAYING') { + throw new Error('게임이 진행 중입니다') + } + + // 이미 참가 중인지 확인 + const alreadyJoined = room.participants.some(p => p.userId === currentUserId) + if (!alreadyJoined) { + room.participants.push({ + userId: currentUserId, + nickname: currentUserNickname, + isHost: false, + }) + room.currentParticipants++ + } + + return { + success: true, + data: { + room, + roomToken: `mock-token-${roomId}-${Date.now()}`, + }, + } + }, + + // 방 나가기 + leaveRoom: async (roomId) => { + await delay(200) + + const room = mockRooms.find(r => r.roomId === roomId) + if (!room) { + throw new Error('방을 찾을 수 없습니다') + } + + const participantIndex = room.participants.findIndex(p => p.userId === currentUserId) + if (participantIndex !== -1) { + const wasHost = room.participants[participantIndex].isHost + room.participants.splice(participantIndex, 1) + room.currentParticipants-- + + // 방장이 나가면 다음 사람이 방장 + if (wasHost && room.participants.length > 0) { + room.participants[0].isHost = true + room.hostId = room.participants[0].userId + room.hostNickname = room.participants[0].nickname + } + + // 모두 나가면 방 삭제 + if (room.participants.length === 0) { + const roomIndex = mockRooms.findIndex(r => r.roomId === roomId) + if (roomIndex !== -1) { + mockRooms.splice(roomIndex, 1) + } + } + } + + return { success: true } + }, + + // 게임 시작 + startGame: async (roomId) => { + await delay(300) + + const room = mockRooms.find(r => r.roomId === roomId) + if (!room) { + throw new Error('방을 찾을 수 없습니다') + } + + if (room.hostId !== currentUserId) { + throw new Error('방장만 게임을 시작할 수 있습니다') + } + + if (room.currentParticipants < 2) { + throw new Error('최소 2명이 필요합니다') + } + + room.status = 'PLAYING' + + const drawerOrder = room.participants.map(p => p.userId) + const randomWord = mockWords[Math.floor(Math.random() * mockWords.length)] + + return { + success: true, + data: { + gameSessionId: `session-${Date.now()}`, + roomId, + status: 'PLAYING', + currentRound: 1, + totalRounds: room.gameSettings.maxRounds, + currentDrawerId: drawerOrder[0], + drawerOrder, + roundStartTime: Date.now(), + serverTime: Date.now(), + roundDuration: room.gameSettings.roundTimeLimit, + currentWord: drawerOrder[0] === currentUserId ? { word: randomWord } : null, + }, + } + }, + + // 게임 종료 + stopGame: async (roomId) => { + await delay(200) + + const room = mockRooms.find(r => r.roomId === roomId) + if (room) { + room.status = 'FINISHED' + } + + return { + success: true, + data: { + roomId, + status: 'FINISHED', + reason: 'STOPPED', + }, + } + }, + + // 게임 재시작 + restartGame: async (roomId) => { + await delay(300) + + const room = mockRooms.find(r => r.roomId === roomId) + if (!room) { + throw new Error('방을 찾을 수 없습니다') + } + + room.status = 'WAITING' + + return { + success: true, + data: room, + } + }, + + // 랜덤 단어 가져오기 (테스트용) + getRandomWord: () => { + return mockWords[Math.floor(Math.random() * mockWords.length)] + }, +} + +export default mockGameService diff --git a/src/domains/games/services/wordchainService.js b/src/domains/games/services/wordchainService.js new file mode 100644 index 0000000..b63a441 --- /dev/null +++ b/src/domains/games/services/wordchainService.js @@ -0,0 +1,58 @@ +/** + * Word Chain Service - 백엔드 API 연동 + * 영어 끝말잇기 게임 관련 REST API 호출 + */ +import chatApi from '../../../api/chatApi' + +/** + * 끝말잇기 게임 진행 관련 API + */ +export const wordchainService = { + /** + * 게임 시작 + * @param {string} roomId + */ + start: async (roomId) => { + const response = await chatApi.post(`/chat/rooms/${roomId}/wordchain/start`, {}) + return response.data + }, + + /** + * 단어 제출 + * @param {string} roomId + * @param {string} word + */ + submit: async (roomId, word) => { + const response = await chatApi.post(`/chat/rooms/${roomId}/wordchain/submit`, { word }) + return response.data + }, + + /** + * 타임아웃 처리 + * @param {string} roomId + */ + timeout: async (roomId) => { + const response = await chatApi.post(`/chat/rooms/${roomId}/wordchain/timeout`, {}) + return response.data + }, + + /** + * 게임 중단 + * @param {string} roomId + */ + stop: async (roomId) => { + const response = await chatApi.post(`/chat/rooms/${roomId}/wordchain/stop`, {}) + return response.data + }, + + /** + * 게임 상태 조회 + * @param {string} roomId + */ + getStatus: async (roomId) => { + const response = await chatApi.get(`/chat/rooms/${roomId}/wordchain/status`) + return response.data + }, +} + +export default wordchainService diff --git a/src/domains/games/theme/gameTheme.js b/src/domains/games/theme/gameTheme.js new file mode 100644 index 0000000..6b3f62c --- /dev/null +++ b/src/domains/games/theme/gameTheme.js @@ -0,0 +1,184 @@ +/** + * Game Design System + * 캐치마인드 게임 전용 디자인 토큰 + */ + +export const GAME_COLORS = { + // 브랜드 (청록색 - 사이드바와 통일) + primary: '#06b6d4', + primaryLight: '#22d3ee', + primaryBg: '#ecfeff', + + // 게임 상태 + status: { + waiting: '#10B981', + waitingBg: '#D1FAE5', + playing: '#F59E0B', + playingBg: '#FEF3C7', + finished: '#6B7280', + finishedBg: '#F3F4F6', + }, + + // 정답/오답 + answer: { + correct: '#F59E0B', + correctBg: '#FEF3C7', + correctText: '#92400E', + wrong: '#EF4444', + wrongBg: '#FEE2E2', + }, + + // 타이머 + timer: { + normal: '#10B981', + warning: '#F59E0B', + danger: '#EF4444', + }, + + // 캔버스 + canvas: { + border: '#E5E7EB', + borderActive: '#10B981', + borderDanger: '#EF4444', + background: '#FFFFFF', + }, + + // 비눗방울 + bubble: { + normal: 'linear-gradient(135deg, rgba(224,242,254,0.95) 0%, rgba(186,230,253,0.9) 100%)', + correct: 'linear-gradient(135deg, rgba(254,243,199,0.95) 0%, rgba(253,230,138,0.9) 100%)', + }, + + // 순위 + rank: { + gold: '#F59E0B', + silver: '#9CA3AF', + bronze: '#CD7F32', + }, +} + +export const GAME_TYPOGRAPHY = { + word: { + fontFamily: 'Pretendard, -apple-system, sans-serif', + fontWeight: 700, + fontSize: '28px', + }, + timer: { + fontFamily: '"JetBrains Mono", "Fira Code", monospace', + fontWeight: 600, + fontSize: '24px', + }, + score: { + fontFamily: '"JetBrains Mono", "Fira Code", monospace', + fontWeight: 700, + fontSize: '16px', + }, + chat: { + fontFamily: 'Pretendard, -apple-system, sans-serif', + fontWeight: 400, + fontSize: '14px', + }, +} + +export const GAME_LAYOUT = { + splitView: { + canvas: '65%', + chat: '35%', + }, + canvas: { + width: 640, + height: 320, + }, + spacing: { + header: 36, + toolbar: 32, + gap: 6, + }, +} + +export const GAME_ANIMATIONS = { + bubble: { + duration: '3s', + easing: 'ease-out', + }, + timerPulse: { + duration: '1s', + easing: 'ease-in-out', + }, + transition: { + duration: '0.3s', + easing: 'cubic-bezier(0.4, 0, 0.2, 1)', + }, +} + +// MUI sx props에서 사용할 수 있는 스타일 객체들 +export const gameStyles = { + // 게임 상태 뱃지 + statusBadge: (status) => ({ + px: 1.5, + py: 0.5, + borderRadius: '12px', + fontSize: '0.75rem', + fontWeight: 600, + bgcolor: GAME_COLORS.status[`${status}Bg`] || GAME_COLORS.status.waitingBg, + color: GAME_COLORS.status[status] || GAME_COLORS.status.waiting, + }), + + // 타이머 스타일 + timer: (seconds) => ({ + fontFamily: GAME_TYPOGRAPHY.timer.fontFamily, + fontWeight: GAME_TYPOGRAPHY.timer.fontWeight, + fontSize: GAME_TYPOGRAPHY.timer.fontSize, + color: seconds <= 10 + ? GAME_COLORS.timer.danger + : seconds <= 30 + ? GAME_COLORS.timer.warning + : GAME_COLORS.timer.normal, + animation: seconds <= 10 ? 'pulse 1s ease-in-out infinite' : 'none', + }), + + // 캔버스 컨테이너 + canvasContainer: (isActive, isDanger) => ({ + border: '3px solid', + borderColor: isDanger + ? GAME_COLORS.canvas.borderDanger + : isActive + ? GAME_COLORS.canvas.borderActive + : GAME_COLORS.canvas.border, + borderRadius: '12px', + overflow: 'hidden', + bgcolor: GAME_COLORS.canvas.background, + transition: `border-color ${GAME_ANIMATIONS.transition.duration} ${GAME_ANIMATIONS.transition.easing}`, + }), + + // 정답 메시지 + correctMessage: { + bgcolor: GAME_COLORS.answer.correctBg, + color: GAME_COLORS.answer.correctText, + fontWeight: 700, + borderRadius: '8px', + px: 1.5, + py: 0.75, + }, + + // 순위 아이콘 + rankIcon: (rank) => { + const colors = { + 1: GAME_COLORS.rank.gold, + 2: GAME_COLORS.rank.silver, + 3: GAME_COLORS.rank.bronze, + } + return { + color: colors[rank] || 'text.secondary', + fontWeight: 700, + } + }, +} + +export default { + GAME_COLORS, + GAME_TYPOGRAPHY, + GAME_LAYOUT, + GAME_ANIMATIONS, + gameStyles, +} diff --git a/src/domains/grammar/components/ChatInput.jsx b/src/domains/grammar/components/ChatInput.jsx index de97179..9a8110f 100644 --- a/src/domains/grammar/components/ChatInput.jsx +++ b/src/domains/grammar/components/ChatInput.jsx @@ -2,10 +2,13 @@ import {useState} from 'react' import {Box, CircularProgress, FormControl, IconButton, MenuItem, Select, TextField, Tooltip,} from '@mui/material' import {School as SchoolIcon, Send as SendIcon} from '@mui/icons-material' import {useSettings} from '../../../contexts/SettingsContext' +import {useThemeMode} from '../../../contexts/ThemeContext' import {GRAMMAR_LEVEL_COLORS, GRAMMAR_LEVELS, TEXT_LIMITS,} from '../constants/grammarConstants' export default function ChatInput({onSend, loading = false, level, onLevelChange}) { const {t, isKorean} = useSettings() + const {mode} = useThemeMode() + const isDark = mode === 'dark' const [message, setMessage] = useState('') const handleSend = () => { @@ -34,8 +37,8 @@ export default function ChatInput({onSend, loading = false, level, onLevelChange sx={{ p: 2, borderTop: '1px solid', - borderColor: 'divider', - backgroundColor: '#fff', + borderColor: isDark ? '#3f3f46' : 'divider', + backgroundColor: isDark ? '#27272a' : '#fff', }} > ))} + + {/* Feedback */} + {feedback && ( + + + 💡 {feedback} + + + )} @@ -186,7 +208,7 @@ export default function ChatMessage({ width: 36, height: 36, borderRadius: '50%', - backgroundColor: '#e5e7eb', + backgroundColor: isDark ? '#3f3f46' : '#e5e7eb', display: 'flex', alignItems: 'center', justifyContent: 'center', @@ -194,7 +216,7 @@ export default function ChatMessage({ flexShrink: 0, }} > - + ) @@ -234,14 +256,14 @@ export default function ChatMessage({ sx={{ p: 2, borderRadius: '20px 20px 20px 4px', - backgroundColor: '#f3f4f6', - border: '1px solid #e5e7eb', + backgroundColor: isDark ? '#3f3f46' : '#f3f4f6', + border: `1px solid ${isDark ? '#52525b' : '#e5e7eb'}`, minHeight: isStreaming ? 48 : 'auto', }} > {displayText} {/* 스트리밍 커서 */} diff --git a/src/domains/grammar/components/GrammarInput.jsx b/src/domains/grammar/components/GrammarInput.jsx index b87871b..cebac31 100644 --- a/src/domains/grammar/components/GrammarInput.jsx +++ b/src/domains/grammar/components/GrammarInput.jsx @@ -11,6 +11,7 @@ import { } from '@mui/material' import {School as SchoolIcon, Spellcheck as SpellcheckIcon,} from '@mui/icons-material' import {useSettings} from '../../../contexts/SettingsContext' +import {useThemeMode} from '../../../contexts/ThemeContext' import { GRAMMAR_LEVEL_BG_COLORS, GRAMMAR_LEVEL_COLORS, @@ -20,6 +21,8 @@ import { export default function GrammarInput({onCheck, loading = false}) { const {t, isKorean} = useSettings() + const {mode} = useThemeMode() + const isDark = mode === 'dark' const [sentence, setSentence] = useState('') const [level, setLevel] = useState(GRAMMAR_LEVELS.BEGINNER) const [error, setError] = useState('') @@ -208,7 +211,7 @@ export default function GrammarInput({onCheck, loading = false}) { fontSize: '1.1rem', lineHeight: 1.7, fontFamily: '"DM Sans", sans-serif', - backgroundColor: '#fff', + backgroundColor: isDark ? '#27272a' : '#fff', '&:hover fieldset': { borderColor: GRAMMAR_LEVEL_COLORS[level], }, @@ -219,7 +222,7 @@ export default function GrammarInput({onCheck, loading = false}) { }, '& .MuiInputBase-input': { '&::placeholder': { - color: '#9ca3af', + color: isDark ? '#a1a1aa' : '#9ca3af', opacity: 1, }, }, @@ -236,7 +239,7 @@ export default function GrammarInput({onCheck, loading = false}) { @@ -272,8 +275,8 @@ export default function GrammarInput({onCheck, loading = false}) { boxShadow: `0 12px 28px -4px ${GRAMMAR_LEVEL_COLORS[level]}50`, }, '&:disabled': { - background: '#e5e7eb', - color: '#9ca3af', + background: isDark ? '#3f3f46' : '#e5e7eb', + color: isDark ? '#a1a1aa' : '#9ca3af', boxShadow: 'none', }, }} diff --git a/src/domains/grammar/components/GrammarResult.jsx b/src/domains/grammar/components/GrammarResult.jsx index 9cfbddb..a82567f 100644 --- a/src/domains/grammar/components/GrammarResult.jsx +++ b/src/domains/grammar/components/GrammarResult.jsx @@ -8,6 +8,7 @@ import { } from '@mui/icons-material' import {useState} from 'react' import {useSettings} from '../../../contexts/SettingsContext' +import {useThemeMode} from '../../../contexts/ThemeContext' import { getScoreColor, getScoreGrade, @@ -17,6 +18,8 @@ import { export default function GrammarResult({result}) { const {t, isKorean} = useSettings() + const {mode} = useThemeMode() + const isDark = mode === 'dark' const [expandedError, setExpandedError] = useState(null) if (!result) return null @@ -182,7 +185,7 @@ export default function GrammarResult({result}) { flexDirection: 'column', alignItems: 'center', justifyContent: 'center', - backgroundColor: '#fff', + backgroundColor: isDark ? '#27272a' : '#fff', }} > @@ -335,7 +338,7 @@ export default function GrammarResult({result}) { > {error.original} - + {error.corrected} diff --git a/src/domains/grammar/components/SessionSidebar.jsx b/src/domains/grammar/components/SessionSidebar.jsx index 5282ba6..34b258f 100644 --- a/src/domains/grammar/components/SessionSidebar.jsx +++ b/src/domains/grammar/components/SessionSidebar.jsx @@ -17,6 +17,7 @@ import { } from '@mui/material' import {Add as AddIcon, Chat as ChatIcon, Delete as DeleteIcon, History as HistoryIcon,} from '@mui/icons-material' import {useSettings} from '../../../contexts/SettingsContext' +import {useThemeMode} from '../../../contexts/ThemeContext' import {GRAMMAR_LEVEL_BG_COLORS, GRAMMAR_LEVEL_COLORS} from '../constants/grammarConstants' export default function SessionSidebar({ @@ -28,6 +29,8 @@ export default function SessionSidebar({ loading = false, }) { const {t, isKorean} = useSettings() + const {mode} = useThemeMode() + const isDark = mode === 'dark' const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [sessionToDelete, setSessionToDelete] = useState(null) @@ -78,8 +81,8 @@ export default function SessionSidebar({ display: 'flex', flexDirection: 'column', borderRight: '1px solid', - borderColor: 'divider', - backgroundColor: '#fafafa', + borderColor: isDark ? '#3f3f46' : 'divider', + backgroundColor: isDark ? '#27272a' : '#fafafa', }} > {/* Header */} @@ -164,14 +167,15 @@ export default function SessionSidebar({ }, }, '&:hover': { - backgroundColor: '#f3f4f6', + backgroundColor: isDark ? '#3f3f46' : '#f3f4f6', }, }} > + {session.lastMessage || `${session.messageCount} ${isKorean ? '메시지' : 'messages'}`} - + {formatDate(session.updatedAt)} diff --git a/src/domains/grammar/pages/WritingPage.jsx b/src/domains/grammar/pages/WritingPage.jsx index 1f479ef..40c2cd1 100644 --- a/src/domains/grammar/pages/WritingPage.jsx +++ b/src/domains/grammar/pages/WritingPage.jsx @@ -2,6 +2,7 @@ import {useCallback, useEffect, useRef, useState} from 'react' import {Alert, Box, Drawer, IconButton, Typography, useMediaQuery, useTheme,} from '@mui/material' import {Edit as EditIcon, Menu as MenuIcon, SmartToy as AiIcon,} from '@mui/icons-material' import {useSettings} from '../../../contexts/SettingsContext' +import {useThemeMode} from '../../../contexts/ThemeContext' import ChatMessage from '../components/ChatMessage' import ChatInput from '../components/ChatInput' import SessionSidebar from '../components/SessionSidebar' @@ -12,6 +13,8 @@ import {GRAMMAR_LEVELS} from '../constants/grammarConstants' export default function WritingPage() { const {t, isKorean} = useSettings() const theme = useTheme() + const {mode} = useThemeMode() + const isDark = mode === 'dark' const isMobile = useMediaQuery(theme.breakpoints.down('md')) const [sidebarOpen, setSidebarOpen] = useState(!isMobile) @@ -124,17 +127,22 @@ export default function WritingPage() { if (response.session) { setLevel(response.session.level || GRAMMAR_LEVELS.BEGINNER) } - // Convert messages to our format - const formattedMessages = (response.messages || []).map((msg) => ({ - id: msg.messageId, - content: msg.content, - correctedContent: msg.correctedContent, - grammarScore: msg.grammarScore, - errors: msg.errorsJson ? JSON.parse(msg.errorsJson) : [], - aiResponse: msg.role === 'ASSISTANT' ? msg.content : null, - isUser: msg.role === 'USER', - createdAt: msg.createdAt, - })) + // Convert messages to our format and sort by createdAt (oldest first) + const formattedMessages = (response.messages || []) + .map((msg) => ({ + id: msg.messageId, + content: msg.content, + correctedContent: msg.correctedContent, + grammarScore: msg.grammarScore, + errors: msg.errors || (msg.errorsJson ? JSON.parse(msg.errorsJson) : []), + feedback: msg.feedback || null, + isCorrect: msg.isCorrect, + aiResponse: msg.role === 'ASSISTANT' ? msg.content : null, + conversationTip: msg.conversationTip || null, + isUser: msg.role === 'USER', + createdAt: msg.createdAt, + })) + .sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)) setMessages(formattedMessages) } catch (err) { console.error('Failed to load session messages:', err) @@ -221,10 +229,10 @@ export default function WritingPage() { height: 'calc(100vh - 120px)', display: 'flex', overflow: 'hidden', - backgroundColor: '#fff', + backgroundColor: isDark ? '#18181b' : '#fff', borderRadius: {xs: 0, md: '20px'}, border: {xs: 'none', md: '1px solid'}, - borderColor: 'divider', + borderColor: isDark ? '#3f3f46' : 'divider', mx: {xs: 0, md: 3}, my: {xs: 0, md: 2}, }} @@ -260,11 +268,11 @@ export default function WritingPage() { sx={{ p: 2, borderBottom: '1px solid', - borderColor: 'divider', + borderColor: isDark ? '#3f3f46' : 'divider', display: 'flex', alignItems: 'center', gap: 2, - backgroundColor: '#fafafa', + backgroundColor: isDark ? '#27272a' : '#fafafa', }} > setSidebarOpen(!sidebarOpen)} sx={{mr: 0.5}}> @@ -302,7 +310,7 @@ export default function WritingPage() { flex: 1, overflow: 'auto', py: 2, - backgroundColor: '#fff', + backgroundColor: isDark ? '#18181b' : '#fff', }} > {error && ( @@ -355,7 +363,7 @@ export default function WritingPage() { mt: 4, p: 2, borderRadius: '12px', - backgroundColor: '#f3f4f6', + backgroundColor: isDark ? '#3f3f46' : '#f3f4f6', maxWidth: 400, }} > diff --git a/src/domains/grammar/services/grammarService.js b/src/domains/grammar/services/grammarService.js index 30de7fa..cfc1f00 100644 --- a/src/domains/grammar/services/grammarService.js +++ b/src/domains/grammar/services/grammarService.js @@ -1,7 +1,7 @@ import grammarApi from '../../../api/grammarApi' -// Mock 데이터 사용 여부 (true: 목 데이터 사용, false: 실제 API 호출) -const USE_MOCK = true +// Mock 데이터 사용 여부 (환경변수로 제어: VITE_USE_MOCK=true) +const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true' // ============================================ // Mock 데이터 @@ -116,7 +116,10 @@ const withMock = (apiCall, mockData) => { setTimeout(() => resolve(mockData), 800) }) } - return apiCall().catch(() => mockData) + // 실제 API 호출 시 응답의 data 필드 추출 (백엔드 응답: { isSuccess, message, data }) + return apiCall() + .then(response => response.data || response) + .catch(() => mockData) } /** diff --git a/src/domains/grammar/services/grammarStreamService.js b/src/domains/grammar/services/grammarStreamService.js index 0d311da..ce0c6a1 100644 --- a/src/domains/grammar/services/grammarStreamService.js +++ b/src/domains/grammar/services/grammarStreamService.js @@ -3,12 +3,11 @@ * 실시간 토큰 단위 AI 응답을 위한 WebSocket 서비스 */ -// WebSocket URL - 환경변수에서 가져오거나 기본값 사용 -const WS_URL = import.meta.env.VITE_GRAMMAR_WS_URL || - 'wss://placeholder.execute-api.ap-northeast-2.amazonaws.com/dev' +// WebSocket URL - 환경변수에서 가져옴 +const WS_URL = import.meta.env.VITE_GRAMMAR_WS_URL -// Mock 모드 (WebSocket 서버가 없을 때 테스트용) -const USE_MOCK = true +// Mock 모드 (환경변수로 제어: VITE_USE_MOCK=true) +const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true' const MOCK_DELAY = 50 // 토큰 간 딜레이 (ms) /** diff --git a/src/domains/news/index.js b/src/domains/news/index.js new file mode 100644 index 0000000..655c41a --- /dev/null +++ b/src/domains/news/index.js @@ -0,0 +1,9 @@ +// Pages +export { default as NewsListPage } from './pages/NewsListPage' +export { default as NewsDetailPage } from './pages/NewsDetailPage' +export { default as NewsQuizPage } from './pages/NewsQuizPage' +export { default as NewsWordsPage } from './pages/NewsWordsPage' +export { default as NewsStatsPage } from './pages/NewsStatsPage' + +// Services +export * from './services/newsService' diff --git a/src/domains/news/pages/NewsDetailPage.jsx b/src/domains/news/pages/NewsDetailPage.jsx new file mode 100644 index 0000000..a7727f8 --- /dev/null +++ b/src/domains/news/pages/NewsDetailPage.jsx @@ -0,0 +1,644 @@ +import { useState, useEffect, useRef } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Container, + Divider, + IconButton, + Skeleton, + Snackbar, + Alert, + Tooltip, + Typography, +} from '@mui/material' +import { + ArrowBack as BackIcon, + Bookmark as BookmarkIcon, + BookmarkBorder as BookmarkBorderIcon, + CheckCircle as CheckIcon, + OpenInNew as ExternalIcon, + Pause as PauseIcon, + PlayArrow as PlayIcon, + Quiz as QuizIcon, + VolumeUp as VolumeIcon, + Add as AddIcon, +} from '@mui/icons-material' +import { useSettings } from '../../../contexts/SettingsContext' +import { + getNewsDetail, + markAsRead, + toggleBookmark, + getAudioUrl, + collectWord, + LEVEL_COLORS, + NEWS_CATEGORIES, + TTS_VOICES, +} from '../services/newsService' + +const NewsDetailPage = () => { + const { articleId } = useParams() + const navigate = useNavigate() + const { isKorean } = useSettings() + const audioRef = useRef(null) + + const [article, setArticle] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // States + const [isBookmarked, setIsBookmarked] = useState(false) + const [isRead, setIsRead] = useState(false) + const [isPlaying, setIsPlaying] = useState(false) + const [audioLoading, setAudioLoading] = useState(false) + const [selectedVoice, setSelectedVoice] = useState('Joanna') + const [collectedWords, setCollectedWords] = useState(new Set()) + const [snackbar, setSnackbar] = useState({ open: false, message: '', severity: 'success' }) + + useEffect(() => { + const fetchArticle = async () => { + try { + setLoading(true) + const response = await getNewsDetail(articleId) + console.log('📰 Article Response:', response) + console.log('📰 Article Data:', response.data) + + // 새 API 응답 구조: data.article (중첩) 또는 기존 data 직접 + const articleData = response.data?.article || response.data + setArticle(articleData) + + // 북마크/읽음 상태 초기화 (새 API에서 제공) + if (response.data?.isBookmarked !== undefined) { + setIsBookmarked(response.data.isBookmarked) + } + if (response.data?.isRead !== undefined) { + setIsRead(response.data.isRead) + } + + setError(null) + } catch (err) { + console.error('Failed to fetch article:', err) + setError(err.message) + } finally { + setLoading(false) + } + } + + if (articleId) { + fetchArticle() + } + }, [articleId]) + + const handleBookmark = async () => { + try { + await toggleBookmark(articleId) + setIsBookmarked(!isBookmarked) + setSnackbar({ + open: true, + message: isBookmarked + ? (isKorean ? '북마크가 해제되었습니다' : 'Bookmark removed') + : (isKorean ? '북마크에 추가되었습니다' : 'Added to bookmarks'), + severity: 'success', + }) + } catch (err) { + console.error('Failed to toggle bookmark:', err) + } + } + + const handleMarkAsRead = async () => { + if (isRead) return + try { + const response = await markAsRead(articleId) + setIsRead(true) + + // 새 뱃지 획득 시 알림 + if (response.data?.newBadges?.length > 0) { + setSnackbar({ + open: true, + message: `${isKorean ? '새 뱃지 획득!' : 'New badge earned!'} ${response.data.newBadges[0].name}`, + severity: 'success', + }) + } else { + setSnackbar({ + open: true, + message: isKorean ? '읽기 완료!' : 'Read complete!', + severity: 'success', + }) + } + } catch (err) { + console.error('Failed to mark as read:', err) + } + } + + const handlePlayAudio = async () => { + if (isPlaying && audioRef.current) { + audioRef.current.pause() + setIsPlaying(false) + return + } + + try { + setAudioLoading(true) + const response = await getAudioUrl(articleId, selectedVoice) + const audioUrl = response.data?.audioUrl + + if (audioUrl) { + if (!audioRef.current) { + audioRef.current = new Audio() + } + audioRef.current.src = audioUrl + audioRef.current.onended = () => setIsPlaying(false) + await audioRef.current.play() + setIsPlaying(true) + } + } catch (err) { + console.error('Failed to play audio:', err) + setSnackbar({ + open: true, + message: isKorean ? '오디오 재생 실패' : 'Failed to play audio', + severity: 'error', + }) + } finally { + setAudioLoading(false) + } + } + + const handleCollectWord = async (keyword) => { + if (collectedWords.has(keyword.word)) return + + try { + // 단어 수집 - 백엔드에서 자동으로 단어장에 NEWS 카테고리로 추가됨 + const response = await collectWord( + articleId, + keyword.word, + `From article: ${article.title}` + ) + setCollectedWords(prev => new Set([...prev, keyword.word])) + + if (response.data?.newBadges?.length > 0) { + setSnackbar({ + open: true, + message: `${isKorean ? '새 뱃지 획득!' : 'New badge earned!'} ${response.data.newBadges[0].name}`, + severity: 'success', + }) + } else { + setSnackbar({ + open: true, + message: isKorean ? '단어가 수집되었습니다' : 'Word collected', + severity: 'success', + }) + } + } catch (err) { + console.error('Failed to collect word:', err) + setSnackbar({ + open: true, + message: isKorean ? '이미 수집된 단어입니다' : 'Word already collected', + severity: 'warning', + }) + } + } + + const getLevelLabel = (level) => { + const labels = { + BEGINNER: isKorean ? '초급' : 'Beginner', + INTERMEDIATE: isKorean ? '중급' : 'Intermediate', + ADVANCED: isKorean ? '고급' : 'Advanced', + } + return labels[level] || level + } + + const getCategoryLabel = (categoryId) => { + const category = NEWS_CATEGORIES.find(c => c.id === categoryId) + return category ? (isKorean ? category.label : category.labelEn) : categoryId + } + + const getCategoryColor = (categoryId) => { + const category = NEWS_CATEGORIES.find(c => c.id === categoryId) + return category?.color || '#6b7280' + } + + if (loading) { + return ( + + + + + + + + ) + } + + if (error || !article) { + return ( + + {error || 'Article not found'} + + + ) + } + + const levelColor = LEVEL_COLORS[article.level] || LEVEL_COLORS.INTERMEDIATE + + return ( + + {/* Top Bar */} + + + + + + + {isBookmarked ? ( + + ) : ( + + )} + + + + + + {audioLoading ? ( + + ) : isPlaying ? ( + + ) : ( + + )} + + + + + + {/* Tags */} + + + + + + {/* Title */} + + {article.title || (isKorean ? '제목 없음' : 'No Title')} + + + {/* Source & Date */} + + {article.source || 'Unknown'} | {article.publishedAt ? new Date(article.publishedAt).toLocaleDateString() : (isKorean ? '날짜 없음' : 'No Date')} + + + {/* Image */} + {article.imageUrl && ( + + )} + + {/* Summary */} + {article.summary && ( + + + + {isKorean ? '요약' : 'Summary'} + + + {article.summary} + + + + )} + + + + {/* Keywords - 단어 카드 (keywords 배열 사용) */} + {article.keywords && article.keywords.length > 0 && ( + + + + + {isKorean ? '핵심 단어' : 'Key Vocabulary'} + + + + + {article.keywords.map((keyword, index) => { + const isCollected = collectedWords.has(keyword.word) + const keywordLevelColor = LEVEL_COLORS[keyword.level] || LEVEL_COLORS.INTERMEDIATE + + return ( + + {/* 난이도 뱃지 (카드 우상단) */} + + + {/* 수집 완료 표시 */} + {isCollected && ( + + + + )} + + + {/* 단어 */} + + {keyword.word} + + + {/* 뜻 */} + + {isKorean && keyword.meaningKo ? keyword.meaningKo : keyword.meaning} + + + {/* 영어 정의 (한국어 모드일 때) */} + {isKorean && keyword.meaningKo && keyword.meaning && ( + + {keyword.meaning} + + )} + + {/* 예문 (있을 경우) */} + {keyword.example && ( + + + "{keyword.example}" + + + )} + + {/* 수집 버튼 */} + !isCollected && handleCollectWord(keyword)} + sx={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: 0.5, + py: 1, + borderRadius: '8px', + cursor: isCollected ? 'default' : 'pointer', + backgroundColor: isCollected ? '#D1FAE5' : '#F3F4F6', + color: isCollected ? '#059669' : '#6B7280', + transition: 'all 0.2s ease', + '&:hover': !isCollected && { + backgroundColor: '#E5E7EB', + }, + }} + > + {isCollected ? ( + <> + + + {isKorean ? '수집됨' : 'Collected'} + + + ) : ( + <> + + + {isKorean ? '단어 수집' : 'Collect'} + + + )} + + + + ) + })} + + + )} + + + + {/* Actions */} + + + + + + + {/* Read Complete Button */} + + + {/* Snackbar */} + setSnackbar(prev => ({ ...prev, open: false }))} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + + {snackbar.message} + + + + ) +} + +export default NewsDetailPage diff --git a/src/domains/news/pages/NewsListPage.jsx b/src/domains/news/pages/NewsListPage.jsx new file mode 100644 index 0000000..5d70748 --- /dev/null +++ b/src/domains/news/pages/NewsListPage.jsx @@ -0,0 +1,435 @@ +import { useState, useEffect, useCallback } from 'react' +import { useNavigate } from 'react-router-dom' +import { + Box, + Card, + CardContent, + CardMedia, + Chip, + CircularProgress, + Container, + FormControl, + Grid, + IconButton, + InputAdornment, + MenuItem, + Select, + Skeleton, + TextField, + Typography, +} from '@mui/material' +import { + Bookmark as BookmarkIcon, + BookmarkBorder as BookmarkBorderIcon, + Newspaper as NewsIcon, + Search as SearchIcon, + Visibility as ViewIcon, +} from '@mui/icons-material' +import { useSettings } from '../../../contexts/SettingsContext' +import { getNewsList, NEWS_CATEGORIES, LEVEL_COLORS, toggleBookmark } from '../services/newsService' + +const NewsListPage = () => { + const navigate = useNavigate() + const { t, isKorean } = useSettings() + + const [articles, setArticles] = useState([]) + const [loading, setLoading] = useState(true) + const [loadingMore, setLoadingMore] = useState(false) + const [hasMore, setHasMore] = useState(true) + const [cursor, setCursor] = useState(null) + const [error, setError] = useState(null) + + // Filters + const [levelFilter, setLevelFilter] = useState('') + const [categoryFilter, setCategoryFilter] = useState('') + const [searchQuery, setSearchQuery] = useState('') + + // 북마크 상태 + const [bookmarkedIds, setBookmarkedIds] = useState(new Set()) + + const fetchNews = useCallback(async (isLoadMore = false) => { + try { + if (isLoadMore) { + setLoadingMore(true) + } else { + setLoading(true) + setCursor(null) + } + + const response = await getNewsList({ + level: levelFilter || undefined, + category: categoryFilter || undefined, + limit: 10, + cursor: isLoadMore ? cursor : undefined, + }) + + const newArticles = response.data?.articles || [] + + // 북마크 상태 초기화 (API에서 isBookmarked 제공) + const bookmarked = new Set() + newArticles.forEach(article => { + if (article.isBookmarked) { + bookmarked.add(article.articleId) + } + }) + + if (isLoadMore) { + setArticles(prev => [...prev, ...newArticles]) + setBookmarkedIds(prev => new Set([...prev, ...bookmarked])) + } else { + setArticles(newArticles) + setBookmarkedIds(bookmarked) + } + + setCursor(response.data?.nextCursor || null) + setHasMore(response.data?.hasMore || false) + setError(null) + } catch (err) { + console.error('Failed to fetch news:', err) + setError(err.message) + } finally { + setLoading(false) + setLoadingMore(false) + } + }, [levelFilter, categoryFilter, cursor]) + + useEffect(() => { + fetchNews(false) + }, [levelFilter, categoryFilter]) + + // 무한 스크롤 + useEffect(() => { + const handleScroll = () => { + if ( + window.innerHeight + document.documentElement.scrollTop + >= document.documentElement.offsetHeight - 100 + && hasMore + && !loadingMore + && !loading + ) { + fetchNews(true) + } + } + + window.addEventListener('scroll', handleScroll) + return () => window.removeEventListener('scroll', handleScroll) + }, [hasMore, loadingMore, loading, fetchNews]) + + const handleBookmark = async (e, articleId) => { + e.stopPropagation() + try { + await toggleBookmark(articleId) + setBookmarkedIds(prev => { + const newSet = new Set(prev) + if (newSet.has(articleId)) { + newSet.delete(articleId) + } else { + newSet.add(articleId) + } + return newSet + }) + } catch (err) { + console.error('Failed to toggle bookmark:', err) + } + } + + const getLevelLabel = (level) => { + const labels = { + BEGINNER: isKorean ? '초급' : 'Beginner', + INTERMEDIATE: isKorean ? '중급' : 'Intermediate', + ADVANCED: isKorean ? '고급' : 'Advanced', + } + return labels[level] || level + } + + const getCategoryLabel = (categoryId) => { + const category = NEWS_CATEGORIES.find(c => c.id === categoryId) + return category ? (isKorean ? category.label : category.labelEn) : categoryId + } + + const getCategoryColor = (categoryId) => { + const category = NEWS_CATEGORIES.find(c => c.id === categoryId) + return category?.color || '#6b7280' + } + + const filteredArticles = articles.filter(article => { + if (!searchQuery) return true + return ( + article.title?.toLowerCase().includes(searchQuery.toLowerCase()) || + article.summary?.toLowerCase().includes(searchQuery.toLowerCase()) + ) + }) + + const formatDate = (dateString) => { + const date = new Date(dateString) + const now = new Date() + const diff = now - date + const hours = Math.floor(diff / (1000 * 60 * 60)) + const days = Math.floor(hours / 24) + + if (hours < 1) return isKorean ? '방금 전' : 'Just now' + if (hours < 24) return isKorean ? `${hours}시간 전` : `${hours}h ago` + if (days < 7) return isKorean ? `${days}일 전` : `${days}d ago` + return date.toLocaleDateString() + } + + return ( + + {/* Header */} + + + + + + + + {isKorean ? '뉴스 영어 학습' : 'News English Learning'} + + + {isKorean ? '실제 뉴스로 영어 실력을 키워보세요' : 'Improve your English with real news'} + + + + + + {/* Filters */} + + + + + + + + + + setSearchQuery(e.target.value)} + sx={{ + flex: 1, + minWidth: 200, + '& .MuiOutlinedInput-root': { borderRadius: '12px' }, + }} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + {/* Error State */} + {error && ( + + {error} + + )} + + {/* News Grid */} + + {loading ? ( + // Skeleton loading + [...Array(6)].map((_, i) => ( + + + + + + + + + + + )) + ) : filteredArticles.length === 0 ? ( + + + + + {isKorean ? '뉴스가 없습니다' : 'No news articles found'} + + + + ) : ( + filteredArticles.map((article) => { + const levelColor = LEVEL_COLORS[article.level] || LEVEL_COLORS.INTERMEDIATE + const isBookmarked = bookmarkedIds.has(article.articleId) + + return ( + + navigate(`/news/${article.articleId}`)} + sx={{ + borderRadius: '16px', + height: '100%', + cursor: 'pointer', + transition: 'all 0.2s ease', + '&:hover': { + transform: 'translateY(-4px)', + boxShadow: '0 12px 24px -8px rgba(0,0,0,0.15)', + }, + }} + > + {/* Image */} + + + + {/* Tags */} + + + + + + {/* Title */} + + {article.title} + + + {/* Summary */} + + {article.summary} + + + {/* Meta */} + + + + {article.source} + + + {formatDate(article.publishedAt)} + + + + + {article.readCount} + + + + + handleBookmark(e, article.articleId)} + sx={{ color: isBookmarked ? '#f97316' : 'text.secondary' }} + > + {isBookmarked ? : } + + + + + + ) + }) + )} + + + {/* Load More Indicator */} + {loadingMore && ( + + + + )} + + {/* No More Items */} + {!hasMore && articles.length > 0 && ( + + + {isKorean ? '모든 뉴스를 불러왔습니다' : 'All news loaded'} + + + )} + + ) +} + +export default NewsListPage diff --git a/src/domains/news/pages/NewsQuizPage.jsx b/src/domains/news/pages/NewsQuizPage.jsx new file mode 100644 index 0000000..507f14f --- /dev/null +++ b/src/domains/news/pages/NewsQuizPage.jsx @@ -0,0 +1,463 @@ +import { useState, useEffect, useRef } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Container, + LinearProgress, + Typography, +} from '@mui/material' +import { + ArrowBack as BackIcon, + ArrowForward as NextIcon, + CheckCircle as CorrectIcon, + Cancel as WrongIcon, + Timer as TimerIcon, +} from '@mui/icons-material' +import { useSettings } from '../../../contexts/SettingsContext' +import { getQuiz, submitQuiz } from '../services/newsService' + +const QUIZ_TYPE_LABELS = { + COMPREHENSION: { ko: '독해력', en: 'Comprehension' }, + WORD_MATCH: { ko: '단어 매칭', en: 'Word Match' }, + FILL_BLANK: { ko: '빈칸 채우기', en: 'Fill Blank' }, +} + +const NewsQuizPage = () => { + const { articleId } = useParams() + const navigate = useNavigate() + const { isKorean } = useSettings() + const timerRef = useRef(null) + + const [quizData, setQuizData] = useState(null) + const [loading, setLoading] = useState(true) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + const [currentQuestion, setCurrentQuestion] = useState(0) + const [answers, setAnswers] = useState({}) + const [selectedAnswer, setSelectedAnswer] = useState(null) + const [showFeedback, setShowFeedback] = useState(false) + const [timeElapsed, setTimeElapsed] = useState(0) + const [quizResult, setQuizResult] = useState(null) + + useEffect(() => { + const fetchQuiz = async () => { + try { + setLoading(true) + const response = await getQuiz(articleId) + setQuizData(response.data) + setError(null) + } catch (err) { + console.error('Failed to fetch quiz:', err) + setError(err.message) + } finally { + setLoading(false) + } + } + + if (articleId) { + fetchQuiz() + } + }, [articleId]) + + // Timer + useEffect(() => { + if (!loading && !quizResult) { + timerRef.current = setInterval(() => { + setTimeElapsed(prev => prev + 1) + }, 1000) + } + + return () => { + if (timerRef.current) { + clearInterval(timerRef.current) + } + } + }, [loading, quizResult]) + + const handleSelectAnswer = (answer) => { + if (showFeedback) return + setSelectedAnswer(answer) + } + + const handleNext = () => { + if (!selectedAnswer) return + + const question = quizData.questions[currentQuestion] + setAnswers(prev => ({ + ...prev, + [question.questionId]: selectedAnswer, + })) + + if (currentQuestion < quizData.questions.length - 1) { + setCurrentQuestion(prev => prev + 1) + setSelectedAnswer(answers[quizData.questions[currentQuestion + 1]?.questionId] || null) + setShowFeedback(false) + } + } + + const handleSubmit = async () => { + if (!selectedAnswer) return + + const question = quizData.questions[currentQuestion] + const finalAnswers = { + ...answers, + [question.questionId]: selectedAnswer, + } + + const answersArray = Object.entries(finalAnswers).map(([questionId, answer]) => ({ + questionId, + answer, + })) + + try { + setSubmitting(true) + clearInterval(timerRef.current) + + const response = await submitQuiz(articleId, answersArray, timeElapsed) + setQuizResult(response.data) + } catch (err) { + console.error('Failed to submit quiz:', err) + setError(err.message) + } finally { + setSubmitting(false) + } + } + + const formatTime = (seconds) => { + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` + } + + if (loading) { + return ( + + + + ) + } + + if (error || !quizData) { + return ( + + {error || 'Quiz not found'} + + + ) + } + + // 퀴즈 결과 화면 + if (quizResult) { + return ( + + + + {isKorean ? '퀴즈 완료!' : 'Quiz Complete!'} + + + {/* Score Circle */} + = 80 + ? 'linear-gradient(135deg, #10B981 0%, #34D399 100%)' + : quizResult.score >= 60 + ? 'linear-gradient(135deg, #F59E0B 0%, #FBBF24 100%)' + : 'linear-gradient(135deg, #EF4444 0%, #F87171 100%)', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + mx: 'auto', + my: 4, + boxShadow: '0 12px 24px -8px rgba(0,0,0,0.2)', + }} + > + + {quizResult.score} + + + / {quizResult.totalPoints} + + + + {/* Stats */} + + + + {quizResult.results.filter(r => r.correct).length} + + + {isKorean ? '정답' : 'Correct'} + + + + + {quizResult.results.filter(r => !r.correct).length} + + + {isKorean ? '오답' : 'Incorrect'} + + + + + {formatTime(timeElapsed)} + + + {isKorean ? '소요 시간' : 'Time'} + + + + + {/* New Badges */} + {quizResult.newBadges?.length > 0 && ( + + + + {isKorean ? '새 뱃지 획득!' : 'New Badge Earned!'} + + {quizResult.newBadges.map((badge, index) => ( + + 🏆 + + {badge.name} + + {badge.description} + + + + ))} + + + )} + + + {/* Results Detail */} + + {isKorean ? '문제별 결과' : 'Question Results'} + + + {quizResult.results.map((result, index) => ( + + + {result.correct ? ( + + ) : ( + + )} + + + Q{index + 1} + + + {result.correctAnswer} + + {!result.correct && result.userAnswer && ( + + {isKorean ? '내 답:' : 'Your answer:'} {result.userAnswer} + + )} + + + + ))} + + + {/* Actions */} + + + + + + ) + } + + const question = quizData.questions[currentQuestion] + const progress = ((currentQuestion + 1) / quizData.questions.length) * 100 + const earnedPoints = Object.keys(answers).length * 20 + + return ( + + {/* Header */} + + + + + {currentQuestion + 1} / {quizData.questions.length} + + + + + {formatTime(timeElapsed)} + + + + {/* Progress */} + + + + + + {earnedPoints} / {quizData.totalPoints} {isKorean ? '점' : 'pts'} + + + + {/* Question */} + + + + + + {question.question} + + + {/* Options */} + + {question.options.map((option, index) => { + const isSelected = selectedAnswer === option + const optionLabels = ['A', 'B', 'C', 'D'] + + return ( + handleSelectAnswer(option)} + sx={{ + borderRadius: '12px', + cursor: 'pointer', + border: '2px solid', + borderColor: isSelected ? '#3B82F6' : 'transparent', + backgroundColor: isSelected ? '#EFF6FF' : '#F9FAFB', + transition: 'all 0.2s ease', + '&:hover': { + borderColor: isSelected ? '#3B82F6' : '#E5E7EB', + backgroundColor: isSelected ? '#EFF6FF' : '#F3F4F6', + }, + }} + > + + + {optionLabels[index]} + + + {option} + + + + ) + })} + + + + + {/* Navigation */} + + {currentQuestion < quizData.questions.length - 1 ? ( + + ) : ( + + )} + + + ) +} + +export default NewsQuizPage diff --git a/src/domains/news/pages/NewsStatsPage.jsx b/src/domains/news/pages/NewsStatsPage.jsx new file mode 100644 index 0000000..b950f2a --- /dev/null +++ b/src/domains/news/pages/NewsStatsPage.jsx @@ -0,0 +1,308 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { + Box, + Button, + Card, + CardContent, + CircularProgress, + Container, + Grid, + LinearProgress, + Typography, +} from '@mui/material' +import { + ArrowBack as BackIcon, + BarChart as StatsIcon, + LocalFireDepartment as StreakIcon, + MenuBook as ReadIcon, + Quiz as QuizIcon, + LibraryBooks as WordsIcon, + Bookmark as BookmarkIcon, +} from '@mui/icons-material' +import { useSettings } from '../../../contexts/SettingsContext' +import { getNewsStats } from '../services/newsService' + +const NewsStatsPage = () => { + const navigate = useNavigate() + const { isKorean } = useSettings() + + const [stats, setStats] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + const fetchStats = async () => { + try { + setLoading(true) + const response = await getNewsStats() + setStats(response.data) + setError(null) + } catch (err) { + console.error('Failed to fetch stats:', err) + setError(err.message) + } finally { + setLoading(false) + } + } + + fetchStats() + }, []) + + if (loading) { + return ( + + + + ) + } + + if (error) { + return ( + + {error} + + + ) + } + + const statCards = [ + { + icon: ReadIcon, + label: isKorean ? '읽은 기사' : 'Articles Read', + value: stats?.totalRead || 0, + subLabel: isKorean ? `오늘 ${stats?.todayRead || 0}개` : `Today: ${stats?.todayRead || 0}`, + color: '#3B82F6', + bgColor: '#EFF6FF', + }, + { + icon: QuizIcon, + label: isKorean ? '퀴즈 완료' : 'Quizzes Completed', + value: stats?.totalQuizzes || 0, + subLabel: isKorean ? `평균 ${stats?.averageQuizScore || 0}점` : `Avg: ${stats?.averageQuizScore || 0}pts`, + color: '#10B981', + bgColor: '#ECFDF5', + }, + { + icon: WordsIcon, + label: isKorean ? '수집 단어' : 'Words Collected', + value: stats?.totalWordsCollected || 0, + subLabel: isKorean ? '뉴스에서 수집' : 'From news', + color: '#8B5CF6', + bgColor: '#F5F3FF', + }, + { + icon: BookmarkIcon, + label: isKorean ? '북마크' : 'Bookmarks', + value: stats?.bookmarkCount || 0, + subLabel: isKorean ? '저장된 기사' : 'Saved articles', + color: '#F97316', + bgColor: '#FFF7ED', + }, + ] + + return ( + + {/* Header */} + + + + + + + + + + + + {isKorean ? '뉴스 학습 통계' : 'News Learning Stats'} + + + {isKorean ? '나의 학습 현황을 확인하세요' : 'Check your learning progress'} + + + + + + {/* Streak Card */} + + + + + + + {stats?.currentStreak || 0} + + + {isKorean ? '일 연속 학습 중!' : 'Day Streak!'} + + + {isKorean ? `최고 기록: ${stats?.longestStreak || 0}일` : `Best: ${stats?.longestStreak || 0} days`} + + + + + {/* Stats Grid */} + + {statCards.map((stat, index) => { + const Icon = stat.icon + return ( + + + + + + + + {stat.value} + + + {stat.label} + + + {stat.subLabel} + + + + + ) + })} + + + {/* Quiz Performance */} + + + + {isKorean ? '퀴즈 성과' : 'Quiz Performance'} + + + + + + {isKorean ? '평균 점수' : 'Average Score'} + + + {stats?.averageQuizScore || 0}% + + + + + + + + + + {stats?.perfectQuizzes || 0} + + + {isKorean ? '만점 횟수' : 'Perfect Scores'} + + + + + + + {stats?.totalQuizzes || 0} + + + {isKorean ? '총 퀴즈' : 'Total Quizzes'} + + + + + + + + {/* Last Activity */} + {stats?.lastReadDate && ( + + + + {isKorean ? '마지막 학습' : 'Last Activity'} + + + {new Date(stats.lastReadDate).toLocaleDateString()} + + + + )} + + ) +} + +export default NewsStatsPage diff --git a/src/domains/news/pages/NewsWordsPage.jsx b/src/domains/news/pages/NewsWordsPage.jsx new file mode 100644 index 0000000..281108e --- /dev/null +++ b/src/domains/news/pages/NewsWordsPage.jsx @@ -0,0 +1,303 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Container, + IconButton, + Tab, + Tabs, + Typography, + Snackbar, + Alert, +} from '@mui/material' +import { + ArrowBack as BackIcon, + CheckCircle as SyncedIcon, + Delete as DeleteIcon, + Sync as SyncIcon, + LibraryBooks as WordsIcon, +} from '@mui/icons-material' +import { useSettings } from '../../../contexts/SettingsContext' +import { getCollectedWords, deleteCollectedWord, syncToVocab } from '../services/newsService' + +const NewsWordsPage = () => { + const navigate = useNavigate() + const { isKorean } = useSettings() + + const [words, setWords] = useState([]) + const [stats, setStats] = useState({ totalWords: 0, syncedToVocab: 0 }) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [activeTab, setActiveTab] = useState(0) // 0: 전체, 1: 미연동, 2: 연동완료 + const [snackbar, setSnackbar] = useState({ open: false, message: '', severity: 'success' }) + + useEffect(() => { + fetchWords() + }, []) + + const fetchWords = async () => { + try { + setLoading(true) + const response = await getCollectedWords() + setWords(response.data?.words || []) + setStats(response.data?.stats || { totalWords: 0, syncedToVocab: 0 }) + setError(null) + } catch (err) { + console.error('Failed to fetch words:', err) + setError(err.message) + } finally { + setLoading(false) + } + } + + const handleDelete = async (word, articleId) => { + try { + await deleteCollectedWord(articleId, word) + setWords(prev => prev.filter(w => w.word !== word)) + setStats(prev => ({ ...prev, totalWords: prev.totalWords - 1 })) + setSnackbar({ + open: true, + message: isKorean ? '단어가 삭제되었습니다' : 'Word deleted', + severity: 'success', + }) + } catch (err) { + console.error('Failed to delete word:', err) + setSnackbar({ + open: true, + message: isKorean ? '삭제 실패' : 'Failed to delete', + severity: 'error', + }) + } + } + + const handleSync = async (word, articleId) => { + try { + await syncToVocab(word, articleId) + setWords(prev => + prev.map(w => + w.word === word ? { ...w, syncedToVocab: true } : w + ) + ) + setStats(prev => ({ ...prev, syncedToVocab: prev.syncedToVocab + 1 })) + setSnackbar({ + open: true, + message: isKorean ? 'Vocabulary에 연동되었습니다' : 'Synced to Vocabulary', + severity: 'success', + }) + } catch (err) { + console.error('Failed to sync word:', err) + setSnackbar({ + open: true, + message: isKorean ? '연동 실패' : 'Failed to sync', + severity: 'error', + }) + } + } + + const filteredWords = words.filter(word => { + if (activeTab === 0) return true + if (activeTab === 1) return !word.syncedToVocab + if (activeTab === 2) return word.syncedToVocab + return true + }) + + if (loading) { + return ( + + + + ) + } + + return ( + + {/* Header */} + + + + + + + + + + + + {isKorean ? '수집한 단어' : 'Collected Words'} + + + {stats.totalWords}{isKorean ? '개' : ' words'} | {stats.syncedToVocab}{isKorean ? '개 연동됨' : ' synced'} + + + + + + {/* Tabs */} + setActiveTab(newValue)} + sx={{ + mb: 3, + '& .MuiTab-root': { + borderRadius: '12px', + minHeight: 40, + textTransform: 'none', + fontWeight: 600, + }, + }} + > + + + + + + {/* Error State */} + {error && ( + + {error} + + )} + + {/* Empty State */} + {filteredWords.length === 0 && !error && ( + + + + {activeTab === 0 + ? (isKorean ? '수집한 단어가 없습니다' : 'No collected words') + : activeTab === 1 + ? (isKorean ? '미연동 단어가 없습니다' : 'No unsynced words') + : (isKorean ? '연동된 단어가 없습니다' : 'No synced words') + } + + + + )} + + {/* Words List */} + + {filteredWords.map((word, index) => ( + + + + + + + {word.word} + + + {word.pronunciation} + + {word.syncedToVocab && ( + } + label={isKorean ? '연동됨' : 'Synced'} + size="small" + sx={{ + backgroundColor: '#D1FAE5', + color: '#10B981', + fontWeight: 600, + }} + /> + )} + + + {word.meaning} + + + "{word.context}" + + + {isKorean ? '출처:' : 'From:'} {word.articleTitle} + + + + + {!word.syncedToVocab && ( + handleSync(word.word, word.articleId)} + sx={{ + backgroundColor: '#EFF6FF', + '&:hover': { backgroundColor: '#DBEAFE' }, + }} + > + + + )} + handleDelete(word.word, word.articleId)} + sx={{ + backgroundColor: '#FEF2F2', + '&:hover': { backgroundColor: '#FEE2E2' }, + }} + > + + + + + + + ))} + + + {/* Snackbar */} + setSnackbar(prev => ({ ...prev, open: false }))} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + + {snackbar.message} + + + + ) +} + +export default NewsWordsPage diff --git a/src/domains/news/services/newsService.js b/src/domains/news/services/newsService.js new file mode 100644 index 0000000..4a30eee --- /dev/null +++ b/src/domains/news/services/newsService.js @@ -0,0 +1,256 @@ +/** + * News API Service + * 뉴스 영어 학습 관련 API 호출 + */ + +import api from '../../../api/axios' + +/** + * API 요청 헬퍼 - 공통 axios 인스턴스 사용 (토큰 갱신 로직 포함) + */ +const fetchWithAuth = async (endpoint, options = {}) => { + const { method = 'GET', body, ...restOptions } = options + + const config = { + ...restOptions, + } + + if (method === 'GET') { + const response = await api.get(endpoint, config) + return response.data + } else if (method === 'POST') { + const response = await api.post(endpoint, body ? JSON.parse(body) : undefined, config) + return response.data + } else if (method === 'PUT') { + const response = await api.put(endpoint, body ? JSON.parse(body) : undefined, config) + return response.data + } else if (method === 'DELETE') { + const response = await api.delete(endpoint, config) + return response.data + } + + throw new Error(`Unsupported method: ${method}`) +} + +/** + * 뉴스 목록 조회 + */ +export const getNewsList = async ({ level, category, limit = 10, cursor } = {}) => { + const params = new URLSearchParams() + if (level) params.append('level', level) + if (category) params.append('category', category) + if (limit) params.append('limit', limit) + if (cursor) params.append('cursor', cursor) + + const query = params.toString() ? `?${params.toString()}` : '' + return fetchWithAuth(`/news${query}`) +} + +/** + * 오늘의 뉴스 조회 + */ +export const getTodayNews = async ({ limit = 10, cursor } = {}) => { + const params = new URLSearchParams() + if (limit) params.append('limit', limit) + if (cursor) params.append('cursor', cursor) + + const query = params.toString() ? `?${params.toString()}` : '' + return fetchWithAuth(`/news/today${query}`) +} + +/** + * 추천 뉴스 조회 + */ +export const getRecommendedNews = async ({ limit = 10, cursor } = {}) => { + const params = new URLSearchParams() + if (limit) params.append('limit', limit) + if (cursor) params.append('cursor', cursor) + + const query = params.toString() ? `?${params.toString()}` : '' + return fetchWithAuth(`/news/recommended${query}`) +} + +/** + * 뉴스 상세 조회 + */ +export const getNewsDetail = async (articleId) => { + return fetchWithAuth(`/news/${articleId}`) +} + +/** + * 읽기 완료 기록 + */ +export const markAsRead = async (articleId) => { + return fetchWithAuth(`/news/${articleId}/read`, { + method: 'POST', + }) +} + +/** + * 북마크 토글 + */ +export const toggleBookmark = async (articleId) => { + return fetchWithAuth(`/news/${articleId}/bookmark`, { + method: 'POST', + }) +} + +/** + * 북마크 목록 조회 + */ +export const getBookmarks = async ({ limit = 20 } = {}) => { + const params = new URLSearchParams() + if (limit) params.append('limit', limit) + + const query = params.toString() ? `?${params.toString()}` : '' + return fetchWithAuth(`/news/bookmarks${query}`) +} + +/** + * TTS 오디오 URL 조회 + */ +export const getAudioUrl = async (articleId, voice = 'Joanna') => { + const params = new URLSearchParams({ voice }) + return fetchWithAuth(`/news/${articleId}/audio?${params.toString()}`) +} + +/** + * 퀴즈 조회 + */ +export const getQuiz = async (articleId) => { + return fetchWithAuth(`/news/${articleId}/quiz`) +} + +/** + * 퀴즈 제출 + */ +export const submitQuiz = async (articleId, answers, timeTaken) => { + return fetchWithAuth(`/news/${articleId}/quiz`, { + method: 'POST', + body: JSON.stringify({ answers, timeTaken }), + }) +} + +/** + * 퀴즈 기록 조회 + */ +export const getQuizHistory = async () => { + return fetchWithAuth('/news/quiz/history') +} + +/** + * 단어 수집 + * - 단어 수집 시 자동으로 단어장(user-words)에 NEWS 카테고리로 추가됨 + * - meaning, example은 기사 키워드에서 자동 추출됨 + */ +export const collectWord = async (articleId, word, context) => { + return fetchWithAuth(`/news/${articleId}/words`, { + method: 'POST', + body: JSON.stringify({ word, context }), + }) +} + +/** + * 수집 단어 목록 조회 + */ +export const getCollectedWords = async () => { + return fetchWithAuth('/news/words') +} + +/** + * 수집 단어 삭제 + */ +export const deleteCollectedWord = async (articleId, word) => { + return fetchWithAuth(`/news/${articleId}/words/${encodeURIComponent(word)}`, { + method: 'DELETE', + }) +} + +/** + * Vocabulary 연동 + */ +export const syncToVocab = async (word, articleId) => { + return fetchWithAuth(`/news/words/${encodeURIComponent(word)}/sync`, { + method: 'POST', + body: JSON.stringify({ articleId }), + }) +} + +/** + * 학습 통계 조회 + */ +export const getNewsStats = async () => { + return fetchWithAuth('/news/stats') +} + +/** + * 대시보드 통합 통계 조회 + * GET /stats/dashboard + * Response: { + * today: { wordsLearned, articlesRead, quizScore, studyTime }, + * overall: { totalWords, totalArticles, averageQuizScore, totalStudyTime }, + * weeklyProgress: [{ date, wordsLearned, articlesRead }], + * levelDistribution: { BEGINNER, INTERMEDIATE, ADVANCED } + * } + */ +export const getDashboardStats = async () => { + return fetchWithAuth('/stats/dashboard') +} + +/** + * 카테고리 목록 + */ +export const NEWS_CATEGORIES = [ + { id: 'WORLD', label: '국제', labelEn: 'World', color: '#14B8A6' }, + { id: 'POLITICS', label: '정치', labelEn: 'Politics', color: '#6366F1' }, + { id: 'BUSINESS', label: '경제', labelEn: 'Business', color: '#F59E0B' }, + { id: 'TECH', label: '기술', labelEn: 'Tech', color: '#3B82F6' }, + { id: 'SCIENCE', label: '과학', labelEn: 'Science', color: '#06B6D4' }, + { id: 'HEALTH', label: '건강', labelEn: 'Health', color: '#10B981' }, + { id: 'SPORTS', label: '스포츠', labelEn: 'Sports', color: '#EF4444' }, + { id: 'ENTERTAINMENT', label: '엔터테인먼트', labelEn: 'Entertainment', color: '#EC4899' }, + { id: 'LIFESTYLE', label: '라이프스타일', labelEn: 'Lifestyle', color: '#F97316' }, +] + +/** + * 난이도별 색상 + */ +export const LEVEL_COLORS = { + BEGINNER: { main: '#10B981', bg: '#D1FAE5' }, + INTERMEDIATE: { main: '#3B82F6', bg: '#DBEAFE' }, + ADVANCED: { main: '#8B5CF6', bg: '#EDE9FE' }, +} + +/** + * TTS 음성 옵션 + */ +export const TTS_VOICES = [ + { id: 'Joanna', label: 'Joanna (미국 여성)', accent: 'US' }, + { id: 'Matthew', label: 'Matthew (미국 남성)', accent: 'US' }, + { id: 'Ivy', label: 'Ivy (미국 아동)', accent: 'US' }, + { id: 'Amy', label: 'Amy (영국 여성)', accent: 'UK' }, + { id: 'Brian', label: 'Brian (영국 남성)', accent: 'UK' }, +] + +export default { + getNewsList, + getTodayNews, + getRecommendedNews, + getNewsDetail, + markAsRead, + toggleBookmark, + getBookmarks, + getAudioUrl, + getQuiz, + submitQuiz, + getQuizHistory, + collectWord, + getCollectedWords, + deleteCollectedWord, + syncToVocab, + getNewsStats, + getDashboardStats, + NEWS_CATEGORIES, + LEVEL_COLORS, + TTS_VOICES, +} diff --git a/src/domains/notification/components/NotificationMenu.jsx b/src/domains/notification/components/NotificationMenu.jsx new file mode 100644 index 0000000..94f76fb --- /dev/null +++ b/src/domains/notification/components/NotificationMenu.jsx @@ -0,0 +1,282 @@ +import { + Box, + Menu, + MenuItem, + Typography, + Divider, + Chip, + IconButton, + Button, + alpha, +} from '@mui/material' +import { + DoneAll as MarkReadIcon, + DeleteSweep as ClearAllIcon, + Wifi as ConnectedIcon, + WifiOff as DisconnectedIcon, + Refresh as RefreshIcon, +} from '@mui/icons-material' +import { useSettings } from '../../../contexts/SettingsContext' +import { useNotificationContext } from '../contexts/NotificationContext' +import { + NotificationConfig, + getNotificationMessage, + formatTimeAgo, +} from '../types/notificationTypes' + +export function NotificationMenu({ anchorEl, open, onClose }) { + const { isKorean } = useSettings() + const { + notifications, + unreadCount, + isConnected, + isEnabled, + connectionError, + markAsRead, + markAllAsRead, + removeNotification, + clearAllNotifications, + reconnect, + } = useNotificationContext() + + const handleNotificationClick = (notification) => { + if (!notification.isRead) { + markAsRead(notification.notificationId) + } + // TODO: 알림 타입별 페이지 네비게이션 추가 가능 + onClose() + } + + return ( + + {/* Header */} + + + + {isKorean ? '알림' : 'Notifications'} + + {unreadCount > 0 && ( + 99 ? '99+' : unreadCount} + size="small" + sx={{ + height: 22, + backgroundColor: '#ef4444', + color: 'white', + fontWeight: 600, + fontSize: 11, + }} + /> + )} + + + + {/* 연결 상태 */} + {isEnabled && ( + + {isConnected ? ( + + ) : ( + + )} + + )} + + {/* 모두 읽음 */} + {unreadCount > 0 && ( + + + + )} + + {/* 모두 삭제 */} + {notifications.length > 0 && ( + + + + )} + + + + + + {/* 알림 비활성화 상태 표시 */} + {!isEnabled && ( + + + + {isKorean ? '실시간 알림이 비활성화되어 있습니다' : 'Real-time notifications are disabled'} + + + )} + + {/* 연결 에러 표시 */} + {isEnabled && connectionError && ( + + + {connectionError} + + + + )} + + {/* 알림 목록 */} + {notifications.length === 0 ? ( + + + {isKorean ? '알림이 없습니다' : 'No notifications'} + + + ) : ( + + {notifications.map((notification) => { + const config = NotificationConfig[notification.type] || { + icon: '🔔', + color: '#6b7280', + bgColor: '#f3f4f6', + } + const message = getNotificationMessage(notification, isKorean) + const timeAgo = formatTimeAgo(notification.createdAt, isKorean) + + return ( + handleNotificationClick(notification)} + sx={{ + py: 2, + px: 2.5, + alignItems: 'flex-start', + backgroundColor: notification.isRead + ? 'transparent' + : alpha(config.color, 0.04), + '&:hover': { + backgroundColor: alpha(config.color, 0.08), + }, + }} + > + + {config.icon} + + + + + {message} + + + {timeAgo} + + + + {!notification.isRead && ( + + )} + + ) + })} + + )} + + ) +} + +export default NotificationMenu diff --git a/src/domains/notification/components/NotificationToast.jsx b/src/domains/notification/components/NotificationToast.jsx new file mode 100644 index 0000000..347e8f8 --- /dev/null +++ b/src/domains/notification/components/NotificationToast.jsx @@ -0,0 +1,123 @@ +import { Snackbar, Box, Typography, IconButton, alpha } from '@mui/material' +import { Close as CloseIcon } from '@mui/icons-material' +import { useSettings } from '../../../contexts/SettingsContext' +import { useNotificationContext } from '../contexts/NotificationContext' +import { NotificationConfig, getNotificationMessage, formatTimeAgo } from '../types/notificationTypes' + +const AUTO_HIDE_DURATION = 5000 + +export function NotificationToast() { + const { currentToast, closeToast } = useNotificationContext() + const { isKorean } = useSettings() + + if (!currentToast) return null + + const config = NotificationConfig[currentToast.type] || { + icon: '🔔', + color: '#6b7280', + bgColor: '#f3f4f6', + titleKo: '알림', + titleEn: 'Notification', + } + + const title = isKorean ? config.titleKo : config.titleEn + const message = getNotificationMessage(currentToast, isKorean) + + return ( + + + + {config.icon} + + + + + {title} + + + {message} + + + + + + + + + ) +} + +export default NotificationToast diff --git a/src/domains/notification/contexts/NotificationContext.jsx b/src/domains/notification/contexts/NotificationContext.jsx new file mode 100644 index 0000000..e03ba18 --- /dev/null +++ b/src/domains/notification/contexts/NotificationContext.jsx @@ -0,0 +1,137 @@ +import { createContext, useContext, useState, useCallback, useMemo, useEffect } from 'react' +import { useAuth } from '../../../contexts/AuthContext' +import { useNotifications } from '../hooks/useNotifications' +import { NotificationType, NotificationConfig, getNotificationMessage } from '../types/notificationTypes' + +const NotificationContext = createContext(null) + +const MAX_NOTIFICATIONS = 50 + +export function NotificationProvider({ children }) { + const { user, isAuthenticated } = useAuth() + const [notifications, setNotifications] = useState([]) + const [unreadCount, setUnreadCount] = useState(0) + const [toastQueue, setToastQueue] = useState([]) + const [currentToast, setCurrentToast] = useState(null) + + // 알림 수신 핸들러 + const handleNotification = useCallback((notification) => { + // 클라이언트에서 isRead 속성 추가 + const enrichedNotification = { + ...notification, + isRead: false, + receivedAt: new Date().toISOString(), + } + + // 알림 목록에 추가 (최신순, 최대 개수 제한) + setNotifications((prev) => { + const updated = [enrichedNotification, ...prev] + return updated.slice(0, MAX_NOTIFICATIONS) + }) + + // 읽지 않은 알림 카운트 증가 + setUnreadCount((prev) => prev + 1) + + // 토스트 큐에 추가 + setToastQueue((prev) => [...prev, enrichedNotification]) + }, []) + + // SSE 연결 + const userId = isAuthenticated && user?.username ? user.username : null + console.log('[NotificationContext] Auth state:', { isAuthenticated, user, userId }) + const { isConnected, isEnabled, error, reconnect } = useNotifications(userId, handleNotification) + + // 토스트 표시 로직 - 큐에서 하나씩 처리 + useEffect(() => { + if (toastQueue.length > 0 && !currentToast) { + const [next, ...rest] = toastQueue + setCurrentToast(next) + setToastQueue(rest) + } + }, [toastQueue, currentToast]) + + // 토스트 닫기 + const closeToast = useCallback(() => { + setCurrentToast(null) + }, []) + + // 알림 읽음 처리 + const markAsRead = useCallback((notificationId) => { + setNotifications((prev) => + prev.map((n) => + n.notificationId === notificationId ? { ...n, isRead: true } : n + ) + ) + setUnreadCount((prev) => Math.max(0, prev - 1)) + }, []) + + // 모든 알림 읽음 처리 + const markAllAsRead = useCallback(() => { + setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true }))) + setUnreadCount(0) + }, []) + + // 알림 삭제 + const removeNotification = useCallback((notificationId) => { + setNotifications((prev) => { + const notification = prev.find((n) => n.notificationId === notificationId) + if (notification && !notification.isRead) { + setUnreadCount((count) => Math.max(0, count - 1)) + } + return prev.filter((n) => n.notificationId !== notificationId) + }) + }, []) + + // 모든 알림 삭제 + const clearAllNotifications = useCallback(() => { + setNotifications([]) + setUnreadCount(0) + }, []) + + const value = useMemo( + () => ({ + notifications, + unreadCount, + isConnected, + isEnabled, + connectionError: error, + currentToast, + closeToast, + markAsRead, + markAllAsRead, + removeNotification, + clearAllNotifications, + reconnect, + }), + [ + notifications, + unreadCount, + isConnected, + isEnabled, + error, + currentToast, + closeToast, + markAsRead, + markAllAsRead, + removeNotification, + clearAllNotifications, + reconnect, + ] + ) + + return ( + + {children} + + ) +} + +export function useNotificationContext() { + const context = useContext(NotificationContext) + if (!context) { + throw new Error('useNotificationContext must be used within NotificationProvider') + } + return context +} + +export { NotificationType, NotificationConfig, getNotificationMessage } diff --git a/src/domains/notification/hooks/useNotifications.js b/src/domains/notification/hooks/useNotifications.js new file mode 100644 index 0000000..0948050 --- /dev/null +++ b/src/domains/notification/hooks/useNotifications.js @@ -0,0 +1,173 @@ +import { useEffect, useCallback, useRef, useState } from 'react' + +const NOTIFICATION_URL = import.meta.env.VITE_NOTIFICATION_URL +const NOTIFICATION_ENABLED = import.meta.env.VITE_NOTIFICATION_ENABLED === 'true' + +const MAX_RETRY_COUNT = 5 +const RETRY_DELAY_MS = 3000 + +/** + * SSE를 통한 실시간 알림 연결 훅 + * @param {string|null} userId - 로그인한 사용자 ID + * @param {function} onNotification - 알림 수신 시 콜백 + * @returns {{ isConnected: boolean, error: string|null, disconnect: function, reconnect: function }} + */ +export function useNotifications(userId, onNotification) { + const eventSourceRef = useRef(null) + const retryCountRef = useRef(0) + const retryTimeoutRef = useRef(null) + + const [isConnected, setIsConnected] = useState(false) + const [error, setError] = useState(null) + + const disconnect = useCallback(() => { + if (retryTimeoutRef.current) { + clearTimeout(retryTimeoutRef.current) + retryTimeoutRef.current = null + } + + if (eventSourceRef.current) { + eventSourceRef.current.close() + eventSourceRef.current = null + } + + setIsConnected(false) + }, []) + + const connect = useCallback(() => { + console.log('[Notifications] connect() called', { userId, NOTIFICATION_URL, NOTIFICATION_ENABLED }) + + if (!NOTIFICATION_ENABLED) { + console.log('[Notifications] Disabled by VITE_NOTIFICATION_ENABLED flag') + return + } + + if (!userId) { + console.warn('[Notifications] Missing userId') + return + } + + if (!NOTIFICATION_URL) { + console.warn('[Notifications] Missing NOTIFICATION_URL env variable') + return + } + + // 기존 연결 정리 + disconnect() + + const url = `${NOTIFICATION_URL}?userId=${encodeURIComponent(userId)}` + console.log('[Notifications] Connecting to:', url) + + try { + const eventSource = new EventSource(url) + eventSourceRef.current = eventSource + + eventSource.onopen = () => { + console.log('[Notifications] 연결됨!') + setIsConnected(true) + setError(null) + retryCountRef.current = 0 + } + + eventSource.onmessage = (event) => { + console.log('[Notifications] 알림:', event.data) + + // Heartbeat 무시 + if (event.data === 'HEARTBEAT') { + return + } + + // Stream end 처리 + if (event.data === 'STREAM_END') { + console.log('[Notifications] Stream ended, reconnecting...') + scheduleReconnect() + return + } + + try { + const notification = JSON.parse(event.data) + console.log('[Notifications] Parsed:', notification) + onNotification?.(notification) + } catch (e) { + console.error('[Notifications] Failed to parse:', e, event.data) + } + } + + eventSource.onerror = (err) => { + console.log('[Notifications] 에러:', err) + console.log('[Notifications] readyState:', eventSource.readyState) + setIsConnected(false) + + // EventSource가 자동으로 재연결을 시도하지만, + // 연속 실패 시 수동 재연결 로직 적용 + if (eventSource.readyState === EventSource.CLOSED) { + scheduleReconnect() + } + } + } catch (err) { + console.error('[Notifications] Failed to create EventSource:', err) + setError('알림 연결에 실패했습니다.') + scheduleReconnect() + } + }, [userId, onNotification, disconnect]) + + const scheduleReconnect = useCallback(() => { + if (retryCountRef.current >= MAX_RETRY_COUNT) { + setError('알림 서버 연결에 실패했습니다. 새로고침해주세요.') + console.error('[Notifications] Max retry count reached') + return + } + + retryCountRef.current += 1 + const delay = RETRY_DELAY_MS * retryCountRef.current + + console.log(`[Notifications] Retry ${retryCountRef.current}/${MAX_RETRY_COUNT} in ${delay}ms`) + + retryTimeoutRef.current = setTimeout(() => { + connect() + }, delay) + }, [connect]) + + // userId 변경 시 연결/해제 + // StrictMode에서 중복 연결 방지를 위한 debounce + useEffect(() => { + let mounted = true + let connectTimeout = null + + if (userId) { + // 100ms 지연으로 StrictMode 중복 호출 방지 + connectTimeout = setTimeout(() => { + if (mounted) { + connect() + } + }, 100) + } else { + disconnect() + } + + return () => { + mounted = false + if (connectTimeout) { + clearTimeout(connectTimeout) + } + disconnect() + } + }, [userId, connect, disconnect]) + + const reconnect = useCallback(() => { + retryCountRef.current = 0 + setError(null) + connect() + }, [connect]) + + return { + isConnected, + isEnabled: NOTIFICATION_ENABLED, + error, + disconnect, + reconnect, + } +} + +export { NOTIFICATION_ENABLED } +export default useNotifications diff --git a/src/domains/notification/index.js b/src/domains/notification/index.js new file mode 100644 index 0000000..c67fbf5 --- /dev/null +++ b/src/domains/notification/index.js @@ -0,0 +1,17 @@ +// Context +export { NotificationProvider, useNotificationContext } from './contexts/NotificationContext' + +// Components +export { NotificationToast } from './components/NotificationToast' +export { NotificationMenu } from './components/NotificationMenu' + +// Hooks +export { useNotifications } from './hooks/useNotifications' + +// Types & Utils +export { + NotificationType, + NotificationConfig, + getNotificationMessage, + formatTimeAgo, +} from './types/notificationTypes' diff --git a/src/domains/notification/types/notificationTypes.js b/src/domains/notification/types/notificationTypes.js new file mode 100644 index 0000000..7b40be5 --- /dev/null +++ b/src/domains/notification/types/notificationTypes.js @@ -0,0 +1,238 @@ +/** + * 알림 타입 상수 + * @enum {string} + */ +export const NotificationType = { + BADGE_EARNED: 'BADGE_EARNED', + DAILY_COMPLETE: 'DAILY_COMPLETE', + STREAK_REMINDER: 'STREAK_REMINDER', + TEST_COMPLETE: 'TEST_COMPLETE', + NEWS_QUIZ_COMPLETE: 'NEWS_QUIZ_COMPLETE', + GAME_END: 'GAME_END', + GAME_STREAK: 'GAME_STREAK', + OPIC_COMPLETE: 'OPIC_COMPLETE', +} + +/** + * @typedef {Object} BadgeEarnedPayload + * @property {string} badgeType - 배지 타입 코드 + * @property {string} badgeName - 배지 이름 + * @property {string} description - 배지 설명 + * @property {string} iconUrl - 배지 아이콘 URL + */ + +/** + * @typedef {Object} DailyCompletePayload + * @property {string} date - 학습 완료 날짜 (YYYY-MM-DD) + * @property {number} wordsLearned - 오늘 학습한 단어 수 + * @property {number} totalWords - 총 학습 단어 수 + * @property {number} currentStreak - 현재 연속 학습 일수 + */ + +/** + * @typedef {Object} StreakReminderPayload + * @property {number} currentStreak - 현재 연속 학습 일수 + * @property {string} message - 리마인더 메시지 + */ + +/** + * @typedef {Object} TestCompletePayload + * @property {string} testId - 테스트 ID + * @property {number} score - 점수 (0-100) + * @property {number} correctCount - 맞힌 문제 수 + * @property {number} totalCount - 전체 문제 수 + * @property {boolean} isPerfect - 만점 여부 + */ + +/** + * @typedef {Object} NewsQuizCompletePayload + * @property {string} articleId - 뉴스 기사 ID + * @property {string} articleTitle - 기사 제목 + * @property {number} score - 점수 (0-100) + * @property {number} correctCount - 맞힌 문제 수 + * @property {number} totalCount - 전체 문제 수 + * @property {boolean} isPerfect - 만점 여부 + */ + +/** + * @typedef {Object} GameEndPayload + * @property {string} roomId - 게임 방 ID + * @property {string} gameSessionId - 게임 세션 ID + * @property {number} rank - 최종 순위 + * @property {number} totalPlayers - 전체 플레이어 수 + * @property {number} score - 획득 점수 + * @property {boolean} isWinner - 1등 여부 + */ + +/** + * @typedef {Object} GameStreakPayload + * @property {string} roomId - 게임 방 ID + * @property {number} streakCount - 연속 정답 횟수 + * @property {number} bonusPoints - 보너스 점수 + */ + +/** + * @typedef {Object} OpicCompletePayload + * @property {string} sessionId - 세션 ID + * @property {string} estimatedLevel - 예상 등급 (IM1, IM2, IH, AL 등) + * @property {number} questionsAnswered - 답변한 문제 수 + * @property {string} feedbackSummary - 피드백 요약 + */ + +/** + * @typedef {Object} Notification + * @property {string} notificationId - 알림 ID ("notif-xxxxxxxx" 형식) + * @property {string} type - 알림 타입 + * @property {string} userId - 대상 사용자 ID + * @property {Object} payload - 타입별 상세 데이터 + * @property {string} createdAt - ISO-8601 형식 생성 시간 + * @property {boolean} [isRead] - 읽음 여부 (클라이언트 관리) + */ + +/** + * 알림 타입별 아이콘 및 스타일 설정 + */ +export const NotificationConfig = { + [NotificationType.BADGE_EARNED]: { + icon: '🏆', + color: '#f59e0b', + bgColor: '#fffbeb', + titleKo: '배지 획득!', + titleEn: 'Badge Earned!', + }, + [NotificationType.DAILY_COMPLETE]: { + icon: '✅', + color: '#10b981', + bgColor: '#ecfdf5', + titleKo: '오늘의 학습 완료!', + titleEn: 'Daily Learning Complete!', + }, + [NotificationType.STREAK_REMINDER]: { + icon: '⏰', + color: '#f97316', + bgColor: '#fff7ed', + titleKo: '학습 리마인더', + titleEn: 'Study Reminder', + }, + [NotificationType.TEST_COMPLETE]: { + icon: '📝', + color: '#8b5cf6', + bgColor: '#f5f3ff', + titleKo: '테스트 완료', + titleEn: 'Test Complete', + }, + [NotificationType.NEWS_QUIZ_COMPLETE]: { + icon: '📰', + color: '#06b6d4', + bgColor: '#ecfeff', + titleKo: '뉴스 퀴즈 완료', + titleEn: 'News Quiz Complete', + }, + [NotificationType.GAME_END]: { + icon: '🎮', + color: '#ec4899', + bgColor: '#fdf2f8', + titleKo: '게임 종료', + titleEn: 'Game Over', + }, + [NotificationType.GAME_STREAK]: { + icon: '🔥', + color: '#ef4444', + bgColor: '#fef2f2', + titleKo: '연속 정답!', + titleEn: 'Answer Streak!', + }, + [NotificationType.OPIC_COMPLETE]: { + icon: '🎤', + color: '#059669', + bgColor: '#ecfdf5', + titleKo: 'OPIc 연습 완료', + titleEn: 'OPIc Practice Complete', + }, +} + +/** + * 알림 payload에서 표시할 메시지 생성 + * @param {Notification} notification + * @param {boolean} isKorean + * @returns {string} + */ +export function getNotificationMessage(notification, isKorean = true) { + const { type, payload } = notification + + switch (type) { + case NotificationType.BADGE_EARNED: + return isKorean + ? `"${payload.badgeName}" 배지를 획득했습니다!` + : `You earned the "${payload.badgeName}" badge!` + + case NotificationType.DAILY_COMPLETE: + return isKorean + ? `${payload.wordsLearned}개 단어 학습 완료! (${payload.currentStreak}일 연속)` + : `${payload.wordsLearned} words learned! (${payload.currentStreak} day streak)` + + case NotificationType.STREAK_REMINDER: + return payload.message + + case NotificationType.TEST_COMPLETE: + return isKorean + ? `테스트 점수: ${payload.score}점 (${payload.correctCount}/${payload.totalCount})` + : `Test score: ${payload.score} (${payload.correctCount}/${payload.totalCount})` + + case NotificationType.NEWS_QUIZ_COMPLETE: + return isKorean + ? `"${payload.articleTitle}" 퀴즈 완료! ${payload.score}점` + : `"${payload.articleTitle}" quiz done! Score: ${payload.score}` + + case NotificationType.GAME_END: + return isKorean + ? `${payload.rank}등으로 게임 종료! (${payload.score}점)` + : `Game over! Rank: #${payload.rank} (${payload.score} pts)` + + case NotificationType.GAME_STREAK: + return isKorean + ? `${payload.streakCount}연속 정답! +${payload.bonusPoints}점 보너스` + : `${payload.streakCount} streak! +${payload.bonusPoints} bonus` + + case NotificationType.OPIC_COMPLETE: + return isKorean + ? `예상 등급: ${payload.estimatedLevel} (${payload.questionsAnswered}문제)` + : `Estimated level: ${payload.estimatedLevel} (${payload.questionsAnswered} questions)` + + default: + return isKorean ? '새 알림이 도착했습니다.' : 'New notification received.' + } +} + +/** + * 시간 경과 표시 포맷 + * @param {string} createdAt - ISO-8601 날짜 문자열 + * @param {boolean} isKorean + * @returns {string} + */ +export function formatTimeAgo(createdAt, isKorean = true) { + const now = new Date() + const created = new Date(createdAt) + const diffMs = now - created + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMs / 3600000) + const diffDays = Math.floor(diffMs / 86400000) + + if (diffMins < 1) { + return isKorean ? '방금 전' : 'Just now' + } + if (diffMins < 60) { + return isKorean ? `${diffMins}분 전` : `${diffMins}m ago` + } + if (diffHours < 24) { + return isKorean ? `${diffHours}시간 전` : `${diffHours}h ago` + } + if (diffDays < 7) { + return isKorean ? `${diffDays}일 전` : `${diffDays}d ago` + } + + return created.toLocaleDateString(isKorean ? 'ko-KR' : 'en-US', { + month: 'short', + day: 'numeric', + }) +} diff --git a/src/domains/opic/constants/opicConstants.js b/src/domains/opic/constants/opicConstants.js new file mode 100644 index 0000000..0faa114 --- /dev/null +++ b/src/domains/opic/constants/opicConstants.js @@ -0,0 +1,319 @@ +/** + * OPIc domain constants + * Based on grammarConstants.js pattern + */ + +// ============================================ +// 주제 타입 +// ============================================ +export const OPIC_TOPICS = { + DESCRIPTION: 'DESCRIPTION', + HABIT: 'HABIT', + PAST_EXPERIENCE: 'PAST_EXPERIENCE', + COMPARISON: 'COMPARISON', + ROLE_PLAY: 'ROLE_PLAY', + ISSUE: 'ISSUE', +} + + +export const OPIC_TOPIC_LABELS = { + DESCRIPTION: { ko: '단순 묘사', en: 'Description' }, + HABIT: { ko: '습관/경향', en: 'Habit' }, + PAST_EXPERIENCE: { ko: '과거 경험', en: 'Past Experience' }, + COMPARISON: { ko: '비교', en: 'Comparison' }, + ROLE_PLAY: { ko: '롤플레이', en: 'Role Play' }, + ISSUE: { ko: '사회 이슈/심화', en: 'Social Issues' }, +} + +// 주제별 아이콘 +export const OPIC_TOPIC_ICONS = { + DESCRIPTION: '🏠', + HABIT: '🔄', + PAST_EXPERIENCE: '📅', +} + +// 주제별 색상 +export const OPIC_TOPIC_COLORS = { + DESCRIPTION: '#3b82f6', + HABIT: '#22c55e', + PAST_EXPERIENCE: '#f97316', +} + +// ============================================ +// 세부 주제 +// ============================================ +export const OPIC_SUBTOPICS = [ + { value: 'BANKS', labelKo: '은행', labelEn: 'Banks' }, + { value: 'BARS', labelKo: '술집/바', labelEn: 'Bars' }, + { value: 'CAFES', labelKo: '카페', labelEn: 'Cafes' }, + { value: 'CONCERTS', labelKo: '콘서트', labelEn: 'Concerts' }, + { value: 'FAMILY', labelKo: '가족', labelEn: 'Family' }, + { value: 'FURNITURE', labelKo: '가구', labelEn: 'Furniture' }, + { value: 'GAMES', labelKo: '게임', labelEn: 'Games' }, + { value: 'GYM', labelKo: '헬스장', labelEn: 'Gym' }, + { value: 'HEALTH', labelKo: '건강', labelEn: 'Health' }, + { value: 'HOLIDAYS', labelKo: '명절/휴일', labelEn: 'Holidays' }, + { value: 'HOMES', labelKo: '집', labelEn: 'Homes' }, + { value: 'HOTEL', labelKo: '호텔', labelEn: 'Hotel' }, + { value: 'INTERNET', labelKo: '인터넷', labelEn: 'Internet' }, + { value: 'MOVIES', labelKo: '영화', labelEn: 'Movies' }, + { value: 'MUSIC', labelKo: '음악', labelEn: 'Music' }, + { value: 'PARKS', labelKo: '공원', labelEn: 'Parks' }, + { value: 'PHONE', labelKo: '전화기', labelEn: 'Phone' }, + { value: 'RECYCLING', labelKo: '재활용', labelEn: 'Recycling' }, + { value: 'RESTAURANTS', labelKo: '식당', labelEn: 'Restaurants' }, + { value: 'SHOPPING', labelKo: '쇼핑', labelEn: 'Shopping' }, + { value: 'TECHNOLOGY', labelKo: '기술', labelEn: 'Technology' }, + { value: 'TRAVEL', labelKo: '여행', labelEn: 'Travel' }, + { value: 'VACATION', labelKo: '휴가', labelEn: 'Vacation' }, + { value: 'WEATHER', labelKo: '날씨', labelEn: 'Weather' }, + { value: 'FREESTYLE', labelKo: '돌발/자유', labelEn: 'Freestyle' }, + { value: 'GENERAL', labelKo: '일반', labelEn: 'General' }, +]; + +// ============================================ +// 레벨 +// ============================================ +export const OPIC_LEVELS = { + IM1: 'IM1', + IM2: 'IM2', + IM3: 'IM3', + IH: 'IH', + AL: 'AL', +} + +export const OPIC_LEVEL_LABELS = { + IM1: { ko: 'IM1 (중급 하)', en: 'IM1 (Intermediate Mid 1)' }, + IM2: { ko: 'IM2 (중급 중)', en: 'IM2 (Intermediate Mid 2)' }, + IM3: { ko: 'IM3 (중급 상)', en: 'IM3 (Intermediate Mid 3)' }, + IH: { ko: 'IH (중상급)', en: 'IH (Intermediate High)' }, + AL: { ko: 'AL (고급)', en: 'AL (Advanced Low)' }, +} + +// 레벨별 색상 +export const OPIC_LEVEL_COLORS = { + IM1: '#22c55e', + IM2: '#3b82f6', + IM3: '#8b5cf6', + IH: '#f97316', +} + +// 레벨별 배경 색상 +export const OPIC_LEVEL_BG_COLORS = { + IM1: '#f0fdf4', + IM2: '#eff6ff', + IM3: '#f5f3ff', + IH: '#fff7ed', +} + +// ============================================ +// UI 번역 (한국어) +// ============================================ +export const OPIC_UI_KO = { + // 헤더 + title: 'OPIc 스피킹 테스트', + subtitle: 'AI 기반 영어 스피킹 연습 및 피드백', + + // 세션 설정 + sessionSetup: '세션 설정', + topic: '주제', + subTopic: '세부 주제', + targetLevel: '목표 레벨', + start: '시작하기', + + // 질문 + question: '질문', + listenQuestion: '질문 듣기', + + // 녹음 + recordAnswer: '답변 녹음', + startRecording: '녹음 시작', + stopRecording: '녹음 중지', + recording: '녹음 중...', + recordingReady: '녹음 완료', + playToReview: '재생하여 확인하세요', + reRecord: '다시 녹음', + + // 제출 + getUploadUrl: 'Upload URL 발급', + preparing: '준비 중...', + submitAnswer: '답변 제출', + submitting: '제출 중...', + + // 피드백 + aiFeedback: 'AI 피드백', + yourAnswer: '내 답변', + correctedAnswer: '교정된 답변', + modelAnswer: '모범 답변', + grammarErrors: '문법 오류', + + // 네비게이션 + nextQuestion: '다음 질문', + completeSession: '세션 완료', + completing: '완료 중...', + + // 결과 + sessionCompleted: '세션이 완료되었습니다!', + checkReport: '리포트를 확인하세요.', + overallScore: '종합 점수', + totalQuestions: '총 질문 수', + answeredQuestions: '답변한 질문', + + // 에러 + errorCreateSession: '세션 생성에 실패했습니다', + errorGetQuestion: '다음 질문을 불러오는데 실패했습니다', + errorStartRecording: '녹음을 시작할 수 없습니다', + errorGetUploadUrl: 'Upload URL 발급에 실패했습니다', + errorSubmitAnswer: '답변 제출에 실패했습니다', + errorCompleteSession: '세션 완료에 실패했습니다', + + // 마이크 권한 + micPermissionRequired: '마이크 권한이 필요합니다', + micPermissionDenied: '마이크 권한이 거부되었습니다', +} + +// ============================================ +// UI 번역 (영어) +// ============================================ +export const OPIC_UI_EN = { + // 헤더 + title: 'OPIc Speaking Test', + subtitle: 'AI-powered English speaking practice & feedback', + + // 세션 설정 + sessionSetup: 'Session Setup', + topic: 'Topic', + subTopic: 'Sub Topic', + targetLevel: 'Target Level', + start: 'Start Session', + + // 질문 + question: 'Question', + listenQuestion: 'Listen to question', + + // 녹음 + recordAnswer: 'Record Answer', + startRecording: 'Start Recording', + stopRecording: 'Stop Recording', + recording: 'Recording...', + recordingReady: 'Recording Ready', + playToReview: 'Play to review', + reRecord: 'Re-record', + + // 제출 + getUploadUrl: 'Get Upload URL', + preparing: 'Preparing...', + submitAnswer: 'Submit Answer', + submitting: 'Submitting...', + + // 피드백 + aiFeedback: 'AI Feedback', + yourAnswer: 'Your Answer', + correctedAnswer: 'Corrected Answer', + modelAnswer: 'Model Answer', + grammarErrors: 'Grammar Errors', + + // 네비게이션 + nextQuestion: 'Next Question', + completeSession: 'Complete Session', + completing: 'Completing...', + + // 결과 + sessionCompleted: 'Session completed!', + checkReport: 'Check your report.', + overallScore: 'Overall Score', + totalQuestions: 'Total Questions', + answeredQuestions: 'Answered Questions', + + // 에러 + errorCreateSession: 'Failed to create session', + errorGetQuestion: 'Failed to get next question', + errorStartRecording: 'Failed to start recording', + errorGetUploadUrl: 'Failed to get upload URL', + errorSubmitAnswer: 'Failed to submit answer', + errorCompleteSession: 'Failed to complete session', + + // 마이크 권한 + micPermissionRequired: 'Microphone permission required', + micPermissionDenied: 'Microphone permission denied', +} + +// ============================================ +// 점수 등급 (grammarConstants 패턴) +// ============================================ +export const OPIC_SCORE_GRADES = { + EXCELLENT: { min: 90, max: 100, label: 'Excellent', labelKo: '훌륭해요!' }, + GOOD: { min: 70, max: 89, label: 'Good', labelKo: '잘했어요!' }, + FAIR: { min: 50, max: 69, label: 'Fair', labelKo: '괜찮아요' }, + POOR: { min: 0, max: 49, label: 'Needs Practice', labelKo: '더 연습해요' }, +} + +// 점수에 따른 등급 반환 +export const getOpicScoreGrade = (score) => { + if (score >= 90) return OPIC_SCORE_GRADES.EXCELLENT + if (score >= 70) return OPIC_SCORE_GRADES.GOOD + if (score >= 50) return OPIC_SCORE_GRADES.FAIR + return OPIC_SCORE_GRADES.POOR +} + +// 점수에 따른 색상 반환 +export const getOpicScoreColor = (score) => { + if (score >= 90) return '#059669' + if (score >= 70) return '#3b82f6' + if (score >= 50) return '#f97316' + return '#ef4444' +} + +// ============================================ +// 설정값 +// ============================================ +export const OPIC_CONFIG = { + DEFAULT_TOTAL_QUESTIONS: 12, + MAX_RECORDING_TIME: 120, // 2분 + MIN_RECORDING_TIME: 5, // 5초 + AUDIO_MIME_TYPE: 'audio/webm', + FALLBACK_MIME_TYPE: 'audio/mp4', +} + +// ============================================ +// 헬퍼 함수 (오류 수정됨) +// ============================================ + +// 언어에 따른 주제 라벨 반환 +export const getTopicLabel = (topic, isKorean) => { + const labelObj = OPIC_TOPIC_LABELS[topic]; + return labelObj ? (isKorean ? labelObj.ko : labelObj.en) : topic; +} + +// 언어에 따른 세부주제 라벨 반환 +export const getSubtopicLabel = (subtopicValue, isKorean) => { + const found = OPIC_SUBTOPICS.find(item => item.value === subtopicValue); + if (!found) return subtopicValue; + return isKorean ? found.labelKo : found.labelEn; +} + +// 언어에 따른 레벨 라벨 반환 +export const getLevelLabel = (level, isKorean) => { + const labelObj = OPIC_LEVEL_LABELS[level]; + return labelObj ? (isKorean ? labelObj.ko : labelObj.en) : level; +} + +// 녹음 시간 포맷 (mm:ss) +export const formatRecordingTime = (seconds) => { + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${mins}:${secs.toString().padStart(2, '0')}` +} + +export default { + OPIC_TOPICS, + OPIC_TOPIC_LABELS, + OPIC_TOPIC_ICONS, + OPIC_SUBTOPICS, + OPIC_LEVELS, + OPIC_LEVEL_LABELS, + getTopicLabel, + getSubtopicLabel, + getLevelLabel, + formatRecordingTime, +} \ No newline at end of file diff --git a/src/domains/opic/pages/OPIcPage.jsx b/src/domains/opic/pages/OPIcPage.jsx new file mode 100644 index 0000000..af50e00 --- /dev/null +++ b/src/domains/opic/pages/OPIcPage.jsx @@ -0,0 +1,704 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { + Alert, + Box, + Button, + Card, + CardContent, + CircularProgress, + FormControl, + InputLabel, + MenuItem, + Select, + TextField, + Typography, + useMediaQuery, + useTheme, + LinearProgress, + Chip, + Divider, + IconButton, + Grid, + Paper +} from '@mui/material' +import { + RecordVoiceOver as VoiceIcon, + Mic as MicIcon, + Stop as StopIcon, + PlayArrow as PlayIcon, + Pause as PauseIcon, + CheckCircle as CheckIcon, + Send as SendIcon, + UploadFile as UploadIcon, + VolumeUp as SpeakerIcon, + Home as HomeIcon, + TrendingUp as TrendingUpIcon, + Warning as WarningIcon, + Lightbulb as LightbulbIcon, + Email as EmailIcon +} from '@mui/icons-material' +import { useSettings } from '../../../contexts/SettingsContext' +import { useAuth } from '../../../contexts/AuthContext' +import { sessionService, uploadAudioToS3, pollForAnswerResult } from '../services/opicService' +import { + OPIC_TOPICS, + OPIC_TOPIC_LABELS, + OPIC_SUBTOPICS, +} from '../constants/opicConstants' + +export default function OPIcPage() { + const { t, isKorean } = useSettings() + const { user } = useAuth() + const theme = useTheme() + const navigate = useNavigate() + const isMobile = useMediaQuery(theme.breakpoints.down('md')) + + // Session state + const [sessionId, setSessionId] = useState(null) + const [sessionSettings, setSessionSettings] = useState({ + topic: OPIC_TOPICS.DESCRIPTION, + subTopic: 'HOMES', + }) + + // Question state + const [currentQuestion, setCurrentQuestion] = useState(null) + const [questionNumber, setQuestionNumber] = useState(0) + const [totalQuestions, setTotalQuestions] = useState(12) + + // Recording state + const [isRecording, setIsRecording] = useState(false) + const [recordedBlob, setRecordedBlob] = useState(null) + const [recordedUrl, setRecordedUrl] = useState(null) + const [mediaRecorder, setMediaRecorder] = useState(null) + const [recordingTime, setRecordingTime] = useState(0) + const audioChunksRef = useRef([]) + const [processingStatus, setProcessingStatus] = useState(null) + + // Upload state + const [uploadUrl, setUploadUrl] = useState(null) + const [s3Key, setS3Key] = useState(null) + + // Feedback state + const [feedback, setFeedback] = useState(null) + + // Report state + const [sessionReport, setSessionReport] = useState(null) + const [showReport, setShowReport] = useState(false) + + // UI state + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [uploadProgress, setUploadProgress] = useState(0) + + // Audio playback state + const [isPlayingQuestion, setIsPlayingQuestion] = useState(false) + const [isPlayingRecorded, setIsPlayingRecorded] = useState(false) + const questionAudioRef = useRef(null) + const recordedAudioRef = useRef(null) + + // Recording timer + useEffect(() => { + let interval + if (isRecording) { + interval = setInterval(() => { + setRecordingTime((prev) => prev + 1) + }, 1000) + } else { + setRecordingTime(0) + } + return () => clearInterval(interval) + }, [isRecording]) + + // Format recording time + const formatTime = (seconds) => { + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${mins}:${secs.toString().padStart(2, '0')}` + } + + // Create session + const handleCreateSession = async () => { + try { + setLoading(true) + setError(null) + const userTargetLevel = user?.level || 'IM2'; + + const requestData = { + topic: sessionSettings.topic, + subTopic: sessionSettings.subTopic, + targetLevel: userTargetLevel + }; + + const data = await sessionService.create(requestData) + + console.log("✅ 백엔드 응답 데이터:", data); + + setSessionId(data.sessionId) + const firstQuestionData = data.question || data.questionResponse || data.firstQuestion; + + if (firstQuestionData) { + displayQuestion(firstQuestionData) + setQuestionNumber(1) + } else { + console.error("❌ 질문 데이터가 응답에 없습니다:", data); + setError("서버 응답에서 질문 데이터를 찾을 수 없습니다."); + } + + if (data.totalQuestions) { + setTotalQuestions(data.totalQuestions) + } + } catch (err) { + console.error('Failed to create session:', err) + setError(isKorean ? '세션 생성에 실패했습니다' : 'Failed to create session') + } finally { + setLoading(false) + } + } + + // Display question + const displayQuestion = (questionData) => { + setCurrentQuestion(questionData) + setFeedback(null) + setRecordedBlob(null) + setRecordedUrl(null) + setUploadUrl(null) + setS3Key(null) + setProcessingStatus(null) + } + + // Get next question + const handleNextQuestion = async () => { + try { + setLoading(true) + setError(null) + const data = await sessionService.getNextQuestion(sessionId) + + // 모든 질문 완료 확인 + if (data.completed) { + // 세션 완료 처리 + await handleCompleteSession() + return + } + + if (data.question) { + displayQuestion(data.question) + + // 질문 번호 업데이트 (백엔드에서 오는 questionNumber가 있으면 사용, 없으면 수동 계산) + setQuestionNumber(data.question.questionNumber || questionNumber + 1) + } else { + console.error("❌ 질문 데이터를 찾을 수 없습니다:", data) + setError(isKorean ? '질문 데이터를 불러오지 못했습니다' : 'Failed to load question data') + } + } catch (err) { + console.error('Failed to get next question:', err) + setError(isKorean ? '다음 질문을 불러오는데 실패했습니다' : 'Failed to get next question') + } finally { + setLoading(false) + } + } + + // Toggle recording + const toggleRecording = async () => { + if (!isRecording) { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) + const recorder = new MediaRecorder(stream, { + mimeType: MediaRecorder.isTypeSupported('audio/webm') + ? 'audio/webm' + : 'audio/mp4', + }) + + audioChunksRef.current = [] + + recorder.ondataavailable = (event) => { + if (event.data.size > 0) { + audioChunksRef.current.push(event.data) + } + } + + recorder.onstop = () => { + const blob = new Blob(audioChunksRef.current, { type: 'audio/webm' }) + setRecordedBlob(blob) + const url = URL.createObjectURL(blob) + setRecordedUrl(url) + stream.getTracks().forEach((track) => track.stop()) + } + + recorder.start() + setMediaRecorder(recorder) + setIsRecording(true) + } catch (err) { + console.error('Failed to start recording:', err) + setError(isKorean ? '녹음을 시작할 수 없습니다' : 'Failed to start recording') + } + } else { + if (mediaRecorder) { + mediaRecorder.stop() + setMediaRecorder(null) + setIsRecording(false) + } + } + } + + // Get upload URL + const handleGetUploadUrl = async () => { + try { + setLoading(true) + setError(null) + const data = await sessionService.getUploadUrl(sessionId) + setUploadUrl(data.uploadUrl) + setS3Key(data.s3Key) + } catch (err) { + console.error('Failed to get upload URL:', err) + setError(isKorean ? 'Upload URL 발급에 실패했습니다' : 'Failed to get upload URL') + } finally { + setLoading(false) + } + } + + // Submit answer + const handleSubmitAnswer = async () => { + try { + setLoading(true) + setError(null) + setUploadProgress(0) + setProcessingStatus(isKorean ? 'S3에 업로드 중...' : 'Uploading to S3...') + + // 1. S3 업로드 + await uploadAudioToS3(uploadUrl, recordedBlob) + setUploadProgress(20) + setProcessingStatus(isKorean ? '답변 제출 중...' : 'Submitting...') + + // 2. 답변 제출 (비동기 처리 시작 요청) + const submitResult = await sessionService.submitAnswer(sessionId, { audioS3Key: s3Key }) + setUploadProgress(40) + setProcessingStatus(isKorean ? 'AI가 분석 중...' : 'AI is analyzing...') + + // 3. 폴링으로 결과 대기 (백엔드 완료될 때까지 반복 확인) + const result = await pollForAnswerResult(sessionId, submitResult.questionIndex, { + onProgress: ({ attempt }) => { + // 진행 상황에 따라 프로그레스 바를 조금씩 채움 (40% ~ 90%) + setUploadProgress(prev => Math.min(prev + 1, 90)) + } + }) + + // 4. 최종 결과 세팅 + setFeedback(result) + setUploadProgress(100) + setProcessingStatus(null) + + } catch (err) { + console.error('Failed to submit answer:', err) + setError(isKorean ? '분석 중 오류가 발생했습니다. 다시 시도해주세요.' : 'Analysis failed. Please try again.') + } finally { + setLoading(false) + setTimeout(() => setUploadProgress(0), 2000) + } + } + + + + // Complete session + const handleCompleteSession = async () => { + try { + setLoading(true) + setError(null) + const report = await sessionService.complete(sessionId) + + const reportData = report.data || report; + + // 리포트 데이터 저장 및 화면 전환 + setSessionReport(reportData) + setShowReport(true) + } catch (err) { + console.error('Failed to complete session:', err) + setError(isKorean ? '세션 완료에 실패했습니다' : 'Failed to complete session') + } finally { + setLoading(false) + } + } + + const handleStartNewSession = () => { + setSessionId(null) + setCurrentQuestion(null) + setQuestionNumber(0) + setFeedback(null) + setShowReport(false) + setSessionReport(null) + setRecordedBlob(null) + setRecordedUrl(null) + setUploadUrl(null) + setS3Key(null) + } + + // Navigate to reports page + const handleGoToReports = () => { + navigate('/reports') + } + + // Play question audio + const handlePlayQuestionAudio = () => { + if (questionAudioRef.current) { + if (isPlayingQuestion) { + questionAudioRef.current.pause() + setIsPlayingQuestion(false) + } else { + questionAudioRef.current.play() + setIsPlayingQuestion(true) + } + } + } + + // Play recorded audio + const handlePlayRecordedAudio = () => { + if (recordedAudioRef.current) { + if (isPlayingRecorded) { + recordedAudioRef.current.pause() + setIsPlayingRecorded(false) + } else { + recordedAudioRef.current.play() + setIsPlayingRecorded(true) + } + } + } + + const getLevelColor = (level) => { + const colors = { + 'IM1': '#22c55e', 'IM2': '#3b82f6', 'IM3': '#8b5cf6', + 'IH': '#f97316', 'AL': '#ef4444' + }; + return colors[level] || '#3b82f6'; + } + + // 결과 리포트 화면 + if (showReport && sessionReport) { + return ( + + + + {/* Header: Score & Level */} + + + {isKorean ? '테스트 결과 리포트' : 'TEST REPORT'} + + + + {/* Level */} + + + {sessionReport.estimatedLevel} + + LEVEL + + + + + {/* Score */} + + + {sessionReport.overallScore} + + SCORE + + + + {/* Overall Feedback */} + + + {sessionReport.feedback} + + + + + + + {/* Details Grid */} + + {[ + { title: isKorean ? "잘한 점" : "Strengths", items: sessionReport.strengths, color: "#16a34a", icon: TrendingUpIcon }, + { title: isKorean ? "아쉬운 점" : "Weaknesses", items: sessionReport.weaknesses, color: "#ea580c", icon: WarningIcon }, + { title: isKorean ? "학습 추천" : "Tips", items: sessionReport.recommendations, color: "#7c3aed", icon: LightbulbIcon } + ].map((section, idx) => ( + + + + + + {section.title} + + + + {section.items && section.items.length > 0 ? ( + section.items.map((item, i) => ( + + {item} + + )) + ) : ( + - + )} + + + + ))} + + + {/* Email Notification */} + + } + label={isKorean ? "결과가 이메일로 발송되었습니다." : "Report sent to your email."} + variant="outlined" + size="small" + sx={{ borderColor: '#e2e8f0', color: '#94a3b8', fontSize: '0.8rem' }} + /> + + + + {/* Bottom Actions */} + + + + + + ); + } + + return ( + + + {/* Header */} + + + + + + {isKorean ? 'OPIc 스피킹 테스트' : 'OPIc Speaking Test'} + {isKorean ? 'AI 기반 OPIc 연습 및 피드백' : 'AI-powered English speaking practice'} + + + + {/* Main Content */} + + {error && {error}} + + {/* --- [1] Session Setup --- */} + {!sessionId && ( + + + {isKorean ? 'OPIc 질문 생성' : 'Session Setup'} + + + {isKorean ? '질문 유형' : 'Question Type'} + + + + + {isKorean ? '주제' : 'Topic'} + + + + + * {isKorean ? `프로필에 설정된 나의 레벨(${user?.level || 'IM2'})에 맞춰 AI 피드백이 제공됩니다.` : `AI feedback tailored to level (${user?.level || 'IM2'}).`} + + + + + + )} + + {/* --- [2] Question Area --- */} + {sessionId && currentQuestion && ( + <> + + + + + + {/* 질문 카드 */} + + + {currentQuestion.questionText} + {currentQuestion.audioUrl && ( + + + {isPlayingQuestion ? : } + + {isKorean ? '질문 듣기' : 'Listen'} + + )} + + + + {/* 녹음 카드 */} + + + {isKorean ? '답변 녹음' : 'Record Answer'} + + + {isRecording && {formatTime(recordingTime)}} + {recordedUrl && !isRecording && ( + + + {isPlayingRecorded ? : } + + + {isKorean ? '녹음 완료' : 'Recorded'} + + + )} + + + + + {/* 제출 버튼 영역 */} + {recordedBlob && !feedback && ( + + + + {!uploadUrl ? ( + + ) : ( + + )} + + {uploadProgress > 0 && ( + + + {processingStatus && ( + + {processingStatus} + + )} + + )} + + + )} + + {/* 피드백 및 다음 질문 버튼 */} + {feedback && ( + + + AI Feedback + + {feedback.transcript && ( + + {isKorean ? '내 답변' : 'Your Answer'} + {feedback.transcript} + + )} + + {feedback.feedback?.correctedAnswer && ( + + {isKorean ? '교정된 답변' : 'Corrected'} + {feedback.feedback.correctedAnswer} + + )} + {feedback.feedback?.errors && feedback.feedback.errors.length > 0 && ( + + + {isKorean ? ' 문법 오류 체크' : 'Grammar Errors'} + + {feedback.feedback.errors.map((error, index) => ( + + + • {error.original}{error.corrected} + + {error.explanation && ( + + └ {error.explanation} + + )} + + ))} + + )} + + {(feedback.feedback?.grammarCorrection || feedback.feedback?.feedback) && ( + + + + + {isKorean ? ' AI 학습 팁 & 문법 교정' : 'Tips & Grammar Correction'} + + + + {/* grammarCorrection이 있으면 우선 보여주고, 없으면 전체 feedback을 보여줌 */} + {feedback.feedback.grammarCorrection || feedback.feedback.feedback} + + + )} + + + + + {/* 마지막 질문이 아니면 '다음 질문' 버튼, 마지막 질문이면 '세션 완료' 버튼 */} + {questionNumber < totalQuestions ? ( + + ) : ( + + )} + + + + )} + + )} + + + ); +} + diff --git a/src/domains/opic/services/opicService.js b/src/domains/opic/services/opicService.js new file mode 100644 index 0000000..3bff7b5 --- /dev/null +++ b/src/domains/opic/services/opicService.js @@ -0,0 +1,159 @@ +import opicApi from '../../../api/opicApi' + +/** + * OPIc 세션 서비스 + */ +export const sessionService = { + /** + * 세션 생성 + * @param {Object} data - { topic, subTopic } + */ + async create(data) { + const response = await opicApi.post('/opic/sessions', data) + if (!response.isSuccess) { + throw new Error(response.message || 'Failed to create session') + } + return response.data + }, + + /** + * 세션 목록 조회 + */ + async getList() { + const response = await opicApi.get('/opic/sessions') + if (!response.isSuccess) { + throw new Error(response.message || 'Failed to get sessions') + } + return response.data + }, + + /** + * 세션 상세 조회 + * @param {string} sessionId + */ + async getDetail(sessionId) { + const response = await opicApi.get(`/opic/sessions/${sessionId}`) + if (!response.isSuccess) { + throw new Error(response.message || 'Failed to get session detail') + } + return response.data + }, + + /** + * 다음 질문 조회 + * @param {string} sessionId + */ + async getNextQuestion(sessionId) { + const response = await opicApi.get(`/opic/sessions/${sessionId}/questions/next`) + if (!response.isSuccess) { + throw new Error(response.message || 'Failed to get question') + } + return response.data + }, + + /** + * 음성 업로드 URL 발급 + * @param {string} sessionId + */ + async getUploadUrl(sessionId) { + const response = await opicApi.get(`/opic/sessions/${sessionId}/upload-url`) + if (!response.isSuccess) { + throw new Error(response.message || 'Failed to get upload URL') + } + return response.data + }, + + /** + * 답변 제출 + * @param {string} sessionId + * @param {Object} data - { audioS3Key } + */ + async submitAnswer(sessionId, data) { + const response = await opicApi.post(`/opic/sessions/${sessionId}/answers`, data) + if (!response.isSuccess) { + throw new Error(response.message || 'Failed to submit answer') + } + return response.data + }, + + /** + * 답변 상태 조회 (폴링용 함수 추가) + */ + async getAnswerStatus(sessionId, questionIndex) { + const response = await opicApi.get(`/opic/sessions/${sessionId}/answers/${questionIndex}/status`) + if (!response.isSuccess) { + throw new Error(response.message || 'Failed to get answer status') + } + return response.data; // { status, transcript, feedback 등 } + }, + + /** + * 세션 완료 + * @param {string} sessionId + */ + async complete(sessionId) { + const response = await opicApi.post(`/opic/sessions/${sessionId}/complete`) + if (!response.isSuccess) { + throw new Error(response.message || 'Failed to complete session') + } + return response.data + }, +} + +/** + * S3 업로드 헬퍼 (presigned URL 사용) + * @param {string} uploadUrl - Presigned URL + * @param {Blob} audioBlob - 녹음된 오디오 + */ +export const uploadAudioToS3 = async (uploadUrl, audioBlob) => { + const response = await fetch(uploadUrl, { + method: 'PUT', + headers: { 'Content-Type': 'audio/webm' }, + body: audioBlob, + }) + if (!response.ok) { + throw new Error('Failed to upload audio to S3') + } + return true +} + +/** + * 답변 처리 결과 폴링 헬퍼 함수 + */ +export const pollForAnswerResult = async (sessionId, questionIndex, options = {}) => { + const { + maxAttempts = 90, + intervalMs = 2000, // 2초 간격 + onProgress = null // 진행 상태 보고용 콜백 + } = options + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const result = await sessionService.getAnswerStatus(sessionId, questionIndex) + + // 1. 완료 시 결과 반환 + if (result.status === 'COMPLETED') { + return result + } + + // 2. 실패 시 에러 발생 + if (result.status === 'FAILED') { + throw new Error(result.message || 'AI 분석 중 오류가 발생했습니다.') + } + + // 3. 처리 중일 때 (PROCESSING) + if (onProgress) { + onProgress({ attempt: attempt + 1, status: result.status }) + } + + // 대기 후 재시도 + await new Promise(resolve => setTimeout(resolve, intervalMs)) + } + + throw new Error('분석 시간이 초과되었습니다. 잠시 후 다시 확인해주세요.') +} + +export default { + sessionService, + uploadAudioToS3, + pollForAnswerResult +} \ No newline at end of file diff --git a/src/domains/profile/hooks/useProfile.js b/src/domains/profile/hooks/useProfile.js new file mode 100644 index 0000000..3e965c4 --- /dev/null +++ b/src/domains/profile/hooks/useProfile.js @@ -0,0 +1,35 @@ +import { useEffect } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { + fetchMyProfile, + updateProfile, + uploadProfileImage, + clearError +} from '../store/profileSlice' + +export const useProfile = () => { + const dispatch = useDispatch() + const { profile, loading, error, updateLoading, imageUploading } = useSelector( + (state) => state.profile + ) + + useEffect(() => { + if (!profile && !loading && !error) { + dispatch(fetchMyProfile()) + } + }, [dispatch, profile, loading, error]) + + return { + profile, + loading, + error, + updateLoading, + imageUploading, + updateProfile: (data) => dispatch(updateProfile(data)).unwrap(), + uploadImage: (file) => dispatch(uploadProfileImage(file)).unwrap(), + clearError: () => dispatch(clearError()), + refetch: () => dispatch(fetchMyProfile()) + } +} + +export default useProfile \ No newline at end of file diff --git a/src/domains/profile/pages/ProfilePage.jsx b/src/domains/profile/pages/ProfilePage.jsx new file mode 100644 index 0000000..2f29b8b --- /dev/null +++ b/src/domains/profile/pages/ProfilePage.jsx @@ -0,0 +1,106 @@ +import { useState, useEffect } from 'react' +import { Box, Button, Card, CardContent, Container, TextField, Typography, Avatar, Alert } from '@mui/material' +import { useDispatch, useSelector } from 'react-redux' + +// ▼▼▼ [중요] 파일 위치가 바뀌었으니 import 경로도 수정했습니다! ▼▼▼ +// (pages 폴더와 store 폴더가 같은 profile 폴더 안에 형제(sibling) 관계이므로) +import { updateProfile } from '../store/profileSlice' + +const ProfilePage = () => { + const dispatch = useDispatch() + const { profile, updateLoading } = useSelector((state) => state.profile) + + const [nickname, setNickname] = useState('') + const [successMsg, setSuccessMsg] = useState('') + + // 리덕스에 있는 내 정보(profile)가 로드되면 입력창에 채워넣기 + useEffect(() => { + if (profile?.nickname) { + setNickname(profile.nickname) + } + }, [profile]) + + const handleSave = async () => { + try { + // 1. 닉네임 변경 요청 보내기 + await dispatch(updateProfile({ nickname })).unwrap() + + // 2. 성공 메시지 띄우기 + setSuccessMsg('닉네임이 변경되었습니다! 로그아웃 후 다시 로그인해주세요.') + } catch (error) { + alert('변경 실패: ' + error) + } + } + + return ( + + + 내 프로필 수정 + + + + + + {/* 프로필 이미지 */} + + + {nickname ? nickname.substring(0, 1).toUpperCase() : 'U'} + + + + {/* 이메일 (수정 불가) */} + + + {/* 닉네임 (수정 가능) */} + setNickname(e.target.value)} + fullWidth + variant="outlined" + helperText="채팅방에서 사용할 멋진 닉네임을 지어주세요!" + /> + + {/* 성공 메시지 */} + {successMsg && ( + {successMsg} + )} + + {/* 저장 버튼 */} + + + + + + ) +} + +export default ProfilePage \ No newline at end of file diff --git a/src/domains/profile/services/profileService.js b/src/domains/profile/services/profileService.js new file mode 100644 index 0000000..c41fcdc --- /dev/null +++ b/src/domains/profile/services/profileService.js @@ -0,0 +1,39 @@ +import api from '../../../api/axios' + +const profileService = { + // 내 프로필 조회 + getMyProfile: async () => { + const response = await api.get('/users/profile/me') + return response.data + }, + + // 프로필 수정 (닉네임, 레벨) + updateProfile: async ({ nickname, level, profileUrl }) => { + const response = await api.put('/users/profile/me', { + nickname, + level, + profileUrl + }) + return response.data + }, + + // 이미지 업로드 URL 발급 + getImageUploadUrl: async (fileName, contentType) => { + const response = await api.post('/users/profile/me/image', { + fileName, + contentType + }) + return response.data + }, + + // S3에 이미지 직접 업로드 + uploadImageToS3: async (uploadUrl, file) => { + await fetch(uploadUrl, { + method: 'PUT', + headers: { 'Content-Type': file.type }, + body: file + }) + } +} + +export default profileService \ No newline at end of file diff --git a/src/domains/profile/store/profileSlice.js b/src/domains/profile/store/profileSlice.js new file mode 100644 index 0000000..76995ec --- /dev/null +++ b/src/domains/profile/store/profileSlice.js @@ -0,0 +1,114 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit' +import profileService from '../services/profileService' + +// 프로필 조회 +export const fetchMyProfile = createAsyncThunk( + 'profile/fetchMyProfile', + async (_, { rejectWithValue }) => { + try { + const response = await profileService.getMyProfile() + return response.data || response + } catch (error) { + const status = error.response?.status + const message = error.response?.data?.error || error.message || '프로필 조회 실패' + return rejectWithValue({ message, status }) + } + } +) + +// 프로필 수정 +export const updateProfile = createAsyncThunk( + 'profile/updateProfile', + async (data, { rejectWithValue }) => { + try { + const response = await profileService.updateProfile(data) + return response.data + } catch (error) { + return rejectWithValue(error.response?.data?.error || '프로필 수정 실패') + } + } +) + +// 이미지 업로드 +export const uploadProfileImage = createAsyncThunk( + 'profile/uploadImage', + async (file, { dispatch, rejectWithValue }) => { + try { + const urlResponse = await profileService.getImageUploadUrl(file.name, file.type) + const { uploadUrl, imageUrl } = urlResponse.data + + await profileService.uploadImageToS3(uploadUrl, file) + await dispatch(updateProfile({ profileUrl: imageUrl })) + + return imageUrl + } catch (error) { + return rejectWithValue('이미지 업로드 실패') + } + } +) + +const profileSlice = createSlice({ + name: 'profile', + initialState: { + profile: null, + loading: false, + error: null, + updateLoading: false, + imageUploading: false, + authError: false + }, + reducers: { + clearError: (state) => { state.error = null }, + clearProfile: (state) => { state.profile = null } + }, + extraReducers: (builder) => { + builder + // fetchMyProfile + .addCase(fetchMyProfile.pending, (state) => { + state.loading = true + state.error = null + }) + .addCase(fetchMyProfile.fulfilled, (state, action) => { + state.loading = false + state.profile = action.payload + }) + .addCase(fetchMyProfile.rejected, (state, action) => { + state.loading = false + state.error = action.payload?.message || action.payload + + const status = action.payload?.status + const message = String(action.payload?.message || action.payload || '') + + if (status === 401 || message.includes('401') || message.includes('인증')) { + state.profile = null + state.authError = true + } + }) + // updateProfile + .addCase(updateProfile.pending, (state) => { + state.updateLoading = true + }) + .addCase(updateProfile.fulfilled, (state, action) => { + state.updateLoading = false + state.profile = action.payload + }) + .addCase(updateProfile.rejected, (state, action) => { + state.updateLoading = false + state.error = action.payload + }) + // uploadProfileImage + .addCase(uploadProfileImage.pending, (state) => { + state.imageUploading = true + }) + .addCase(uploadProfileImage.fulfilled, (state) => { + state.imageUploading = false + }) + .addCase(uploadProfileImage.rejected, (state, action) => { + state.imageUploading = false + state.error = action.payload + }) + } +}) + +export const { clearError, clearProfile } = profileSlice.actions +export default profileSlice.reducer \ No newline at end of file diff --git a/src/domains/speaking/components/SpeakingChatMessage.jsx b/src/domains/speaking/components/SpeakingChatMessage.jsx new file mode 100644 index 0000000..d22b65a --- /dev/null +++ b/src/domains/speaking/components/SpeakingChatMessage.jsx @@ -0,0 +1,163 @@ +import { useState, useRef } from 'react' +import { Avatar, Box, IconButton, Typography, Tooltip, keyframes } from '@mui/material' +import { + Person as PersonIcon, + SmartToy as AiIcon, + VolumeUp as VolumeUpIcon, + Stop as StopIcon, +} from '@mui/icons-material' +import { useSettings } from '../../../contexts/SettingsContext' + +const pulse = keyframes` + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +` + +export default function SpeakingChatMessage({ message, isUser = false }) { + const { isKorean } = useSettings() + const [isPlaying, setIsPlaying] = useState(false) + const audioRef = useRef(null) + + const { + content, + userTranscript, + aiText, + aiAudioUrl, + confidence, + } = message + + const displayText = isUser ? (userTranscript || content) : (aiText || content) + + /** + * AI 응답 음성 재생 + */ + const playAudio = () => { + if (!aiAudioUrl) return + + if (audioRef.current) { + audioRef.current.pause() + } + + const audio = new Audio(aiAudioUrl) + audioRef.current = audio + + audio.onplay = () => setIsPlaying(true) + audio.onended = () => setIsPlaying(false) + audio.onerror = () => { + setIsPlaying(false) + console.error('Audio playback error') + } + + audio.play() + } + + const stopAudio = () => { + if (audioRef.current) { + audioRef.current.pause() + audioRef.current.currentTime = 0 + setIsPlaying(false) + } + } + + return ( + + {/* 아바타 */} + + {isUser ? : } + + + {/* 메시지 내용 */} + + {/* 역할 라벨 */} + + {isUser + ? (isKorean ? '나' : 'You') + : 'Amy (AI)'} + + + {/* 메시지 버블 */} + + + {displayText} + + + {/* STT 신뢰도 표시 (사용자 메시지) */} + {isUser && confidence && ( + + {isKorean ? '인식률' : 'Confidence'}: {Math.round(confidence * 100)}% + + )} + + + {/* AI 응답 음성 재생 버튼 */} + {!isUser && aiAudioUrl && ( + + + + {isPlaying ? : } + + + + {isPlaying + ? (isKorean ? '재생 중...' : 'Playing...') + : (isKorean ? '음성 듣기' : 'Listen')} + + + )} + + + ) +} \ No newline at end of file diff --git a/src/domains/speaking/components/SpeakingInput.jsx b/src/domains/speaking/components/SpeakingInput.jsx new file mode 100644 index 0000000..d90ba85 --- /dev/null +++ b/src/domains/speaking/components/SpeakingInput.jsx @@ -0,0 +1,294 @@ +import { useState, useRef, useEffect } from 'react' +import { + Box, + IconButton, + TextField, + Tooltip, + FormControl, + Select, + MenuItem, + Typography, + CircularProgress, +} from '@mui/material' +import { + Mic as MicIcon, + Stop as StopIcon, + Send as SendIcon, + Refresh as RefreshIcon, +} from '@mui/icons-material' +import { useSettings } from '../../../contexts/SettingsContext' +import { + SPEAKING_LEVELS, + SPEAKING_LEVEL_COLORS, + SPEAKING_LEVEL_LABELS, + AUDIO_CONFIG, +} from '../constants/speakingConstants' + +export default function SpeakingInput({ + onSendVoice, + onSendText, + onLevelChange, + onReset, + loading = false, + level = SPEAKING_LEVELS.INTERMEDIATE, +}) { + const { isKorean } = useSettings() + const [message, setMessage] = useState('') + const [isRecording, setIsRecording] = useState(false) + const [recordingTime, setRecordingTime] = useState(0) + + const mediaRecorderRef = useRef(null) + const audioChunksRef = useRef([]) + const timerRef = useRef(null) + + // 녹음 타이머 + useEffect(() => { + if (isRecording) { + timerRef.current = setInterval(() => { + setRecordingTime((prev) => { + if (prev >= AUDIO_CONFIG.MAX_DURATION / 1000) { + stopRecording() + return 0 + } + return prev + 1 + }) + }, 1000) + } else { + clearInterval(timerRef.current) + setRecordingTime(0) + } + + return () => clearInterval(timerRef.current) + }, [isRecording]) + + /** + * 녹음 시작 + */ + const startRecording = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + sampleRate: 16000, + channelCount: 1, + } + }) + const mediaRecorder = new MediaRecorder(stream, { + mimeType: AUDIO_CONFIG.MIME_TYPE || 'audio/webm;codecs=opus', + audioBitsPerSecond: 16000, + }) + + audioChunksRef.current = [] + + mediaRecorder.ondataavailable = (event) => { + if (event.data.size > 0) { + audioChunksRef.current.push(event.data) + } + } + + mediaRecorder.onstop = async () => { + const audioBlob = new Blob(audioChunksRef.current, { + type: AUDIO_CONFIG.MIME_TYPE, + }) + + // 용량 확인용 로그 WㅉWWWWWWWs + console.log(`[Recording] Final size: ${audioBlob.size} bytes`); + + // Blob → Base64 변환 + const reader = new FileReader() + reader.onloadend = () => { + const base64 = reader.result.split(',')[1] + onSendVoice?.(base64) + } + reader.readAsDataURL(audioBlob) + + // 스트림 정리 + stream.getTracks().forEach((track) => track.stop()) + } + + mediaRecorderRef.current = mediaRecorder + mediaRecorder.start() + setIsRecording(true) + } catch (error) { + console.error('Microphone access denied:', error) + alert(isKorean ? '마이크 접근이 거부되었습니다.' : 'Microphone access denied.') + } + } + + /** + * 녹음 중지 + */ + const stopRecording = () => { + if (mediaRecorderRef.current && isRecording) { + mediaRecorderRef.current.stop() + setIsRecording(false) + } + } + + /** + * 텍스트 전송 + */ + const handleSendText = () => { + console.log('[SpeakingInput] handleSendText called, message:', message) + if (message.trim().length < 2 || loading) { + console.log('[SpeakingInput] Blocked - length or loading') + return + } + console.log('[SpeakingInput] Calling onSendText') + onSendText?.(message.trim()) + setMessage('') + } + + const handleKeyDown = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSendText() + } + } + + const levelOptions = Object.values(SPEAKING_LEVELS).map((value) => ({ + value, + label: isKorean + ? SPEAKING_LEVEL_LABELS.ko[value] + : SPEAKING_LEVEL_LABELS.en[value], + })) + + return ( + + + {/* Level Selector */} + + + + + {/* 녹음 버튼 */} + + + + {isRecording ? : } + + + + {/* 녹음 시간 표시 */} + {isRecording && ( + + {recordingTime}s + + )} + + + {/* 텍스트 입력 */} + setMessage(e.target.value)} + onKeyDown={handleKeyDown} + disabled={loading || isRecording} + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: '20px', + }, + }} + /> + + {/* 전송 버튼 */} + + + + {loading ? ( + + ) : ( + + )} + + + + + {/* 대화 초기화 버튼 */} + + + + + + + + ) +} \ No newline at end of file diff --git a/src/domains/speaking/constants/speakingConstants.js b/src/domains/speaking/constants/speakingConstants.js new file mode 100644 index 0000000..99243f3 --- /dev/null +++ b/src/domains/speaking/constants/speakingConstants.js @@ -0,0 +1,31 @@ +export const SPEAKING_LEVELS = { + BEGINNER: 'BEGINNER', + INTERMEDIATE: 'INTERMEDIATE', + ADVANCED: 'ADVANCED', +} + +export const SPEAKING_LEVEL_COLORS = { + [SPEAKING_LEVELS.BEGINNER]: '#22c55e', // 초록 + [SPEAKING_LEVELS.INTERMEDIATE]: '#f59e0b', // 주황 + [SPEAKING_LEVELS.ADVANCED]: '#ef4444', // 빨강 +} + +export const SPEAKING_LEVEL_LABELS = { + ko: { + [SPEAKING_LEVELS.BEGINNER]: '초급', + [SPEAKING_LEVELS.INTERMEDIATE]: '중급', + [SPEAKING_LEVELS.ADVANCED]: '고급', + }, + en: { + [SPEAKING_LEVELS.BEGINNER]: 'Beginner', + [SPEAKING_LEVELS.INTERMEDIATE]: 'Intermediate', + [SPEAKING_LEVELS.ADVANCED]: 'Advanced', + }, +} + +// 음성 녹음 설정 +export const AUDIO_CONFIG = { + MAX_DURATION: 60000, // 최대 녹음 시간 (60초) + MIME_TYPE: 'audio/webm', + SAMPLE_RATE: 16000, +} \ No newline at end of file diff --git a/src/domains/speaking/hooks/useSpeaking.js b/src/domains/speaking/hooks/useSpeaking.js new file mode 100644 index 0000000..1702ab5 --- /dev/null +++ b/src/domains/speaking/hooks/useSpeaking.js @@ -0,0 +1,119 @@ +import { useCallback, useState } from 'react' +import { speakingService } from '../services/speakingService' + +/** + * Speaking REST API 훅 + */ +export function useSpeaking() { + const [isProcessing, setIsProcessing] = useState(false) + const [error, setError] = useState(null) + const [result, setResult] = useState(null) + const [sessionId, setSessionId] = useState(null) + const [currentLevel, setCurrentLevel] = useState('INTERMEDIATE') + + /** + * 음성 전송 + */ + const sendVoice = useCallback(async (audioBase64) => { + setIsProcessing(true) + setResult(null) + setError(null) + + try { + const response = await speakingService.chat({ + sessionId, + audio: audioBase64, + level: currentLevel, + }) + + setSessionId(response.sessionId) + setResult(response) + return response + } catch (err) { + console.error('[useSpeaking] Voice error:', err) + setError(err.message || '처리 중 오류가 발생했습니다') + throw err + } finally { + setIsProcessing(false) + } + }, [sessionId, currentLevel]) + + /** + * 텍스트 전송 + */ + const sendText = useCallback(async (text) => { + setIsProcessing(true) + setResult(null) + setError(null) + + try { + const response = await speakingService.chat({ + sessionId, + text, + level: currentLevel, + }) + + setSessionId(response.sessionId) + setResult(response) + return response + } catch (err) { + console.error('[useSpeaking] Text error:', err) + setError(err.message || '처리 중 오류가 발생했습니다') + throw err + } finally { + setIsProcessing(false) + } + }, [sessionId, currentLevel]) + + /** + * 레벨 변경 + */ + const setLevel = useCallback((level) => { + setCurrentLevel(level) + }, []) + + /** + * 대화 초기화 + */ + const resetConversation = useCallback(async () => { + if (!sessionId) { + // 세션이 없으면 로컬 상태만 초기화 + setResult(null) + setError(null) + return + } + + try { + await speakingService.reset(sessionId) + } catch (err) { + console.error('[useSpeaking] Reset error:', err) + } finally { + // 에러가 나도 로컬 상태는 초기화 + setSessionId(null) + setResult(null) + setError(null) + } + }, [sessionId]) + + /** + * 새 세션 시작 + */ + const startNewSession = useCallback(() => { + setSessionId(null) + setResult(null) + setError(null) + }, []) + + return { + isProcessing, + error, + result, + sessionId, + currentLevel, + sendVoice, + sendText, + setLevel, + resetConversation, + startNewSession, + } +} \ No newline at end of file diff --git a/src/domains/speaking/index.js b/src/domains/speaking/index.js new file mode 100644 index 0000000..342097d --- /dev/null +++ b/src/domains/speaking/index.js @@ -0,0 +1,15 @@ +// Pages +export { default as SpeakingPage } from './pages/SpeakingPage' + +// Components +export { default as SpeakingChatMessage } from './components/SpeakingChatMessage' +export { default as SpeakingInput } from './components/SpeakingInput' + +// Hooks +export { useSpeaking } from './hooks/useSpeaking' + +// Services +export { speakingService } from './services/speakingService' + +// Constants +export * from './constants/speakingConstants' \ No newline at end of file diff --git a/src/domains/speaking/pages/SpeakingPage.jsx b/src/domains/speaking/pages/SpeakingPage.jsx new file mode 100644 index 0000000..1c481f7 --- /dev/null +++ b/src/domains/speaking/pages/SpeakingPage.jsx @@ -0,0 +1,222 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Alert, Box, Typography } from '@mui/material' +import { SmartToy as AiIcon } from '@mui/icons-material' +import { useSettings } from '../../../contexts/SettingsContext' // 경로는 프로젝트 구조에 맞게 확인 필요 +import SpeakingChatMessage from '../components/SpeakingChatMessage' +import SpeakingInput from '../components/SpeakingInput' +import { useSpeaking } from '../hooks/useSpeaking' +import { SPEAKING_LEVELS } from '../constants/speakingConstants' + +export default function SpeakingPage() { + const { isKorean } = useSettings() + + const [messages, setMessages] = useState([]) + const [level, setLevel] = useState(SPEAKING_LEVELS.INTERMEDIATE) + const [error, setError] = useState(null) + + const messagesEndRef = useRef(null) + + // REST API 훅 사용 (훅 내부에서 speakingService를 호출하므로 안전함) + const { + isProcessing, + result, + error: apiError, + sendVoice, + sendText, + setLevel: setApiLevel, + resetConversation, + } = useSpeaking() + + // 스크롤 자동 이동 + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages, isProcessing]) // isProcessing 추가: 답변 준비 중일 때도 스크롤 이동 + + // 결과 처리 (서버 응답이 오면 메시지 목록에 추가) + useEffect(() => { + if (result) { + // 1. 사용자 메시지 추가 (STT 결과가 있을 경우) + if (result.userTranscript) { + const userMessage = { + id: `user-${Date.now()}`, + content: result.userTranscript, + userTranscript: result.userTranscript, + confidence: result.confidence, + isUser: true, + createdAt: new Date().toISOString(), + } + setMessages((prev) => [...prev, userMessage]) + } + + // 2. AI 응답 메시지 추가 + if (result.aiText) { + const aiMessage = { + id: `ai-${Date.now()}`, + content: result.aiText, + aiText: result.aiText, + aiAudioUrl: result.aiAudioUrl, + isUser: false, + createdAt: new Date().toISOString(), + } + setMessages((prev) => [...prev, aiMessage]) + } + } + }, [result]) + + // 에러 처리 + useEffect(() => { + if (apiError) { + setError(apiError) + } + }, [apiError]) + + /** + * 음성 전송 + */ + const handleSendVoice = useCallback(async (audioBase64) => { + setError(null) + try { + await sendVoice(audioBase64) + } catch (err) { + // 에러는 훅 상태(apiError)를 통해 처리됨 + } + }, [sendVoice]) + + /** + * 텍스트 전송 + */ + const handleSendText = useCallback(async (text) => { + setError(null) + try { + await sendText(text) + } catch (err) { + // 에러는 훅 상태(apiError)를 통해 처리됨 + } + }, [sendText]) + + /** + * 레벨 변경 + */ + const handleLevelChange = useCallback((newLevel) => { + setLevel(newLevel) + setApiLevel(newLevel) + }, [setApiLevel]) + + /** + * 대화 초기화 + */ + const handleReset = useCallback(() => { + setMessages([]) + resetConversation() + }, [resetConversation]) + + return ( + + {/* 헤더 */} + + + {isKorean ? 'AI와 대화하기' : 'Talk with AI'} + + + {isKorean + ? 'Amy와 영어로 자유롭게 대화해보세요' + : 'Have a free conversation with Amy in English!'} + + + + {/* 에러 알림 */} + {error && ( + setError(null)} sx={{ m: 2 }}> + {error} + + )} + + {/* 메시지 영역 */} + + {messages.length === 0 ? ( + // 빈 상태 (안내 메시지) + + + + {isKorean ? '안녕하세요! 저는 Amy예요.' : "Hi! I'm Amy."} + + + {isKorean + ? '마이크 버튼을 눌러 영어로 말해보세요. 제가 대화 상대가 되어드릴게요!' + : 'Press the microphone button and speak in English. I\'ll be your conversation partner!'} + + + {/* 팁 박스 */} + + + {isKorean ? '💡 대화 예시:' : '💡 Example:'} + + + "Hello! How are you today?" + + + "What did you do last weekend?" + + + + ) : ( + // 메시지 목록 + <> + {messages.map((msg) => ( + + ))} + + {/* 처리 중 표시 (로딩 인디케이터) */} + {isProcessing && ( + + + + {isKorean ? 'Amy가 답변을 생각하고 있어요...' : 'Amy is thinking...'} + + + )} + +
+ + )} + + + {/* 입력 영역 (컴포넌트 재사용) */} + + + ) +} \ No newline at end of file diff --git a/src/domains/speaking/services/speakingService.js b/src/domains/speaking/services/speakingService.js new file mode 100644 index 0000000..180cdbe --- /dev/null +++ b/src/domains/speaking/services/speakingService.js @@ -0,0 +1,29 @@ +import speakingApi from '../../../api/speakingApi' + +export const speakingService = { + /** + * 대화 요청 (음성 또는 텍스트) + * POST /speaking/chat + */ + async chat({ sessionId, audio, text, level = 'INTERMEDIATE' }) { + // null/undefined인 필드는 요청 바디에서 제외 + const requestBody = { + ...(sessionId && { sessionId }), + ...(audio && { audio }), + ...(text && { text }), + level, + } + + console.log('[speakingService] Request body:', requestBody) + + return await speakingApi.post('/speaking/chat', requestBody) + }, + + /** + * 대화 초기화 + * POST /speaking/reset + */ + async reset(sessionId) { + return await speakingApi.post('/speaking/reset', { sessionId }) + }, +} \ No newline at end of file diff --git a/src/domains/vocab/components/TestQuestion.jsx b/src/domains/vocab/components/TestQuestion.jsx index e30095b..c3a9f0b 100644 --- a/src/domains/vocab/components/TestQuestion.jsx +++ b/src/domains/vocab/components/TestQuestion.jsx @@ -1,4 +1,5 @@ import {Box, Paper, RadioGroup, Typography} from '@mui/material' +import {useThemeMode} from '../../../contexts/ThemeContext' export default function TestQuestion({ question, @@ -7,6 +8,9 @@ export default function TestQuestion({ showResult = false, disabled = false, }) { + const {mode} = useThemeMode() + const isDark = mode === 'dark' + if (!question) return null const getOptionStyle = (option, index) => { @@ -16,7 +20,7 @@ export default function TestQuestion({ if (!showResult) { return { border: isSelected ? '2px solid #059669' : '2px solid #e7e5e4', - backgroundColor: isSelected ? '#ecfdf5' : '#ffffff', + backgroundColor: isSelected ? '#ecfdf5' : (isDark ? '#27272a' : '#ffffff'), transform: isSelected ? 'scale(1.02)' : 'scale(1)', } } @@ -199,7 +203,7 @@ export default function TestQuestion({ ? '#059669' : isWrong ? '#ef4444' - : '#1c1917', + : (isDark ? '#fafafa' : '#1c1917'), flex: 1, }} > diff --git a/src/domains/vocab/components/WordDetailModal.jsx b/src/domains/vocab/components/WordDetailModal.jsx index 4941aaa..dc4f505 100644 --- a/src/domains/vocab/components/WordDetailModal.jsx +++ b/src/domains/vocab/components/WordDetailModal.jsx @@ -31,6 +31,7 @@ import { WORD_STATUS_LABELS, } from '../constants/vocabConstants' import {useTranslation} from '../../../contexts/SettingsContext' +import {useThemeMode} from '../../../contexts/ThemeContext' export default function WordDetailModal({ open, @@ -44,6 +45,8 @@ export default function WordDetailModal({ isPlayingTTS, }) { const {t} = useTranslation() + const {mode} = useThemeMode() + const isDark = mode === 'dark' const [selectedVoice, setSelectedVoice] = useState(VOICE_TYPES.FEMALE) if (!word) return null @@ -189,7 +192,7 @@ export default function WordDetailModal({ {wordData.korean} @@ -223,7 +226,7 @@ export default function WordDetailModal({ "{wordData.example}" diff --git a/src/domains/vocab/pages/DailyLearning.jsx b/src/domains/vocab/pages/DailyLearning.jsx index dd2b4d9..c0d8c86 100644 --- a/src/domains/vocab/pages/DailyLearning.jsx +++ b/src/domains/vocab/pages/DailyLearning.jsx @@ -33,8 +33,116 @@ import FlashCard from '../components/FlashCard' import {dailyService, userWordService, voiceService} from '../services/vocabService' import {LEVEL_LABELS, LEVELS} from '../constants/vocabConstants' import {useTranslation} from '../../../contexts/SettingsContext' +import {useAuth} from '../../../contexts/AuthContext' +import {useThemeMode} from '../../../contexts/ThemeContext' -const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1' +// 카드 셔플 애니메이션 컴포넌트 +function ShuffleAnimation({count, isKorean, isDark}) { + return ( + + {/* 카드 셔플 애니메이션 */} + + {[...Array(Math.min(count, 5))].map((_, i) => ( + + + 📚 + + + ))} + + + {/* 텍스트 */} + + {isKorean ? '카드를 섞는 중...' : 'Shuffling cards...'} + + + {isKorean + ? `${count}개의 단어를 다시 학습합니다` + : `Reviewing ${count} words again` + } + + + ) +} // Level Selection Screen function LevelSelect({onSelect, loading, t, isKorean}) { @@ -142,6 +250,10 @@ function LevelSelect({onSelect, loading, t, isKorean}) { export default function DailyLearning() { const navigate = useNavigate() const {t, isKorean} = useTranslation() + const {user} = useAuth() + const {mode} = useThemeMode() + const isDark = mode === 'dark' + const userId = user?.userId || user?.username const [phase, setPhase] = useState('loading') const [loading, setLoading] = useState(false) const [error, setError] = useState(null) @@ -154,7 +266,13 @@ export default function DailyLearning() { const [results, setResults] = useState({correct: 0, incorrect: 0}) const [swipeDirection, setSwipeDirection] = useState(null) const [isEntering, setIsEntering] = useState(false) + const [unknownWords, setUnknownWords] = useState([]) // "몰라요" 선택한 단어들 + const [totalWordCount, setTotalWordCount] = useState(0) // 전체 단어 수 (진행률 계산용) + const [isShuffling, setIsShuffling] = useState(false) // 셔플 애니메이션 상태 + const [shuffleCount, setShuffleCount] = useState(0) // 셔플할 단어 수 + // 마운트 시 먼저 level 없이 시도 (기존 학습이 있으면 성공) + // 실패하면 level 선택 화면으로 이동 useEffect(() => { fetchDailyWords() }, []) @@ -164,7 +282,7 @@ export default function DailyLearning() { setLoading(true) setError(null) - const response = await dailyService.getWords(TEMP_USER_ID, level) + const response = await dailyService.getWords(level) const dailyData = response?.data || response const allWords = [ @@ -179,6 +297,7 @@ export default function DailyLearning() { } setWords(allWords) + setTotalWordCount(allWords.length) // 전체 단어 수 저장 const learnedCount = dailyData?.learnedCount || 0 if (learnedCount > 0 && learnedCount < allWords.length) { @@ -212,7 +331,10 @@ export default function DailyLearning() { } const currentWord = words[currentIndex] - const progress = words.length > 0 ? (learnedIds.size / words.length) * 100 : 0 + // 진행률: 학습 완료된 단어 / 전체 단어 수 + const progress = totalWordCount > 0 ? (learnedIds.size / totalWordCount) * 100 : 0 + // 현재 라운드에서 남은 단어 (몰라요 단어 재학습 시 표시용) + const remainingInRound = words.length - currentIndex const playTTS = useCallback(async (word) => { if (!word || isPlayingTTS) return @@ -248,17 +370,19 @@ export default function DailyLearning() { setSwipeDirection(isCorrect ? 'right' : 'left') - try { - await userWordService.update(TEMP_USER_ID, currentWord.wordId, isCorrect) - - setResults(prev => ({ - ...prev, - [isCorrect ? 'correct' : 'incorrect']: prev[isCorrect ? 'correct' : 'incorrect'] + 1 - })) - - setLearnedIds(prev => new Set([...prev, currentWord.wordId])) - } catch (err) { - console.error('Answer update error:', err) + if (isCorrect) { + // "알아요" 선택 - API 호출하고 학습 완료 처리 + try { + await dailyService.markLearned(currentWord.wordId) + setLearnedIds(prev => new Set([...prev, currentWord.wordId])) + setResults(prev => ({...prev, correct: prev.correct + 1})) + } catch (err) { + console.error('Answer update error:', err) + } + } else { + // "몰라요" 선택 - API 호출 X, 나중에 다시 학습하도록 저장 + setUnknownWords(prev => [...prev, currentWord]) + setResults(prev => ({...prev, incorrect: prev.incorrect + 1})) } setTimeout(() => { @@ -269,20 +393,68 @@ export default function DailyLearning() { }, 250) } + // 배열 섞기 함수 + const shuffleArray = (array) => { + const shuffled = [...array] + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]] + } + return shuffled + } + const moveToNext = () => { setIsFlipped(false) if (currentIndex < words.length - 1) { + // 다음 단어로 이동 setCurrentIndex(prev => prev + 1) + } else if (unknownWords.length > 0) { + // 현재 리스트 끝 + "몰라요" 단어 있음 → 셔플 애니메이션 후 다시 학습 + setShuffleCount(unknownWords.length) + setIsShuffling(true) + setTimeout(() => { + const shuffled = shuffleArray(unknownWords) + setWords(shuffled) + setUnknownWords([]) + setCurrentIndex(0) + setIsShuffling(false) + }, 1500) // 1.5초 애니메이션 } else { + // 모든 단어 "알아요" 완료 → 학습 완료 setPhase('complete') } } + // 건너뛰기 - "몰라요"와 동일하게 처리 + const handleSkip = () => { + if (!currentWord || swipeDirection || isShuffling) return + + setIsFlipped(false) + + if (currentIndex < words.length - 1) { + // 다음 단어가 있으면 현재 단어를 unknownWords에 추가하고 다음으로 + setUnknownWords(prev => [...prev, currentWord]) + setCurrentIndex(prev => prev + 1) + } else { + // 마지막 단어 건너뛰기 → 모든 unknown 단어 + 현재 단어로 셔플 + const allUnknown = [...unknownWords, currentWord] + setShuffleCount(allUnknown.length) + setIsShuffling(true) + setTimeout(() => { + const shuffled = shuffleArray(allUnknown) + setWords(shuffled) + setUnknownWords([]) + setCurrentIndex(0) + setIsShuffling(false) + }, 1500) + } + } + const handleToggleBookmark = async () => { if (!currentWord) return try { const newBookmarked = !currentWord.bookmarked - await userWordService.updateTag(TEMP_USER_ID, currentWord.wordId, { + await userWordService.updateTag(userId, currentWord.wordId, { bookmarked: newBookmarked, }) setWords(prev => @@ -296,11 +468,14 @@ export default function DailyLearning() { } const handleRestart = () => { + // 처음부터 다시 시작 - 단어 목록 다시 가져오기 setCurrentIndex(0) setLearnedIds(new Set()) + setUnknownWords([]) setIsFlipped(false) - setPhase('learning') setResults({correct: 0, incorrect: 0}) + setPhase('loading') + fetchDailyWords() // 단어 다시 로드 } // Loading Screen @@ -375,7 +550,7 @@ export default function DailyLearning() { {t('dailyLearning.completedSession')} - + @@ -402,7 +577,7 @@ export default function DailyLearning() { px: 3, py: 1.5, borderRadius: '12px', - backgroundColor: accuracy >= 80 ? '#ecfdf5' : accuracy >= 50 ? '#fff7ed' : '#fef2f2', + backgroundColor: isDark ? (accuracy >= 80 ? '#064e3b' : accuracy >= 50 ? '#78350f' : '#7f1d1d') : (accuracy >= 80 ? '#ecfdf5' : accuracy >= 50 ? '#fff7ed' : '#fef2f2'), }} > - {/* Header */} - - navigate('/vocab')}> - - - - - {currentIndex + 1} / {words.length} - - - setAutoPlayTTS(e.target.checked)} - size="small" - sx={{ - '& .MuiSwitch-switchBase.Mui-checked': { - color: '#059669', - }, - '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { - backgroundColor: '#059669', - }, - }} - /> - } - label={} + <> + {/* 셔플 애니메이션 오버레이 */} + {isShuffling && ( + - + )} - {/* Progress Bar */} - - - - {t('dailyLearning.progress')} - - - {Math.round(progress)}% - - - + {/* Header */} + - + > + navigate('/vocab')}> + + + + + {currentIndex + 1} / {words.length} + + + setAutoPlayTTS(e.target.checked)} + size="small" + sx={{ + '& .MuiSwitch-switchBase.Mui-checked': { + color: '#059669', + }, + '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { + backgroundColor: '#059669', + }, + }} + /> + } + label={} + /> + - {/* FlashCard */} - - playTTS(currentWord)} - isPlayingTTS={isPlayingTTS} - /> - + {/* Progress Bar */} + + + + {t('dailyLearning.progress')} + + + {Math.round(progress)}% + + + + - {/* Answer Buttons */} - - - - - - {/* Navigation */} - - + playTTS(currentWord)} + isPlayingTTS={isPlayingTTS} + /> + - - + - - + {t('dailyLearning.dontKnow')} + + + + + {/* Navigation */} + + + + + + {currentWord?.bookmarked ? ( + + ) : ( + + )} + + + + + + + ) } diff --git a/src/domains/vocab/pages/StatsPage.jsx b/src/domains/vocab/pages/StatsPage.jsx index 0c6ed67..9edff1e 100644 --- a/src/domains/vocab/pages/StatsPage.jsx +++ b/src/domains/vocab/pages/StatsPage.jsx @@ -13,75 +13,182 @@ import { ListItem, ListItemText, Paper, - Tab, - Tabs, - Tooltip, Typography, } from '@mui/material' import { ArrowBack as BackIcon, - CalendarMonth as CalendarIcon, + CheckCircle as CheckIcon, + LocalFireDepartment as FireIcon, + MenuBook as BookIcon, + Quiz as QuizIcon, + School as SchoolIcon, + Timeline as TimelineIcon, TrendingUp as TrendingUpIcon, + VolumeUp as VolumeIcon, Warning as WarningIcon, } from '@mui/icons-material' import {statsService, voiceService} from '../services/vocabService' -import {DIFFICULTY_LABELS, LEVEL_COLORS, LEVEL_LABELS, VOICE_TYPES,} from '../constants/vocabConstants' +import {DIFFICULTY_LABELS, LEVEL_LABELS, VOICE_TYPES} from '../constants/vocabConstants' import {useTranslation} from '../../../contexts/SettingsContext' +import {useAuth} from '../../../contexts/AuthContext' +import {useThemeMode} from '../../../contexts/ThemeContext' import {BadgeSection} from '../../badge' -const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1' - -// 학습 캘린더 히트맵 컴포넌트 +// 학습 캘린더 히트맵 컴포넌트 (GitHub 스타일) function LearningCalendar({data}) { + const [hoveredDay, setHoveredDay] = useState(null) + const [tooltipPos, setTooltipPos] = useState({x: 0, y: 0}) + const today = new Date() + const todayStr = today.toISOString().split('T')[0] + + // 12주(84일) 전부터 오늘까지 const startDate = new Date(today) - startDate.setDate(startDate.getDate() - 83) // 12주 전 + startDate.setDate(startDate.getDate() - 83) + + // 시작일을 해당 주의 일요일로 조정 + const startDayOfWeek = startDate.getDay() + startDate.setDate(startDate.getDate() - startDayOfWeek) const weeks = [] + const monthLabels = [] let currentDate = new Date(startDate) + let lastMonth = -1 - // 12주 데이터 생성 - for (let w = 0; w < 12; w++) { + // 주별로 데이터 생성 + while (currentDate <= today || weeks.length < 13) { const week = [] for (let d = 0; d < 7; d++) { const dateStr = currentDate.toISOString().split('T')[0] - const dayData = data?.find(d => d.date === dateStr) + // 백엔드는 "period" 필드를 사용, 폴백으로 "date"도 지원 + const dayData = data?.find(item => (item.period || item.date) === dateStr) + const isFuture = currentDate > today + + // 월 라벨 추가 (각 주의 첫 날이 새 달이면) + if (d === 0 && currentDate.getMonth() !== lastMonth && !isFuture) { + const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'] + monthLabels.push({ + month: monthNames[currentDate.getMonth()], + weekIndex: weeks.length, + }) + lastMonth = currentDate.getMonth() + } + + // 백엔드는 newWordsLearned 사용, 폴백으로 learnedCount도 지원 + const count = dayData?.newWordsLearned || dayData?.learnedCount || 0 + week.push({ date: dateStr, - count: dayData?.learnedCount || 0, - isToday: dateStr === today.toISOString().split('T')[0], + count, + isToday: dateStr === todayStr, + isFuture, + dayOfWeek: d, }) currentDate.setDate(currentDate.getDate() + 1) } weeks.push(week) + if (weeks.length >= 14) break } - const getColor = (count) => { + // GitHub 스타일 색상 (초록 계열) + const getColor = (count, isFuture) => { + if (isFuture) return 'transparent' if (count === 0) return '#ebedf0' - if (count < 20) return '#9be9a8' - if (count < 40) return '#40c463' - if (count < 55) return '#30a14e' + if (count < 5) return '#9be9a8' + if (count < 15) return '#40c463' + if (count < 30) return '#30a14e' return '#216e39' } + const getLevel = (count) => { + if (count === 0) return 0 + if (count < 5) return 1 + if (count < 15) return 2 + if (count < 30) return 3 + return 4 + } + const dayLabels = ['일', '월', '화', '수', '목', '금', '토'] + const formatDate = (dateStr) => { + const date = new Date(dateStr) + const year = date.getFullYear() + const month = date.getMonth() + 1 + const day = date.getDate() + const weekday = dayLabels[date.getDay()] + return `${year}년 ${month}월 ${day}일 (${weekday})` + } + + const handleMouseEnter = (e, day) => { + if (day.isFuture) return + const rect = e.target.getBoundingClientRect() + setTooltipPos({ + x: rect.left + rect.width / 2, + y: rect.top - 8, + }) + setHoveredDay(day) + } + + // 총 학습량 계산 (백엔드는 newWordsLearned 사용) + const totalLearned = data?.reduce((sum, item) => sum + (item.newWordsLearned || item.learnedCount || 0), 0) || 0 + const activeDays = data?.filter(item => (item.newWordsLearned || item.learnedCount || 0) > 0).length || 0 + return ( - - + + {/* 요약 정보 */} + + + 최근 12주간 {totalLearned}개 단어 학습 + + + + + {/* 월 라벨 */} + + {monthLabels.map((label, idx) => ( + + {label.month} + + ))} + + + {/* 캘린더 그리드 */} + {/* 요일 라벨 */} - + {dayLabels.map((label, idx) => ( {idx % 2 === 1 ? label : ''} @@ -89,85 +196,180 @@ function LearningCalendar({data}) { ))} - {/* 히트맵 그리드 */} + {/* 주별 셀 */} {weeks.map((week, wIdx) => ( - + {week.map((day, dIdx) => ( - - - + onMouseEnter={(e) => handleMouseEnter(e, day)} + onMouseLeave={() => setHoveredDay(null)} + sx={{ + width: 12, + height: 12, + backgroundColor: getColor(day.count, day.isFuture), + borderRadius: '2px', + border: day.isToday + ? '2px solid #10b981' + : day.isFuture + ? 'none' + : '1px solid rgba(27, 31, 35, 0.06)', + cursor: day.isFuture ? 'default' : 'pointer', + transition: 'all 0.15s ease', + boxSizing: 'border-box', + '&:hover': day.isFuture ? {} : { + transform: 'scale(1.2)', + borderColor: 'rgba(27, 31, 35, 0.15)', + boxShadow: '0 1px 3px rgba(0,0,0,0.12)', + }, + }} + /> ))} ))} {/* 범례 */} - - 적음 - {[0, 10, 30, 45, 55].map((count, idx) => ( - - ))} - 많음 + + + 오늘 학습하셨나요? + + + Less + {[0, 1, 2, 3, 4].map((level) => ( + + ))} + More + + + {/* 호버 툴팁 */} + {hoveredDay && ( + + + {hoveredDay.count > 0 + ? `${hoveredDay.count}개 단어 학습` + : '학습 기록 없음'} + + + {formatDate(hoveredDay.date)} + + + )} ) } -// 취약 단어 목록 컴포넌트 -function WeakWordsList({words, onPlayTTS, playingWordId}) { +// 복습 필요 단어 목록 컴포넌트 +function WeakWordsList({words, onPlayTTS, playingWordId, isDark}) { if (!words || words.length === 0) { return ( - - 취약 단어가 없습니다 - + + + + 모든 단어를 잘 학습했어요! + + + 복습이 필요한 단어가 없습니다 + + ) } return ( - {words.map((item, index) => ( + {words.slice(0, 5).map((item, index) => ( + + + {index + 1} + + {item.english} - + {item.incorrectCount > 0 && ( + + {item.incorrectCount}회 오답 + + )} } secondary={item.korean} @@ -176,11 +378,12 @@ function WeakWordsList({words, onPlayTTS, playingWordId}) { size="small" onClick={() => onPlayTTS?.(item)} disabled={playingWordId === item.wordId} + sx={{ + backgroundColor: isDark ? '#3f3f46' : '#f3f4f6', + '&:hover': {backgroundColor: isDark ? '#52525b' : '#e5e7eb'}, + }} > - + ))} @@ -189,32 +392,60 @@ function WeakWordsList({words, onPlayTTS, playingWordId}) { } // 레벨별 진행률 차트 -function LevelProgressChart({data}) { +function LevelProgressChart({data, isDark}) { if (!data) return null + const levelConfig = { + BEGINNER: {icon: '🌱', color: '#10b981', bgColor: '#ecfdf5'}, + INTERMEDIATE: {icon: '🌿', color: '#f97316', bgColor: '#fff7ed'}, + ADVANCED: {icon: '🌳', color: '#ef4444', bgColor: '#fef2f2'}, + } + return ( - + {Object.entries(LEVEL_LABELS).map(([level, label]) => { const levelData = data[level] || {total: 0, learned: 0} - const progress = levelData.total > 0 - ? (levelData.learned / levelData.total) * 100 - : 0 + const progress = levelData.total > 0 ? (levelData.learned / levelData.total) * 100 : 0 + const config = levelConfig[level] return ( - - - - {label} - - - {levelData.learned}/{levelData.total} + + + + + {config.icon} + + + {label} + + + + {levelData.learned} / {levelData.total} ) @@ -229,83 +460,131 @@ function DifficultyChart({data}) { const total = Object.values(data).reduce((sum, val) => sum + val, 0) - const colors = { - EASY: '#4caf50', - NORMAL: '#2196f3', - HARD: '#ff9800', + const config = { + EASY: {label: '쉬움', color: '#10b981', bgColor: '#ecfdf5', icon: '😊'}, + NORMAL: {label: '보통', color: '#3b82f6', bgColor: '#eff6ff', icon: '🤔'}, + HARD: {label: '어려움', color: '#ef4444', bgColor: '#fef2f2', icon: '😰'}, } return ( - - {/* 막대 그래프 */} - - {Object.entries(DIFFICULTY_LABELS).map(([key, label]) => { - const count = data[key] || 0 - const height = total > 0 ? (count / total) * 100 : 0 - - return ( - - - {count} - - - - {label} - - - ) - })} - + + {Object.entries(DIFFICULTY_LABELS).map(([key]) => { + const count = data[key] || 0 + const percentage = total > 0 ? ((count / total) * 100).toFixed(0) : 0 + const cfg = config[key] + + return ( + + {cfg.icon} + + {count} + + + {cfg.label} ({percentage}%) + + + ) + })} ) } -// 통계 요약 카드 -function StatCard({title, value, subtitle, icon: Icon, color}) { +// 히어로 통계 카드 +function HeroStatCard({icon: Icon, label, value, subValue, color, bgGradient}) { return ( - + - - {title} + + {label} - + {value} - {subtitle && ( - - {subtitle} + {subValue && ( + + {subValue} )} - {Icon && ( - - - - )} + + + ) } +// 미니 통계 카드 +function MiniStatCard({icon: Icon, label, value, color, bgColor}) { + return ( + + + + + + + {value} + + + {label} + + + + ) +} + export default function StatsPage() { const navigate = useNavigate() - const {t} = useTranslation() - const [tab, setTab] = useState(0) // 0: 일간, 1: 주간, 2: 월간 + const {t, isKorean} = useTranslation() + const {user} = useAuth() + const {mode} = useThemeMode() + const isDark = mode === 'dark' const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -323,26 +602,29 @@ export default function StatsPage() { fetchAllStats() }, []) - useEffect(() => { - fetchPeriodStats() - }, [tab]) - const fetchAllStats = async () => { try { setLoading(true) setError(null) const [overviewRes, dailyRes, weakRes] = await Promise.all([ - statsService.getOverall(TEMP_USER_ID), - statsService.getDaily(TEMP_USER_ID, {limit: 84}), - statsService.getWeakness(TEMP_USER_ID), + statsService.getOverall(), + statsService.getDaily(null, {limit: 84}), + statsService.getWeakness(), ]) - setOverviewStats(overviewRes?.data) - setCalendarData(dailyRes?.data?.dailyStats || []) - setWeakWords(weakRes?.data?.weakWords || []) - setLevelProgress(overviewRes?.data?.levelProgress) - setDifficultyDist(overviewRes?.data?.difficultyDistribution) + // API 응답 데이터 접근 (data 필드 또는 직접 접근) + const overview = overviewRes?.data || overviewRes + const daily = dailyRes?.data || dailyRes + const weak = weakRes?.data || weakRes + + setOverviewStats(overview) + // 다양한 응답 형식 지원: history, dailyStats, 또는 배열 자체 + const calendarHistory = daily?.history || daily?.dailyStats || (Array.isArray(daily) ? daily : []) + setCalendarData(calendarHistory) + setWeakWords(weak?.frequentMistakes || weak?.weakWords || weak?.weakestWords || []) + setLevelProgress(overview?.levelProgress) + setDifficultyDist(overview?.difficultyDistribution) } catch (err) { console.error('Fetch stats error:', err) setError('통계를 불러오는데 실패했습니다.') @@ -351,19 +633,6 @@ export default function StatsPage() { } } - const fetchPeriodStats = async () => { - // 기간별 통계는 getDaily로 처리 - try { - const limits = [7, 30, 90] // 일간, 주간, 월간 - const response = await statsService.getDaily(TEMP_USER_ID, { - limit: limits[tab], - }) - // 기간별 통계 처리 - } catch (err) { - console.error('Period stats error:', err) - } - } - const handlePlayTTS = async (word) => { if (playingWordId) return @@ -392,122 +661,195 @@ export default function StatsPage() { return ( - + ) } + // 데이터 추출 + const totalLearned = overviewStats?.totalLearned || overviewStats?.newWordsLearned || 0 + const successRate = overviewStats?.successRate || overviewStats?.averageAccuracy || 0 + const currentStreak = overviewStats?.currentStreak || overviewStats?.streakDays || 0 + const longestStreak = overviewStats?.longestStreak || currentStreak + const testsCompleted = overviewStats?.testsCompleted || 0 + const correctAnswers = overviewStats?.correctAnswers || 0 + const incorrectAnswers = overviewStats?.incorrectAnswers || 0 + const wordsReviewed = overviewStats?.wordsReviewed || 0 + return ( - + {/* 헤더 */} - navigate('/vocab')}> + navigate('/vocab')} + sx={{ + backgroundColor: isDark ? '#3f3f46' : '#f3f4f6', + '&:hover': {backgroundColor: isDark ? '#52525b' : '#e5e7eb'}, + }} + > - - {t('stats.title')} - + + + 학습 통계 + + + {user?.username || '사용자'}님의 학습 현황 + + {error && ( - setError(null)}> + setError(null)}> {error} )} - {/* 기간 탭 */} - setTab(v)} - sx={{mb: 3}} - variant="fullWidth" - > - - - - - - {/* 요약 카드 */} + {/* 히어로 섹션 - 핵심 통계 */} - - + - - + - - + - - + - {/* 학습 캘린더 */} - - - {t('stats.learningHistory')} + {/* 추가 통계 미니 카드 */} + + + 상세 통계 - + + + + + + + + + + + + + + - {/* 레벨별 진행률 */} - - - {t('stats.levelProgress')} - - + {/* 학습 캘린더 */} + + + + + 학습 기록 + + + {/* 난이도 분포 */} - - - {t('stats.difficultyDist')} - - - + {difficultyDist && ( + + + 체감 난이도 분포 + + + + )} - {/* 취약 단어 */} - - - - {t('stats.weakWordsTop10')} + {/* 레벨별 진행률 */} + {levelProgress && ( + + + 레벨별 학습 진행률 - navigate('/vocab/daily?mode=weak')} - /> + + + )} + + {/* 복습이 필요한 단어 */} + + + + + + 복습이 필요한 단어 + + + {weakWords.length > 0 && ( + navigate('/vocab/daily?mode=weak')} + /> + )} diff --git a/src/domains/vocab/pages/TestPage.jsx b/src/domains/vocab/pages/TestPage.jsx index 329a9d6..2a5124a 100644 --- a/src/domains/vocab/pages/TestPage.jsx +++ b/src/domains/vocab/pages/TestPage.jsx @@ -26,12 +26,13 @@ import { import TestQuestion from '../components/TestQuestion' import {testService} from '../services/vocabService' import {useTranslation} from '../../../contexts/SettingsContext' +import {useAuth} from '../../../contexts/AuthContext' +import {useThemeMode} from '../../../contexts/ThemeContext' -const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1' const QUESTION_TIME_LIMIT = 5 // Test Setup Screen -function TestSetup({onStart, recentResults, loading, t}) { +function TestSetup({onStart, recentResults, loading, t, isDark}) { return ( @@ -183,6 +184,7 @@ function TestInProgress({ onPrev, onSubmit, t, + isDark, }) { const currentQuestion = questions[currentIndex] const progress = ((currentIndex + 1) / questions.length) * 100 @@ -205,14 +207,14 @@ function TestInProgress({ px: 2, py: 1, borderRadius: '12px', - backgroundColor: timeRemaining <= 2 ? '#fef2f2' : '#f5f5f4', + backgroundColor: timeRemaining <= 2 ? '#fef2f2' : (isDark ? '#3f3f46' : '#f5f5f4'), }} > - + {timeRemaining} @@ -300,8 +302,8 @@ function TestInProgress({ ? '#059669' : idx === currentIndex ? '#10b981' - : '#f5f5f4', - color: answers[q.wordId] || idx === currentIndex ? 'white' : '#57534e', + : (isDark ? '#3f3f46' : '#f5f5f4'), + color: answers[q.wordId] || idx === currentIndex ? 'white' : (isDark ? '#a1a1aa' : '#57534e'), transition: 'all 0.2s ease', }} > @@ -314,7 +316,7 @@ function TestInProgress({ } // Result Screen -function TestResult({result, onRetry, onHome, t}) { +function TestResult({result, onRetry, onHome, t, isDark}) { const score = result.successRate || 0 const isGreat = score >= 80 const isGood = score >= 60 @@ -353,7 +355,7 @@ function TestResult({result, onRetry, onHome, t}) { {result.totalQuestions || 0} {t('test.question')} / {result.correctCount || 0} {t('test.correct')} - + : @@ -457,6 +459,10 @@ function TestResult({result, onRetry, onHome, t}) { export default function TestPage() { const navigate = useNavigate() const {t} = useTranslation() + const {user} = useAuth() + const {mode} = useThemeMode() + const isDark = mode === 'dark' + const userId = user?.userId || user?.username const [phase, setPhase] = useState('setup') const [loading, setLoading] = useState(false) const [error, setError] = useState(null) @@ -497,7 +503,7 @@ export default function TestPage() { const fetchRecentResults = async () => { try { - const response = await testService.getResults(TEMP_USER_ID, {limit: 5}) + const response = await testService.getResults(userId, {limit: 5}) setRecentResults(response?.testResults || []) } catch (err) { console.error('Fetch results error:', err) @@ -508,7 +514,7 @@ export default function TestPage() { try { setLoading(true) setError(null) - const response = await testService.start(TEMP_USER_ID, 'DAILY') + const response = await testService.start(userId, 'DAILY') const testData = response?.data || response if (testData?.testId) { @@ -554,7 +560,7 @@ export default function TestPage() { answer: answers[q.wordId] || '', })) - const response = await testService.submit(TEMP_USER_ID, testId, answersArray) + const response = await testService.submit(userId, testId, answersArray) const resultData = response?.data || response if (resultData) { @@ -597,7 +603,7 @@ export default function TestPage() { )} {phase === 'setup' && - } + } {phase === 'testing' && questions.length > 0 && ( )} {phase === 'result' && result && ( - navigate('/vocab')} t={t}/> + navigate('/vocab')} t={t} isDark={isDark}/> )} ) diff --git a/src/domains/vocab/pages/VocabDashboard.jsx b/src/domains/vocab/pages/VocabDashboard.jsx index 7667110..fe0f37b 100644 --- a/src/domains/vocab/pages/VocabDashboard.jsx +++ b/src/domains/vocab/pages/VocabDashboard.jsx @@ -14,28 +14,37 @@ import { LinearProgress, Tooltip, Typography, + useTheme, } from '@mui/material' import { + ArrowForward as ArrowIcon, CheckCircle as CheckIcon, - EmojiEvents as TrophyIcon, LocalFireDepartment as FireIcon, MenuBook as VocabIcon, PlayArrow as PlayIcon, Quiz as TestIcon, + School as LearnIcon, Star as StarIcon, StarBorder as StarBorderIcon, + Timeline as StatsIcon, TrendingUp as TrendingIcon, VolumeUp as VolumeIcon, + Warning as WarningIcon, } from '@mui/icons-material' import {dailyService, statsService, userWordService, voiceService} from '../services/vocabService' -import {DAILY_GOAL, LEVEL_LABELS,} from '../constants/vocabConstants' +import {DAILY_GOAL} from '../constants/vocabConstants' import {useTranslation} from '../../../contexts/SettingsContext' - -const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1' +import {useAuth} from '../../../contexts/AuthContext' +import {useThemeMode} from '../../../contexts/ThemeContext' export default function VocabDashboard() { const navigate = useNavigate() + const theme = useTheme() + const {mode} = useThemeMode() + const isDark = mode === 'dark' const {t, isKorean} = useTranslation() + const {user} = useAuth() + const userId = user?.userId || user?.username const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [dailyData, setDailyData] = useState(null) @@ -54,16 +63,20 @@ export default function VocabDashboard() { setError(null) const [daily, stats, weekly, weakness] = await Promise.all([ - dailyService.getWords(TEMP_USER_ID).catch(() => null), - statsService.getOverall(TEMP_USER_ID).catch(() => null), - statsService.getDaily(TEMP_USER_ID, {limit: 7}).catch(() => null), - statsService.getWeakness(TEMP_USER_ID).catch(() => null), + dailyService.getWords().catch(() => null), + statsService.getOverall().catch(() => null), + statsService.getDaily(null, {limit: 7}).catch(() => null), + statsService.getWeakness().catch(() => null), ]) setDailyData(daily) setStatsData(stats) - setWeeklyStats(weekly?.dailyStats || []) - setWeakWords(weakness?.weakestWords?.slice(0, 5) || []) + // API: { data: { history: [...] } } 또는 mock: { history: [...] } + const weeklyData = weekly?.data || weekly + setWeeklyStats(weeklyData?.history || weeklyData?.dailyStats || []) + // API: { data: { frequentMistakes: [...] } } 또는 mock: { frequentMistakes: [...] } + const weaknessData = weakness?.data || weakness + setWeakWords(weaknessData?.frequentMistakes?.slice(0, 5) || weaknessData?.weakestWords?.slice(0, 5) || []) } catch (err) { console.error('Dashboard fetch error:', err) setError('Failed to load data.') @@ -81,6 +94,8 @@ export default function VocabDashboard() { audio.onended = () => setPlayingTTS(null) audio.onerror = () => setPlayingTTS(null) await audio.play() + } else { + setPlayingTTS(null) } } catch (err) { console.error('TTS error:', err) @@ -90,7 +105,7 @@ export default function VocabDashboard() { const handleToggleBookmark = async (word) => { try { - await userWordService.updateTag(TEMP_USER_ID, word.wordId, { + await userWordService.updateTag(userId, word.wordId, { bookmarked: !word.bookmarked, }) setWeakWords((prev) => @@ -113,36 +128,45 @@ export default function VocabDashboard() { ) } - const learnedCount = dailyData?.learnedCount || 0 - const totalWords = dailyData?.totalWords || DAILY_GOAL.TOTAL - const progress = totalWords > 0 ? (learnedCount / totalWords) * 100 : 0 - const newWordsCount = dailyData?.newWords?.length || 0 - const reviewWordsCount = dailyData?.reviewWords?.length || 0 + // API 응답 구조: { status, message, data: { dailyStudy, progress, newWords, reviewWords } } + // 또는 mock: { dailyStudy, progress, newWords, reviewWords } + const daily = dailyData?.data || dailyData + const learnedCount = daily?.progress?.learned || daily?.dailyStudy?.learnedCount || 0 + const totalWords = daily?.progress?.total || daily?.dailyStudy?.totalWords || DAILY_GOAL.TOTAL + const progress = daily?.progress?.percentage ?? (totalWords > 0 ? (learnedCount / totalWords) * 100 : 0) + const isCompleted = daily?.progress?.isCompleted || daily?.dailyStudy?.isCompleted || false + + // 통계 데이터 (API: { status, data: {...} } 또는 mock: {...}) + const stats = statsData?.data || statsData + const currentStreak = stats?.currentStreak || stats?.streakDays || 0 + const longestStreak = stats?.longestStreak || 0 + const wordsLearned = stats?.newWordsLearned || stats?.totalLearned || 0 + const successRate = stats?.successRate || stats?.averageAccuracy || 0 + const testsCompleted = stats?.testsCompleted || stats?.testCount || 0 - // Calculate streak from weekly stats - const streak = weeklyStats.filter(s => s?.isCompleted).length + // 주간 통계에서 완료일 수 계산 + const weeklyCompleted = weeklyStats.filter(s => s?.isCompleted).length return ( {/* Header */} - - + + - + - + {t('vocabDash.title')} @@ -153,504 +177,541 @@ export default function VocabDashboard() { {error && ( - + {error} )} - {/* Hero Progress Card */} - - {/* Decorative Elements */} - - - - - - - + {/* 오늘의 학습 카드 */} + + + + + + + + {isKorean ? '오늘의 학습' : "Today's Learning"} + + + + {Math.round(progress)} + + % + + + {isCompleted && ( + } + label={isKorean ? '완료' : 'Done'} + sx={{ + backgroundColor: 'white', + color: '#059669', + fontWeight: 700, + }} + /> + )} + + + + + + {learnedCount} / {totalWords} {isKorean ? '단어' : 'words'} + + + {totalWords - learnedCount} {isKorean ? '남음' : 'left'} + + + + + + + + + + + {/* 연속 학습 카드 */} + + + + + + {currentStreak} - - {Math.round(progress)}% + + {isKorean ? '일 연속 학습' : 'Day Streak'} - + + + {isKorean ? '최장 기록' : 'Best'}: {longestStreak} {isKorean ? '일' : 'days'} + + + + + + - {streak > 0 && ( + {/* 통계 요약 카드 4개 */} + + + navigate('/vocab/stats')} + > + - - - - {streak} - - - {t('vocabDash.days')} - - + - )} - - - - - - {learnedCount} / {totalWords} {t('vocabDash.wordsLearned')} + + {wordsLearned} - - - - - - - - {isKorean ? '새 단어' : 'New Words'} - - - {newWordsCount} / {DAILY_GOAL.NEW_WORDS} - - - - - {isKorean ? '복습' : 'Review'} + + {isKorean ? '학습한 단어' : 'Words Learned'} - - {reviewWordsCount} / {DAILY_GOAL.REVIEW_WORDS} - - - - - - - + + + - {/* Quick Actions */} - - + navigate('/vocab/stats')} > - + - + - - {t('vocabDash.viewStats')} + + {successRate.toFixed?.(0) || 0}% - - {statsData?.totalWords || 0} + + {isKorean ? '정답률' : 'Accuracy'} - - {t('vocabDash.wordsLearned')} + + + + + + navigate('/vocab/test')} + > + + + + + + {testsCompleted} - + {isKorean ? '테스트 완료' : 'Tests Done'} + + + + + + + navigate('/vocab/words')} + > + + + > + + + + {weeklyCompleted}/7 + + + {isKorean ? '이번주 완료' : 'This Week'} + + - + {/* 빠른 액션 */} + + navigate('/vocab/test')} > - + - + - - {t('vocabDash.takeQuiz')} - - - {statsData?.avgSuccessRate?.toFixed(0) || 0}% - - - {isKorean ? '평균 점수' : 'average score'} - - + + + {isKorean ? '퀴즈 풀기' : 'Take Quiz'} + + + {isKorean ? '실력을 테스트해보세요' : 'Test your knowledge'} + + + - + navigate('/vocab/words')} > - + - + - - {t('vocabDash.viewWordList')} - - - {statsData?.wordStatusCounts?.MASTERED || 0} - - - {isKorean ? '마스터' : 'mastered'} - - + + {isKorean ? '단어장' : 'Word List'} + + + {isKorean ? '학습한 단어 보기' : 'View your words'} + + + + + + + + + navigate('/vocab/stats')} + > + + + > + + + + + {isKorean ? '학습 통계' : 'Statistics'} + + + {isKorean ? '상세 통계 보기' : 'View detailed stats'} + + + - {/* Weekly Progress */} - + {/* 주간 학습 현황 */} + - - {t('vocabDash.weeklyProgress')} - - - {(isKorean ? ['월', '화', '수', '목', '금', '토', '일'] : ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']).map((day, index) => { - const stat = weeklyStats[index] - const isCompleted = stat?.isCompleted - const hasProgress = stat?.learnedCount > 0 - const isToday = index === new Date().getDay() - 1 || (new Date().getDay() === 0 && index === 6) + + + {isKorean ? '이번주 학습 현황' : 'This Week'} + + = 7 ? 'success' : 'default'} + /> + - return ( - - - {day} - + + {(() => { + const days = isKorean ? ['월', '화', '수', '목', '금', '토', '일'] : ['M', 'T', 'W', 'T', 'F', 'S', 'S'] + const today = new Date() + const dayOfWeek = today.getDay() + const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek + + return days.map((day, index) => { + const date = new Date(today) + date.setDate(today.getDate() + mondayOffset + index) + const dateStr = date.toISOString().split('T')[0] + + // 해당 날짜의 통계 찾기 + const stat = weeklyStats.find(s => s?.period === dateStr || s?.date === dateStr) + const isCompleted = stat?.isCompleted + const hasProgress = (stat?.newWordsLearned || stat?.learnedCount || 0) > 0 + const isToday = date.toDateString() === today.toDateString() + const isFuture = date > today + + return ( - {isCompleted ? ( - + + {day} + + + {date.getDate()} + + + {isCompleted ? ( - - ) : hasProgress ? ( - + ) : hasProgress ? ( - {stat?.learnedCount} + {stat?.newWordsLearned || stat?.learnedCount || 0} - - ) : ( - - )} + ) : isFuture ? null : ( + + X + + )} + - - ) - })} + ) + }) + })()} - {/* Weak Words */} + {/* 취약 단어 */} {weakWords.length > 0 && ( - + + - {t('vocabDash.focusWords')} + {isKorean ? '복습이 필요한 단어' : 'Words to Review'} - + - - {isKorean ? '추가 연습이 필요한 단어입니다' : 'These words need extra attention'} - - {weakWords.map((word, index) => ( - - - - + + {weakWords.map((word) => ( + + + {word.english} + + {word.korean} + + + + + + handlePlayTTS(word)} + disabled={playingTTS === word.wordId} + > + + + + + handleToggleBookmark(word)}> + {word.bookmarked ? ( + + ) : ( + + )} + + - - {word.korean} - - - - - - handlePlayTTS(word)} - disabled={playingTTS === word.wordId} - sx={{ - backgroundColor: playingTTS === word.wordId ? 'primary.main' : 'transparent', - '&:hover': {backgroundColor: 'rgba(5, 150, 105, 0.1)'}, - }} - > - - - - - handleToggleBookmark(word)}> - {word.bookmarked ? ( - - ) : ( - - )} - - - - - ))} + ))} + )} diff --git a/src/domains/vocab/pages/WordListPage.jsx b/src/domains/vocab/pages/WordListPage.jsx index e9c82c8..be858c3 100644 --- a/src/domains/vocab/pages/WordListPage.jsx +++ b/src/domains/vocab/pages/WordListPage.jsx @@ -19,6 +19,7 @@ import { Clear as ClearIcon, ErrorOutline as ErrorIcon, LibraryBooks as WordListIcon, + Newspaper as NewsIcon, Search as SearchIcon, Star as StarIcon, StarBorder as StarBorderIcon, @@ -28,8 +29,9 @@ import WordDetailModal from '../components/WordDetailModal' import {myWordService, voiceService} from '../services/vocabService' import {LEVEL_LABELS, WORD_STATUS_LABELS,} from '../constants/vocabConstants' import {useTranslation} from '../../../contexts/SettingsContext' +import {useAuth} from '../../../contexts/AuthContext' +import {useThemeMode} from '../../../contexts/ThemeContext' -const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1' const PAGE_SIZE = 20 // 디바운스 훅 @@ -47,15 +49,32 @@ function useDebounce(value, delay) { export default function WordListPage() { const navigate = useNavigate() const {t} = useTranslation() + const {user} = useAuth() + const {mode} = useThemeMode() + const isDark = mode === 'dark' + const userId = user?.userId || user?.username const [searchParams] = useSearchParams() const observerRef = useRef(null) const loadMoreRef = useRef(null) const initialFilter = searchParams.get('filter') + const initialCategory = searchParams.get('category') const [filterMode, setFilterMode] = useState(initialFilter || 'all') + const [categoryFilter, setCategoryFilter] = useState(initialCategory || 'all') const [searchText, setSearchText] = useState('') + // 카테고리 목록 + const categories = [ + { id: 'all', label: t('wordList.categoryAll'), color: '#059669' }, + { id: 'DAILY', label: '일상', labelEn: 'Daily', color: '#10B981' }, + { id: 'BUSINESS', label: '비즈니스', labelEn: 'Business', color: '#F59E0B' }, + { id: 'ACADEMIC', label: '학술', labelEn: 'Academic', color: '#8B5CF6' }, + { id: 'TRAVEL', label: '여행', labelEn: 'Travel', color: '#06B6D4' }, + { id: 'TECHNOLOGY', label: '기술', labelEn: 'Tech', color: '#3B82F6' }, + { id: 'NEWS', label: '뉴스', labelEn: 'News', color: '#EC4899', icon: NewsIcon }, + ] + const [userWords, setUserWords] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) @@ -81,13 +100,18 @@ export default function WordListPage() { cursor: reset ? undefined : cursor, } + // 카테고리 필터 + if (categoryFilter && categoryFilter !== 'all') { + params.category = categoryFilter + } + if (filterMode === 'bookmarked') { params.bookmarked = true } else if (filterMode === 'incorrect') { params.incorrectOnly = true } - const response = await myWordService.getList(TEMP_USER_ID, params) + const response = await myWordService.getList(userId, params) const data = response?.data || response const newWords = data?.userWords || [] @@ -100,14 +124,14 @@ export default function WordListPage() { } finally { setLoading(false) } - }, [loading, cursor, filterMode]) + }, [loading, cursor, filterMode, categoryFilter]) useEffect(() => { setUserWords([]) setCursor(null) setHasMore(true) fetchUserWords(true) - }, [filterMode]) + }, [filterMode, categoryFilter]) useEffect(() => { if (loading || !hasMore) return @@ -168,7 +192,7 @@ export default function WordListPage() { const newBookmarked = !word.bookmarked try { - await myWordService.toggleBookmark(TEMP_USER_ID, word.wordId, newBookmarked) + await myWordService.toggleBookmark(userId, word.wordId, newBookmarked) setUserWords(prev => prev.map(w => @@ -275,12 +299,59 @@ export default function WordListPage() { sx={{ mb: 3, '& .MuiOutlinedInput-root': { - backgroundColor: 'white', + backgroundColor: isDark ? '#27272a' : 'white', }, }} /> - {/* 필터 탭 */} + {/* 카테고리 필터 */} + + + {t('wordList.category') || '카테고리'} + + + {categories.map((cat) => { + const isSelected = categoryFilter === cat.id + const IconComponent = cat.icon + + return ( + : undefined} + onClick={() => setCategoryFilter(cat.id)} + sx={{ + fontWeight: 600, + fontSize: 12, + backgroundColor: isSelected + ? (cat.color || '#059669') + : (isDark ? '#3f3f46' : '#F3F4F6'), + color: isSelected ? 'white' : (isDark ? '#a1a1aa' : '#6B7280'), + border: '2px solid', + borderColor: isSelected ? (cat.color || '#059669') : 'transparent', + transition: 'all 0.2s ease', + '&:hover': { + backgroundColor: isSelected + ? (cat.color || '#059669') + : (isDark ? '#52525b' : '#E5E7EB'), + }, + '& .MuiChip-icon': { + color: isSelected ? 'white' : (cat.color || '#6B7280'), + }, + }} + /> + ) + })} + + + + {/* 상태 필터 탭 */} - + {word.english} {word.level && ( @@ -378,9 +449,26 @@ export default function WordListPage() { }} /> )} + {word.category === 'NEWS' && ( + } + label="뉴스" + size="small" + sx={{ + height: 22, + fontSize: 11, + fontWeight: 600, + backgroundColor: '#FCE7F3', + color: '#EC4899', + '& .MuiChip-icon': { + color: '#EC4899', + }, + }} + /> + )} - + {word.korean} @@ -407,13 +495,13 @@ export default function WordListPage() { sx={{ width: 40, height: 40, - backgroundColor: playingWordId === word.wordId ? '#059669' : '#f5f5f4', - '&:hover': {backgroundColor: playingWordId === word.wordId ? '#047857' : '#e7e5e4'}, + backgroundColor: playingWordId === word.wordId ? '#059669' : (isDark ? '#3f3f46' : '#f5f5f4'), + '&:hover': {backgroundColor: playingWordId === word.wordId ? '#047857' : (isDark ? '#52525b' : '#e7e5e4')}, }} > {word.bookmarked ? ( @@ -457,7 +545,7 @@ export default function WordListPage() { width: 64, height: 64, borderRadius: '16px', - backgroundColor: '#f5f5f4', + backgroundColor: isDark ? '#3f3f46' : '#f5f5f4', display: 'flex', alignItems: 'center', justifyContent: 'center', diff --git a/src/domains/vocab/services/vocabService.js b/src/domains/vocab/services/vocabService.js index 613b1d7..4f15560 100644 --- a/src/domains/vocab/services/vocabService.js +++ b/src/domains/vocab/services/vocabService.js @@ -1,7 +1,7 @@ import vocabApi from '../../../api/vocabApi' -// Mock 데이터 사용 여부 (true: 목 데이터 사용, false: 실제 API 호출) -const USE_MOCK = true +// Mock 데이터 사용 여부 (환경변수로 제어: VITE_USE_MOCK=true) +const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true' // ============================================ // Mock 데이터 @@ -235,7 +235,17 @@ const withMock = (apiCall, mockData) => { // interceptor가 response.data를 반환하므로 mockData를 직접 반환 return Promise.resolve(mockData) } - return apiCall().catch(() => mockData) + // 실제 API 호출 시 응답의 data 필드 추출 (백엔드 응답: { isSuccess, message, data }) + return apiCall() + .then(response => response.data || response) + .catch((err) => { + // 400 에러(level required 등)는 re-throw하여 컴포넌트에서 처리 + if (err.response?.status === 400) { + throw err + } + // 네트워크 에러 등은 mock fallback + return mockData + }) } /** @@ -245,7 +255,7 @@ export const wordService = { // GET /words - 단어 목록 조회 getList: ({level, category, limit = 20, cursor} = {}) => withMock( - () => vocabApi.get('/words', {params: {level, category, limit, cursor}}), + () => vocabApi.get('/vocab/words', {params: {level, category, limit, cursor}}), { words: mockWords.filter(w => (!level || w.level === level) && (!category || w.category === category)).slice(0, limit), hasMore: false, @@ -256,7 +266,7 @@ export const wordService = { // GET /words - 단어 목록 조회 (별칭) getWords: (params) => withMock( - () => vocabApi.get('/words', {params}), + () => vocabApi.get('/vocab/words', {params}), {words: mockWords, hasMore: false} ), @@ -284,14 +294,14 @@ export const wordService = { // POST /words/batch - 배치 단어 생성 createBatch: (words) => withMock( - () => vocabApi.post('/words/batch', {words}), + () => vocabApi.post('/vocab/words/batch', {words}), {successCount: words.length, failCount: 0, totalRequested: words.length} ), // POST /words/batch/get - 배치 단어 조회 getBatch: (wordIds) => withMock( - () => vocabApi.post('/words/batch/get', {wordIds}), + () => vocabApi.post('/vocab/words/batch/get', {wordIds}), { words: mockWords.filter(w => wordIds.includes(w.wordId)), requestedCount: wordIds.length, @@ -301,31 +311,45 @@ export const wordService = { } /** - * 일일 학습 API - Backend: POST /daily-study/record, GET /user-words/review + * 일일 학습 API - Backend: GET /vocab/daily?level={level}, POST /vocab/daily/words/{wordId}/learned + * userId는 토큰에서 추출됨 */ export const dailyService = { - // 일일 학습용 단어 조회 (새 단어 + 복습 단어) - getWords: (userId, level) => + // GET /vocab/daily?level={level} - 오늘의 학습 단어 조회 + // 첫 호출 시 자동으로 생성됨 + getWords: (level) => withMock( - () => vocabApi.get('/user-words/review', {params: {userId, ...(level ? {level} : {})}}), + () => vocabApi.get('/vocab/daily', {params: {level: level?.toUpperCase()}}), { - newWords: mockWords.filter(w => !level || w.level === level).slice(0, 10), + dailyStudy: { + date: new Date().toISOString().split('T')[0], + totalWords: 55, + learnedCount: 0, + isCompleted: false, + }, + newWords: mockWords.filter(w => !level || w.level === level.toUpperCase()).slice(0, 50), reviewWords: mockUserWords.filter(w => w.status === 'REVIEWING').slice(0, 5), - learnedCount: 0, - isCompleted: false, + progress: { + total: 55, + learned: 0, + remaining: 55, + percentage: 0, + isCompleted: false, + }, } ), - // POST /daily-study/record - 일일 학습 기록 - markLearned: (userId, wordId, isCorrect, studyType = 'REVIEW') => + // POST /vocab/daily/words/{wordId}/learned - 단어 학습 완료 표시 + // body 필요 없음 (userId는 토큰에서 추출) + markLearned: (wordId) => withMock( - () => vocabApi.post('/daily-study/record', {userId, wordId, isCorrect, studyType}), + () => vocabApi.post(`/vocab/daily/words/${wordId}/learned`), { - userId, - date: new Date().toISOString().split('T')[0], - wordsStudied: 1, - correctCount: isCorrect ? 1 : 0, - incorrectCount: isCorrect ? 0 : 1, + total: 55, + learned: 1, + remaining: 54, + percentage: 1.82, + isCompleted: false, } ), } @@ -337,7 +361,7 @@ export const userWordService = { // GET /user-words/review - 복습 예정 단어 조회 getList: (userId, {status, limit = 20, cursor, date} = {}) => withMock( - () => vocabApi.get('/user-words/review', {params: {userId, status, limit, cursor, date}}), + () => vocabApi.get('/vocab/user-words', {params: {userId, status, limit, cursor, date}}), { userWords: mockUserWords.filter(w => !status || w.status === status).slice(0, limit), hasMore: false, @@ -348,14 +372,14 @@ export const userWordService = { // GET /user-words/review - 사용자 단어 조회 (별칭) getUserWords: (userId, params) => withMock( - () => vocabApi.get('/user-words/review', {params: {userId, ...params}}), + () => vocabApi.get('/vocab/user-words', {params: {userId, ...params}}), {words: mockUserWords, hasMore: false} ), - // POST /user-words/{wordId}/review - 사용자 단어 학습 업데이트 + // PUT /user-words/{wordId} - 사용자 단어 학습 업데이트 update: (userId, wordId, isCorrect) => withMock( - () => vocabApi.post(`/user-words/${wordId}/review`, {userId, isCorrect}), + () => vocabApi.put(`/vocab/user-words/${wordId}`, {userId, isCorrect}), { userId, wordId, @@ -371,16 +395,18 @@ export const userWordService = { ), // PATCH /user-words/{wordId}/tag - 사용자 단어 태그 업데이트 + // userId는 토큰에서 추출되므로 body에 포함하지 않음 updateTag: (userId, wordId, {bookmarked, favorite, difficulty}) => withMock( - () => vocabApi.patch(`/user-words/${wordId}/tag`, {userId, bookmarked, favorite, difficulty}), + () => vocabApi.patch(`/vocab/user-words/${wordId}/tag`, {bookmarked, favorite, difficulty}), {success: true, userId, wordId, bookmarked, favorite, difficulty} ), // PATCH /user-words/{wordId}/tag - 사용자 단어 업데이트 (별칭) + // userId는 토큰에서 추출되므로 body에 포함하지 않음 updateUserWord: (userId, wordId, data) => withMock( - () => vocabApi.patch(`/user-words/${wordId}/tag`, {userId, ...data}), + () => vocabApi.patch(`/vocab/user-words/${wordId}/tag`, data), {success: true, ...data} ), } @@ -389,15 +415,16 @@ export const userWordService = { * 나의 단어장 API - 북마크/오답 필터링 */ export const myWordService = { - // GET /user-words/review - 나의 단어 목록 (필터링) - getList: (userId, {bookmarked, incorrectOnly, limit = 20, cursor} = {}) => + // GET /user-words - 나의 단어 목록 (필터링) + // category: NEWS, DAILY, BUSINESS, ACADEMIC, TRAVEL, TECHNOLOGY + getList: (userId, {category, bookmarked, incorrectOnly, limit = 20, cursor} = {}) => withMock( - () => vocabApi.get('/user-words/review', { - params: {userId, bookmarked, incorrectOnly, limit, cursor} + () => vocabApi.get('/vocab/user-words', { + params: {userId, category, bookmarked, incorrectOnly, limit, cursor} }), { userWords: mockUserWords - .filter(w => (!bookmarked || w.bookmarked) && (!incorrectOnly || w.incorrectCount > 0)) + .filter(w => (!category || w.category === category) && (!bookmarked || w.bookmarked) && (!incorrectOnly || w.incorrectCount > 0)) .slice(0, limit), hasMore: false, } @@ -406,21 +433,22 @@ export const myWordService = { // 북마크된 단어 조회 getBookmarked: (userId, {limit = 20, cursor} = {}) => withMock( - () => vocabApi.get('/user-words/review', {params: {userId, bookmarked: true, limit, cursor}}), + () => vocabApi.get('/vocab/user-words', {params: {userId, bookmarked: true, limit, cursor}}), {userWords: mockUserWords.filter(w => w.bookmarked).slice(0, limit), hasMore: false} ), // 오답 단어 조회 getIncorrect: (userId, {limit = 20, cursor} = {}) => withMock( - () => vocabApi.get('/user-words/review', {params: {userId, incorrectOnly: true, limit, cursor}}), + () => vocabApi.get('/vocab/user-words', {params: {userId, incorrectOnly: true, limit, cursor}}), {userWords: mockUserWords.filter(w => w.incorrectCount > 0).slice(0, limit), hasMore: false} ), // PATCH /user-words/{wordId}/tag - 북마크 토글 + // userId는 토큰에서 추출되므로 body에 포함하지 않음 toggleBookmark: (userId, wordId, bookmarked) => withMock( - () => vocabApi.patch(`/user-words/${wordId}/tag`, {userId, bookmarked}), + () => vocabApi.patch(`/vocab/user-words/${wordId}/tag`, {bookmarked}), {success: true, wordId, bookmarked} ), } @@ -432,7 +460,7 @@ export const testService = { // POST /tests/start - 시험 시작 start: (userId, testType = 'DAILY', wordCount = 20, level) => withMock( - () => vocabApi.post('/tests/start', {userId, testType, wordCount, level}), + () => vocabApi.post('/vocab/test/start', {userId, testType, wordCount, level}), { testId: `test-${Date.now()}`, testType, @@ -445,10 +473,10 @@ export const testService = { } ), - // POST /tests/{testId}/submit - 시험 제출 + // POST /vocab/test/submit - 시험 제출 submit: (userId, testId, answers) => withMock( - () => vocabApi.post(`/tests/${testId}/submit`, {userId, answers}), + () => vocabApi.post('/vocab/test/submit', {userId, testId, answers}), { testId, totalQuestions: answers.length, @@ -463,63 +491,95 @@ export const testService = { // 시험 결과 조회 (프론트엔드 전용 - 백엔드에서 미구현) getResults: (userId, {limit = 20, cursor} = {}) => withMock( - () => vocabApi.get('/tests/results', {params: {userId, limit, cursor}}), + () => vocabApi.get('/vocab/test/results', {params: {userId, limit, cursor}}), {testResults: mockTestResults.slice(0, limit), hasMore: false} ), } /** - * 통계 API - Backend: GET /statistics + * 통계 API - Backend: GET /stats/total, GET /stats/history, GET /vocab/stats/weakness + * userId는 토큰에서 추출되므로 파라미터로 전달하지 않음 */ export const statsService = { - // GET /statistics - 학습 통계 조회 - getOverall: (userId, period = 'ALL') => + // GET /stats/total - 전체 통계 조회 + getOverall: () => withMock( - () => vocabApi.get('/statistics', {params: {userId, period}}), + () => vocabApi.get('/stats/total'), { - totalWords: mockWords.length, - totalLearned: 15, - masteredWords: 5, - learningWords: 8, - newWords: 7, - averageSuccessRate: 78.5, - averageAccuracy: 78.5, - studyStreak: 7, + periodType: 'TOTAL', + period: 'ALL', + testsCompleted: 4, + questionsAnswered: 100, + correctAnswers: 78, + incorrectAnswers: 22, + successRate: 78.0, + newWordsLearned: 50, + wordsReviewed: 20, + currentStreak: 7, + longestStreak: 15, + lastStudyDate: new Date().toISOString().split('T')[0], + // 프론트엔드 호환용 필드 + totalLearned: 50, + averageSuccessRate: 78.0, + averageAccuracy: 78.0, streakDays: 7, - dailyStats: generateDailyStats().slice(0, 7), - levelProgress: { - BEGINNER: {total: 8, learned: 6}, - INTERMEDIATE: {total: 7, learned: 5}, - ADVANCED: {total: 5, learned: 2}, - }, - difficultyDistribution: { - EASY: 6, - NORMAL: 9, - HARD: 5, - }, } ), - // GET /statistics - 기간별 통계 (프론트엔드 래핑) - getDaily: (userId, {limit = 30, period = 'MONTH'} = {}) => + // GET /stats/history - 히스토리 조회 (히트맵/차트용) + getDaily: (userId, {limit = 7} = {}) => withMock( - () => vocabApi.get('/statistics', {params: {userId, period}}), - {dailyStats: generateDailyStats().slice(0, limit)} + () => vocabApi.get('/stats/history', {params: {limit}}), + { + history: generateDailyStats().slice(0, limit).map(s => ({ + period: s.date, + testsCompleted: Math.floor(Math.random() * 3), + questionsAnswered: s.wordsStudied, + correctAnswers: s.correctCount, + successRate: s.successRate, + newWordsLearned: s.learnedCount, + wordsReviewed: Math.floor(Math.random() * 10), + // 프론트엔드 호환용 + date: s.date, + learnedCount: s.learnedCount, + isCompleted: s.learnedCount >= 55, + })), + dailyStats: generateDailyStats().slice(0, limit), + hasMore: false, + } ), - // 취약 단어 조회 (프론트엔드 전용 - 백엔드에서 미구현) - getWeakness: (userId) => + // GET /vocab/stats/weakness - 취약점 분석 + getWeakness: () => withMock( - () => vocabApi.get('/statistics', {params: {userId, includeWeak: true}}), + () => vocabApi.get('/vocab/stats/weakness'), { + weakCategories: [ + {category: 'BUSINESS', incorrectRate: 35.5, totalAnswered: 100, incorrectCount: 35}, + {category: 'ACADEMIC', incorrectRate: 28.0, totalAnswered: 50, incorrectCount: 14}, + ], + frequentMistakes: mockUserWords + .filter(w => w.incorrectCount > 0) + .sort((a, b) => b.incorrectCount - a.incorrectCount) + .slice(0, 10) + .map(w => ({ + wordId: w.wordId, + english: w.english, + korean: w.korean, + incorrectCount: w.incorrectCount, + accuracy: Math.round((w.correctCount / (w.correctCount + w.incorrectCount)) * 100), + })), weakWords: mockUserWords .filter(w => w.incorrectCount > 0) - .sort((a, b) => (a.correctCount / (a.correctCount + a.incorrectCount)) - (b.correctCount / (b.correctCount + b.incorrectCount))) .slice(0, 10) .map(w => ({ ...w, accuracy: Math.round((w.correctCount / (w.correctCount + w.incorrectCount)) * 100), })), + weakestWords: mockUserWords + .filter(w => w.incorrectCount > 0) + .slice(0, 5), + recommendedReview: 15, } ), } @@ -531,7 +591,7 @@ export const voiceService = { // POST /voice/synthesize - 음성 합성 synthesize: (wordId, text, voice = 'female', type = 'word') => withMock( - () => vocabApi.post('/voice/synthesize', {wordId, text, voice, type}), + () => vocabApi.post('/vocab/voice/synthesize', {wordId, text, voice, type}), { audioUrl: null, // Mock에서는 실제 오디오 없음 cached: false, diff --git a/src/i18n/translations.js b/src/i18n/translations.js index a7cd430..d5a1384 100644 --- a/src/i18n/translations.js +++ b/src/i18n/translations.js @@ -54,6 +54,8 @@ export const translations = { chatPeopleDesc: '다른 학습자와 대화', writingPractice: '작문 연습', writingPracticeDesc: '문법 교정 & 피드백', + newsLearning: '뉴스 학습', + newsLearningDesc: '실제 뉴스로 영어 학습', vocabLearn: '단어 외우기', vocabLearnDesc: '매일 55개 단어 학습', vocabTest: '시험 보기', @@ -103,6 +105,8 @@ export const translations = { quizDesc: '4지선다 테스트', wordListTitle: '단어장', wordListDesc: '내 모든 단어', + newsTitle: '뉴스 학습', + newsDesc: '실제 뉴스로 영어 학습', }, // Vocab Dashboard @@ -197,6 +201,8 @@ export const translations = { title: '나의 단어장', wordsCount: '개의 단어', searchPlaceholder: '단어 검색...', + category: '카테고리', + categoryAll: '전체', filterAll: '전체', filterBookmarked: '북마크', filterIncorrect: '틀린 단어', @@ -375,6 +381,78 @@ export const translations = { ALL_BADGES: '마스터', }, }, + + // Games + games: { + title: '게임', + description: '재미있는 게임으로 영어 실력을 향상하세요', + catchmindTitle: '캐치마인드', + catchmindDesc: '그림 맞추기 게임', + wordchainTitle: '끝말잇기', + wordchainDesc: '영어 끝말잇기', + }, + + // News + news: { + title: '뉴스 영어 학습', + subtitle: '실제 뉴스로 영어 실력을 키워보세요', + todayNews: '오늘의 뉴스', + recommended: '추천 뉴스', + allNews: '전체 뉴스', + readArticle: '기사 읽기', + takeQuiz: '퀴즈 풀기', + collectWord: '단어 수집', + markAsRead: '읽기 완료', + readComplete: '읽기 완료됨', + bookmark: '북마크', + listen: '듣기', + keywords: '핵심 단어', + summary: '요약', + viewOriginal: '원문 보기', + quizComplete: '퀴즈 완료!', + score: '점수', + correct: '정답', + incorrect: '오답', + time: '소요 시간', + newBadge: '새 뱃지 획득!', + collectedWords: '수집한 단어', + syncToVocab: 'Vocabulary 연동', + synced: '연동됨', + notSynced: '미연동', + stats: '학습 통계', + articlesRead: '읽은 기사', + quizzesCompleted: '퀴즈 완료', + wordsCollected: '수집 단어', + streak: '연속 학습', + levels: { + BEGINNER: '초급', + INTERMEDIATE: '중급', + ADVANCED: '고급', + }, + categories: { + TECH: '기술', + BUSINESS: '비즈니스', + SPORTS: '스포츠', + ENTERTAINMENT: '엔터테인먼트', + WORLD: '세계', + CULTURE: '문화', + SCIENCE: '과학', + }, + }, + + // Recent Activity + recentActivity: { + title: '최근 활동', + noActivity: '아직 활동 기록이 없습니다', + startLearning: '학습을 시작해서 진도를 확인하세요', + todayWords: '오늘 외운 단어', + words: '개', + newsRead: '읽은 뉴스', + articles: '개', + quizScore: '퀴즈 점수', + streak: '연속 학습', + days: '일', + }, }, en: { @@ -427,6 +505,8 @@ export const translations = { chatPeopleDesc: 'Practice with learners', writingPractice: 'Composition', writingPracticeDesc: 'Grammar & feedback', + newsLearning: 'News Learning', + newsLearningDesc: 'Learn English with real news', vocabLearn: 'Learn Words', vocabLearnDesc: '55 words per day', vocabTest: 'Take Quiz', @@ -476,6 +556,8 @@ export const translations = { quizDesc: 'Multiple choice test', wordListTitle: 'Word List', wordListDesc: 'All your words', + newsTitle: 'News Learning', + newsDesc: 'Learn English with real news', }, // Vocab Dashboard @@ -570,6 +652,8 @@ export const translations = { title: 'My Word List', wordsCount: 'words', searchPlaceholder: 'Search words...', + category: 'Category', + categoryAll: 'All', filterAll: 'All', filterBookmarked: 'Bookmarked', filterIncorrect: 'Incorrect', @@ -748,6 +832,78 @@ export const translations = { ALL_BADGES: 'Master', }, }, + + // Games + games: { + title: 'Games', + description: 'Improve your English with fun games', + catchmindTitle: 'Catchmind', + catchmindDesc: 'Drawing guessing game', + wordchainTitle: 'Word Chain', + wordchainDesc: 'English word chain game', + }, + + // News + news: { + title: 'News English Learning', + subtitle: 'Improve your English with real news', + todayNews: "Today's News", + recommended: 'Recommended', + allNews: 'All News', + readArticle: 'Read Article', + takeQuiz: 'Take Quiz', + collectWord: 'Collect Word', + markAsRead: 'Mark as Read', + readComplete: 'Read Complete', + bookmark: 'Bookmark', + listen: 'Listen', + keywords: 'Key Vocabulary', + summary: 'Summary', + viewOriginal: 'View Original', + quizComplete: 'Quiz Complete!', + score: 'Score', + correct: 'Correct', + incorrect: 'Incorrect', + time: 'Time', + newBadge: 'New Badge Earned!', + collectedWords: 'Collected Words', + syncToVocab: 'Sync to Vocabulary', + synced: 'Synced', + notSynced: 'Not Synced', + stats: 'Learning Stats', + articlesRead: 'Articles Read', + quizzesCompleted: 'Quizzes Completed', + wordsCollected: 'Words Collected', + streak: 'Streak', + levels: { + BEGINNER: 'Beginner', + INTERMEDIATE: 'Intermediate', + ADVANCED: 'Advanced', + }, + categories: { + TECH: 'Tech', + BUSINESS: 'Business', + SPORTS: 'Sports', + ENTERTAINMENT: 'Entertainment', + WORLD: 'World', + CULTURE: 'Culture', + SCIENCE: 'Science', + }, + }, + + // Recent Activity + recentActivity: { + title: 'Recent Activity', + noActivity: 'No activity yet', + startLearning: 'Start learning to see your progress', + todayWords: 'Words Today', + words: '', + newsRead: 'News Read', + articles: '', + quizScore: 'Quiz Score', + streak: 'Streak', + days: ' days', + }, }, } diff --git a/src/index.css b/src/index.css index 82ee46c..7eb2f6a 100644 --- a/src/index.css +++ b/src/index.css @@ -89,9 +89,9 @@ body { font-family: var(--font-body); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - background-color: var(--color-gray-50); - color: var(--color-gray-900); line-height: 1.6; + /* Let MUI CssBaseline control background-color and color for dark mode support */ + transition: background-color 0.2s ease, color 0.2s ease; } #root { @@ -258,6 +258,179 @@ h1, h2, h3, h4, h5, h6 { -webkit-backdrop-filter: blur(12px); } +/* ============================================ + Dark Mode Support + ============================================ */ +.dark-mode .glass { + background: rgba(39, 39, 42, 0.8); +} + +.dark-mode ::-webkit-scrollbar-track { + background: #27272a; +} + +.dark-mode ::-webkit-scrollbar-thumb { + background: #52525b; +} + +.dark-mode ::-webkit-scrollbar-thumb:hover { + background: #71717a; +} + +.dark-mode ::selection { + background-color: #059669; + color: #ecfdf5; +} + +/* Dark Mode - MUI Components */ +.dark-mode .MuiCard-root, +.dark-mode .MuiPaper-root:not(.MuiAlert-root):not(.MuiMenu-paper) { + background-color: #27272a !important; + color: #fafafa !important; +} + +.dark-mode .MuiCardContent-root { + color: #fafafa !important; +} + +.dark-mode .MuiTypography-root { + color: #fafafa !important; +} + +.dark-mode .MuiTypography-colorTextSecondary, +.dark-mode .MuiTypography-root.MuiTypography-colorTextSecondary, +.dark-mode [class*="MuiTypography-colorTextSecondary"] { + color: #a1a1aa !important; +} + +/* Preserve colored text */ +.dark-mode .MuiTypography-colorPrimary { + color: #34d399 !important; +} + +.dark-mode .MuiTypography-colorError { + color: #f87171 !important; +} + +.dark-mode .MuiInputBase-root { + background-color: #3f3f46 !important; + color: #fafafa !important; +} + +.dark-mode .MuiOutlinedInput-notchedOutline { + border-color: #52525b !important; +} + +.dark-mode .MuiInputLabel-root { + color: #a1a1aa !important; +} + +.dark-mode .MuiChip-root:not(.MuiChip-colorPrimary):not(.MuiChip-colorSecondary):not(.MuiChip-colorSuccess):not(.MuiChip-colorError):not(.MuiChip-colorWarning) { + background-color: #3f3f46 !important; + color: #fafafa !important; +} + +.dark-mode .MuiChip-outlined { + border-color: #52525b !important; + background-color: transparent !important; +} + +.dark-mode .MuiButton-outlined { + border-color: #52525b !important; + color: #fafafa !important; +} + +.dark-mode .MuiButton-text { + color: #fafafa !important; +} + +.dark-mode .MuiIconButton-root { + color: #fafafa !important; +} + +.dark-mode .MuiSvgIcon-root { + color: inherit !important; +} + +.dark-mode .MuiAlert-root { + background-color: #3f3f46 !important; +} + +.dark-mode .MuiAlert-standardSuccess { + background-color: rgba(52, 211, 153, 0.15) !important; +} + +.dark-mode .MuiAlert-standardError { + background-color: rgba(248, 113, 113, 0.15) !important; +} + +.dark-mode .MuiAlert-standardWarning { + background-color: rgba(251, 191, 36, 0.15) !important; +} + +.dark-mode .MuiTableCell-root { + border-color: #3f3f46 !important; + color: #fafafa !important; +} + +.dark-mode .MuiTableHead-root .MuiTableCell-root { + background-color: #27272a !important; +} + +.dark-mode .MuiDivider-root { + border-color: #3f3f46 !important; +} + +.dark-mode .MuiLinearProgress-root { + background-color: #3f3f46 !important; +} + +.dark-mode .MuiMenu-paper, +.dark-mode .MuiPopover-paper { + background-color: #27272a !important; +} + +.dark-mode .MuiMenuItem-root { + color: #fafafa !important; +} + +.dark-mode .MuiMenuItem-root:hover { + background-color: #3f3f46 !important; +} + +.dark-mode .MuiList-root { + background-color: #27272a !important; +} + +.dark-mode .MuiListItemButton-root:hover { + background-color: #3f3f46 !important; +} + +.dark-mode .MuiListItemText-primary { + color: #fafafa !important; +} + +.dark-mode .MuiListItemText-secondary { + color: #a1a1aa !important; +} + +.dark-mode .MuiTab-root { + color: #a1a1aa !important; +} + +.dark-mode .MuiTab-root.Mui-selected { + color: #34d399 !important; +} + +.dark-mode .MuiTabs-indicator { + background-color: #34d399 !important; +} + +.dark-mode .MuiTooltip-tooltip { + background-color: #3f3f46 !important; + color: #fafafa !important; +} + /* Card Hover Effect */ .card-hover { transition: transform var(--transition-base), box-shadow var(--transition-base); diff --git a/src/layouts/AuthLayout/index.jsx b/src/layouts/AuthLayout/index.jsx new file mode 100644 index 0000000..904e720 --- /dev/null +++ b/src/layouts/AuthLayout/index.jsx @@ -0,0 +1,50 @@ +import {Box, Paper, Typography} from '@mui/material'; + +export default function AuthLayout({children}) { + return ( + + + + + + AI + + + + AI 언어 학습 + + + {children} + + + ); +} diff --git a/src/layouts/MainLayout/Footer/index.jsx b/src/layouts/MainLayout/Footer/index.jsx index 27093f4..d60e513 100644 --- a/src/layouts/MainLayout/Footer/index.jsx +++ b/src/layouts/MainLayout/Footer/index.jsx @@ -1,8 +1,11 @@ import {Box, Container, Link, Typography} from '@mui/material' import {useTranslation} from '../../../contexts/SettingsContext' +import {useThemeMode} from '../../../contexts/ThemeContext' const Footer = () => { const {t} = useTranslation() + const {mode} = useThemeMode() + const isDark = mode === 'dark' return ( { py: 3, px: 2, mt: 'auto', - backgroundColor: 'background.paper', + backgroundColor: isDark ? '#27272a' : '#ffffff', borderTop: 1, - borderColor: 'divider', + borderColor: isDark ? '#3f3f46' : 'divider', }} > diff --git a/src/layouts/MainLayout/Header/index.jsx b/src/layouts/MainLayout/Header/index.jsx index e468f15..d4c6583 100644 --- a/src/layouts/MainLayout/Header/index.jsx +++ b/src/layouts/MainLayout/Header/index.jsx @@ -1,11 +1,11 @@ -import {useState} from 'react' -import {useNavigate} from 'react-router-dom' +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useSelector } from 'react-redux' import { AppBar, Avatar, Badge, Box, - Chip, Divider, IconButton, Menu, @@ -25,21 +25,26 @@ import { Settings as SettingsIcon, Translate as TranslateIcon, } from '@mui/icons-material' -import {useThemeMode} from '../../../contexts/ThemeContext' -import {useSettings, useTranslation} from '../../../contexts/SettingsContext' -import {LANGUAGE_LABELS, LANGUAGES} from '../../../i18n/translations' +import { useThemeMode } from '../../../contexts/ThemeContext' +import { useSettings, useTranslation } from '../../../contexts/SettingsContext' +import { LANGUAGE_LABELS, LANGUAGES } from '../../../i18n/translations' +import { useAuth } from '../../../contexts/AuthContext' +import { useNotificationContext, NotificationMenu } from '../../../domains/notification' -const Header = ({onMenuClick, sidebarOpen}) => { +const Header = ({ onMenuClick, sidebarOpen }) => { const theme = useTheme() const navigate = useNavigate() const isMobile = useMediaQuery(theme.breakpoints.down('md')) - const {mode, toggleTheme} = useThemeMode() - const {setLanguage, language} = useSettings() - const {t} = useTranslation() + const { mode, toggleTheme } = useThemeMode() + const { setLanguage, language } = useSettings() + const { t } = useTranslation() + const { profile } = useSelector((state) => state.profile) const [anchorEl, setAnchorEl] = useState(null) const [notificationAnchor, setNotificationAnchor] = useState(null) const [langAnchor, setLangAnchor] = useState(null) + const { logout } = useAuth() + const { unreadCount } = useNotificationContext() const handleProfileMenuOpen = (event) => { setAnchorEl(event.currentTarget) @@ -70,8 +75,9 @@ const Header = ({onMenuClick, sidebarOpen}) => { handleLangClose() } - const handleLogout = () => { + const handleLogout = async () => { handleProfileMenuClose() + await logout() // Cognito 로그아웃 (토큰 삭제) navigate('/login') } @@ -82,14 +88,14 @@ const Header = ({onMenuClick, sidebarOpen}) => { sx={{ zIndex: theme.zIndex.drawer + 1, background: mode === 'dark' - ? 'rgba(30, 30, 30, 0.85)' - : 'rgba(255, 255, 255, 0.85)', + ? 'rgba(24, 24, 27, 0.95)' + : 'rgba(255, 255, 255, 0.95)', backdropFilter: 'blur(20px)', borderBottom: '1px solid', borderColor: mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)', }} > - + {/* Hamburger menu (mobile) */} {isMobile && ( { }, }} > - + )} @@ -141,7 +147,7 @@ const Header = ({onMenuClick, sidebarOpen}) => { L - + { - + {/* Right side icons */} - + {/* Language selector */} { }, }} > - + {/* Dark mode toggle */} @@ -188,7 +194,7 @@ const Header = ({onMenuClick, sidebarOpen}) => { }, }} > - {mode === 'dark' ? : } + {mode === 'dark' ? : } {/* Notifications */} @@ -203,7 +209,7 @@ const Header = ({onMenuClick, sidebarOpen}) => { }} > { fontSize: 10, minWidth: 18, height: 18, + display: unreadCount > 0 ? 'flex' : 'none', }, }} > - + {/* Profile */} { fontSize: 14, }} > - U + {profile?.nickname ? profile.nickname.substring(0, 1).toUpperCase() : 'U'} @@ -250,15 +257,15 @@ const Header = ({onMenuClick, sidebarOpen}) => { minWidth: 160, }, }} - transformOrigin={{horizontal: 'right', vertical: 'top'}} - anchorOrigin={{horizontal: 'right', vertical: 'bottom'}} + transformOrigin={{ horizontal: 'right', vertical: 'top' }} + anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} > - + {t('settings.language')} - + {Object.entries(LANGUAGES).map(([key, value]) => ( { {/* Notification Menu */} - - - - {t('nav.notifications')} - - - - - {[ - { - text: language === 'ko' ? '면접 연습 세션이 완료되었습니다.' : 'Interview session completed.', - time: language === 'ko' ? '10분 전' : '10 min ago' - }, - { - text: language === 'ko' ? 'OPIC 모의고사 결과가 도착했습니다.' : 'OPIC test results arrived.', - time: language === 'ko' ? '1시간 전' : '1 hour ago' - }, - { - text: language === 'ko' ? '새로운 학습 리포트가 생성되었습니다.' : 'New learning report generated.', - time: language === 'ko' ? '어제' : 'Yesterday' - }, - ].map((item, index) => ( - - - - {item.text} - - - {item.time} - - - - ))} - + /> {/* Profile Menu */} { boxShadow: '0 10px 40px -10px rgba(0,0,0,0.2)', }, }} - transformOrigin={{horizontal: 'right', vertical: 'top'}} - anchorOrigin={{horizontal: 'right', vertical: 'bottom'}} + transformOrigin={{ horizontal: 'right', vertical: 'top' }} + anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} > - + - {t('header.user')} + {profile?.nickname || t('header.user')} - user@example.com + {profile?.email || 'user@example.com'} - + { handleProfileMenuClose(); navigate('/profile'); }} - sx={{py: 1.5, px: 2.5}} + sx={{ py: 1.5, px: 2.5 }} > - + {t('nav.profile')} { handleProfileMenuClose(); navigate('/settings'); }} - sx={{py: 1.5, px: 2.5}} + sx={{ py: 1.5, px: 2.5 }} > - + {t('nav.settings')} - + - + {t('nav.logout')} diff --git a/src/layouts/MainLayout/HorizontalNav/index.jsx b/src/layouts/MainLayout/HorizontalNav/index.jsx index c4ea8f4..fb62d41 100644 --- a/src/layouts/MainLayout/HorizontalNav/index.jsx +++ b/src/layouts/MainLayout/HorizontalNav/index.jsx @@ -10,11 +10,13 @@ import { LibraryBooks as WordListIcon, MenuBook as VocabIcon, Mic as SpeakingIcon, + Newspaper as NewsIcon, People as PeopleIcon, Quiz as QuizIcon, School as LearnIcon, Settings as SettingsIcon, SmartToy as AiIcon, + SportsEsports as GameIcon, TrendingUp as TrendingIcon, } from '@mui/icons-material' import {useThemeMode} from '../../../contexts/ThemeContext' @@ -43,7 +45,7 @@ const HorizontalNav = () => { id: 'speaking', label: t('sidebar.speaking'), icon: SpeakingIcon, - color: '#3b82f6', + color: '#059669', children: [ { id: 'opic', @@ -81,13 +83,20 @@ const HorizontalNav = () => { path: '/writing', desc: t('sidebar.writingPracticeDesc') }, + { + id: 'news-learning', + label: t('sidebar.newsLearning'), + icon: NewsIcon, + path: '/news', + desc: t('sidebar.newsLearningDesc') + }, ], }, { id: 'vocab', label: t('sidebar.vocab'), icon: VocabIcon, - color: '#059669', + color: '#f97316', children: [ { id: 'vocab-daily', @@ -119,6 +128,28 @@ const HorizontalNav = () => { }, ], }, + { + id: 'games', + label: t('games.title'), + icon: GameIcon, + color: '#06b6d4', + children: [ + { + id: 'catchmind', + label: t('games.catchmindTitle'), + icon: GameIcon, + path: '/games/catchmind', + desc: t('games.catchmindDesc') + }, + { + id: 'wordchain', + label: t('games.wordchainTitle') || '끝말잇기', + icon: GameIcon, + path: '/games/wordchain', + desc: t('games.wordchainDesc') || '영어 끝말잇기' + }, + ], + }, { id: 'dashboard', label: t('nav.dashboard'), @@ -137,7 +168,7 @@ const HorizontalNav = () => { id: 'settings', label: t('nav.settings'), icon: SettingsIcon, - color: '#6b7280', + color: mode === 'dark' ? '#a1a1aa' : '#6b7280', path: '/settings', }, ] @@ -282,7 +313,7 @@ const HorizontalNav = () => { width: DROPDOWN_ITEM_WIDTH + DROPDOWN_PADDING * 2, // 최대 하위 메뉴 개수 기준으로 고정 높이 설정 minHeight: maxChildren * DROPDOWN_ITEM_HEIGHT + DROPDOWN_PADDING * 2, - backgroundColor: mode === 'dark' ? '#1e1e1e' : 'white', + backgroundColor: mode === 'dark' ? '#27272a' : 'white', boxShadow: mode === 'dark' ? '0 10px 40px -10px rgba(0,0,0,0.5)' : '0 10px 40px -10px rgba(0,0,0,0.15)', diff --git a/src/layouts/MainLayout/Sidebar/index.jsx b/src/layouts/MainLayout/Sidebar/index.jsx index 8ee4644..abc6754 100644 --- a/src/layouts/MainLayout/Sidebar/index.jsx +++ b/src/layouts/MainLayout/Sidebar/index.jsx @@ -29,11 +29,13 @@ import { LibraryBooks as WordListIcon, MenuBook as VocabIcon, Mic as SpeakingIcon, + Newspaper as NewsIcon, People as PeopleIcon, Quiz as QuizIcon, School as LearnIcon, Settings as SettingsIcon, SmartToy as AiIcon, + SportsEsports as GameIcon, TrendingUp as TrendingIcon, } from '@mui/icons-material' import {useThemeMode} from '../../../contexts/ThemeContext' @@ -52,7 +54,7 @@ const Sidebar = ({open, collapsed, onToggleCollapse, onClose}) => { const [expandedMenus, setExpandedMenus] = useState(() => { const saved = localStorage.getItem('expandedMenus') - return saved ? JSON.parse(saved) : {speaking: true, writing: true, vocab: true} + return saved ? JSON.parse(saved) : {speaking: true, writing: true, vocab: true, games: true} }) useEffect(() => { @@ -67,8 +69,8 @@ const Sidebar = ({open, collapsed, onToggleCollapse, onClose}) => { id: 'speaking', label: t('sidebar.speaking'), icon: SpeakingIcon, - color: '#3b82f6', - bgColor: '#eff6ff', + color: '#059669', + bgColor: '#ecfdf5', children: [ { id: 'opic', @@ -107,14 +109,21 @@ const Sidebar = ({open, collapsed, onToggleCollapse, onClose}) => { path: '/writing', description: t('sidebar.writingPracticeDesc'), }, + { + id: 'news-learning', + label: t('sidebar.newsLearning'), + icon: NewsIcon, + path: '/news', + description: t('sidebar.newsLearningDesc'), + }, ], }, { id: 'vocab', label: t('sidebar.vocab'), icon: VocabIcon, - color: '#059669', - bgColor: '#ecfdf5', + color: '#f97316', + bgColor: '#fff7ed', children: [ { id: 'vocab-daily', @@ -146,6 +155,29 @@ const Sidebar = ({open, collapsed, onToggleCollapse, onClose}) => { }, ], }, + { + id: 'games', + label: t('games.title'), + icon: GameIcon, + color: '#06b6d4', + bgColor: '#ecfeff', + children: [ + { + id: 'catchmind', + label: t('games.catchmindTitle'), + icon: GameIcon, + path: '/games/catchmind', + description: t('games.catchmindDesc'), + }, + { + id: 'wordchain', + label: '끝말잇기', + icon: GameIcon, + path: '/games/wordchain', + description: '영어 끝말잇기', + }, + ], + }, ], }, { @@ -175,7 +207,7 @@ const Sidebar = ({open, collapsed, onToggleCollapse, onClose}) => { icon: SettingsIcon, path: '/settings', description: t('sidebar.settingsDesc'), - color: '#6b7280', + color: mode === 'dark' ? '#a1a1aa' : '#6b7280', bgColor: '#f3f4f6', }, ], diff --git a/src/layouts/MainLayout/index.jsx b/src/layouts/MainLayout/index.jsx index 971b7e1..6626f15 100644 --- a/src/layouts/MainLayout/index.jsx +++ b/src/layouts/MainLayout/index.jsx @@ -5,6 +5,7 @@ import Header from './Header' import Sidebar from './Sidebar' import HorizontalNav from './HorizontalNav' import Footer from './Footer' +import {useThemeMode} from '../../contexts/ThemeContext' const DRAWER_WIDTH = 280 const DRAWER_WIDTH_COLLAPSED = 76 @@ -14,6 +15,8 @@ const USE_HORIZONTAL_NAV = true const MainLayout = () => { const theme = useTheme() + const {mode} = useThemeMode() + const isDark = mode === 'dark' const isMobile = useMediaQuery(theme.breakpoints.down('md')) // 모바일 사이드바 열림 상태 @@ -51,7 +54,7 @@ const MainLayout = () => { const topOffset = USE_HORIZONTAL_NAV && !isMobile ? 120 : 64 return ( - + {/* Header */}
{ sx={{ flex: 1, p: 3, - backgroundColor: 'background.default', + backgroundColor: isDark ? '#18181b' : '#fafaf9', + transition: 'background-color 0.2s ease', }} > diff --git a/src/main.jsx b/src/main.jsx index 9cabb0e..b357b89 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -8,6 +8,12 @@ import {ThemeProvider} from './contexts/ThemeContext' import {ChatProvider} from './contexts/ChatContext' import {SettingsProvider} from './contexts/SettingsContext' import './index.css' +import {Amplify} from 'aws-amplify' +import {AuthProvider} from './contexts/AuthContext.jsx' +import {NotificationProvider, NotificationToast} from './domains/notification' +import awsConfig from './aws-config' + +Amplify.configure(awsConfig) createRoot(document.getElementById('root')).render( @@ -16,7 +22,12 @@ createRoot(document.getElementById('root')).render( - + + + + + + diff --git a/src/pages/Dashboard/index.jsx b/src/pages/Dashboard/index.jsx new file mode 100644 index 0000000..d73d094 --- /dev/null +++ b/src/pages/Dashboard/index.jsx @@ -0,0 +1,26 @@ +import {Box, Button, Card, CardContent, Typography} from '@mui/material'; +import {useNavigate} from 'react-router-dom'; +import {useAuth} from '../../contexts/AuthContext'; + +export default function DashboardPage() { + const navigate = useNavigate(); + const {user, logout} = useAuth(); + + const handleLogout = async () => { + await logout(); + navigate('/login'); + }; + + return ( + + 🎉 로그인 성공! + + + 이메일: {user?.email} + JWT 토큰이 자동으로 관리되고 있습니다. + + + + + ); +} diff --git a/src/pages/Login/index.jsx b/src/pages/Login/index.jsx new file mode 100644 index 0000000..e22fc44 --- /dev/null +++ b/src/pages/Login/index.jsx @@ -0,0 +1,12 @@ +import {useNavigate} from 'react-router-dom'; +import AuthLayout from '../../layouts/AuthLayout'; +import LoginForm from '../../domains/auth/components/LoginForm'; + +export default function LoginPage() { + const navigate = useNavigate(); + return ( + + navigate('/signup')}/> + + ); +} diff --git a/src/pages/SignUp/index.jsx b/src/pages/SignUp/index.jsx new file mode 100644 index 0000000..5e0eacd --- /dev/null +++ b/src/pages/SignUp/index.jsx @@ -0,0 +1,12 @@ +import {useNavigate} from 'react-router-dom'; +import AuthLayout from '../../layouts/AuthLayout'; +import SignupForm from '../../domains/auth/components/SignupForm'; + +export default function SignUpPage() { + const navigate = useNavigate(); + return ( + + navigate('/login')}/> + + ); +} diff --git a/src/store/index.js b/src/store/index.js index 543e164..17e676c 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -1,4 +1,6 @@ -import {configureStore, createSlice} from '@reduxjs/toolkit' +import { configureStore, createSlice } from '@reduxjs/toolkit' + +import profileReducer from '../domains/profile/store/profileSlice' // 임시 슬라이스 (빈 store 에러 방지) const appSlice = createSlice({ @@ -12,6 +14,7 @@ const appSlice = createSlice({ export const store = configureStore({ reducer: { app: appSlice.reducer, + profile: profileReducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ diff --git a/src/theme/theme.js b/src/theme/theme.js index 773340a..1b0b938 100644 --- a/src/theme/theme.js +++ b/src/theme/theme.js @@ -402,15 +402,15 @@ export const darkTheme = createTheme({ contrastText: '#1c1917', }, background: { - default: '#0c0a09', // Stone 950 - paper: '#1c1917', // Stone 900 + default: '#18181b', // Zinc 900 - softer dark + paper: '#27272a', // Zinc 800 - for cards }, text: { primary: '#fafaf9', // Stone 50 secondary: '#a8a29e', // Stone 400 disabled: '#78716c', // Stone 500 }, - divider: '#292524', // Stone 800 + divider: '#3f3f46', // Zinc 700 action: { hover: 'rgba(52, 211, 153, 0.08)', selected: 'rgba(52, 211, 153, 0.16)', diff --git a/vite.config.js b/vite.config.js index 4c5fcda..9833a92 100644 --- a/vite.config.js +++ b/vite.config.js @@ -5,7 +5,15 @@ export default defineConfig({ plugins: [react()], server: { port: 3000, - open: true + open: true, + proxy: { + '/api': { + target: 'https://gc8l9ijhzc.execute-api.ap-northeast-2.amazonaws.com/dev', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, ''), + secure: true, + } + } }, build: { outDir: 'dist',