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 46c55a6..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); @@ -457,7 +474,7 @@ jobs: }) } ); - + # 기존 이슈 일괄 처리 sync-all-open-issues: if: github.event_name == 'workflow_dispatch' @@ -487,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( @@ -567,7 +584,7 @@ jobs: { type: 'paragraph', content: [{ type: 'text', text: parseSection('작업 범위') }] } ); } - + const jiraResponse = await fetch( `${process.env.JIRA_BASE_URL}/rest/api/3/issue`, { @@ -590,9 +607,9 @@ jobs: }) } ); - + const jiraData = await jiraResponse.json(); - + if (jiraData.key) { await github.rest.issues.createComment({ owner: context.repo.owner, @@ -604,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 5f7747b..d99c58e 100644 --- a/.github/workflows/github-jira-pr-sync.yml +++ b/.github/workflows/github-jira-pr-sync.yml @@ -488,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..bb70c3a 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,6 +1,6 @@ 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 {Navigate, Route, Routes, useNavigate} from 'react-router-dom' +import {Box, Button, Card, CardContent, CircularProgress, Collapse, Container, Grid, Typography} from '@mui/material' import { ChevronRight as ChevronRightIcon, Create as WritingCategoryIcon, @@ -13,6 +13,7 @@ import { Quiz as QuizIcon, School as LearnIcon, SmartToy as AiIcon, + SportsEsports as GameIcon, WavingHand as WaveIcon, } from '@mui/icons-material' import MainLayout from './layouts/MainLayout' @@ -26,8 +27,62 @@ 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 CatchmindLobbyPage from './domains/games/pages/CatchmindLobbyPage' +import CatchmindWaitingPage from './domains/games/pages/CatchmindWaitingPage' +import CatchmindPlayPage from './domains/games/pages/CatchmindPlayPage' 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' + + +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() { @@ -41,8 +96,8 @@ function Dashboard() { title: t('dashboard.speakingTitle'), description: t('dashboard.speakingDesc'), icon: SpeakingIcon, - color: '#3b82f6', - bgColor: '#eff6ff', + color: '#059669', + bgColor: '#ecfdf5', children: [ { id: 'opic', @@ -115,6 +170,23 @@ function Dashboard() { }, ], }, + { + id: 'games', + title: t('games.title'), + description: t('games.description'), + icon: GameIcon, + color: '#8b5cf6', + bgColor: '#f3e8ff', + children: [ + { + id: 'catchmind', + title: t('games.catchmindTitle'), + icon: GameIcon, + path: '/games/catchmind', + description: t('games.catchmindDesc') + }, + ], + }, ] const handleCardHover = (modeId) => { @@ -168,7 +240,7 @@ function Dashboard() { const hasChildren = mode.children && mode.children.length > 0 return ( - + handleCardHover(mode.id)} onMouseLeave={handleCardLeave} @@ -416,12 +488,12 @@ function ReportsPage() { {/* 통계 요약 카드 */} - + {isKorean ? '총 학습일' : 'Study Days'} - + {stats.totalStudyDays} @@ -429,7 +501,7 @@ function ReportsPage() { - + {isKorean ? '학습한 단어' : 'Words Learned'} @@ -442,7 +514,7 @@ function ReportsPage() { - + {isKorean ? '테스트 완료' : 'Tests Taken'} @@ -455,7 +527,7 @@ function ReportsPage() { - + {isKorean ? '평균 점수' : 'Average Score'} @@ -476,7 +548,7 @@ function ReportsPage() { {isKorean ? '연속 학습 기록' : 'Study Streak'} - + - + @@ -575,21 +647,21 @@ function SettingsPage() { {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', }, }} > @@ -599,7 +671,7 @@ function SettingsPage() { {option.label} @@ -627,7 +699,7 @@ function SettingsPage() { - + setTtsVoice('FEMALE')} sx={{ @@ -669,7 +741,7 @@ function SettingsPage() { - + setTtsVoice('MALE')} sx={{ @@ -770,8 +842,23 @@ function App() { {/* Chat room page (separate layout) */} }/> + {/* 로그인 / 회원가입 route */} + + + + }/> + + + + }/> + {/* MainLayout routes */} - }> + + + + }> }/> }/> }/> @@ -785,6 +872,9 @@ function App() { }/> }/> }/> + }/> + }/> + }/> {/* 404 */} diff --git a/src/api/axios.js b/src/api/axios.js index a740563..af5ff20 100644 --- a/src/api/axios.js +++ b/src/api/axios.js @@ -1,7 +1,9 @@ import axios from 'axios' +const API_BASE_URL = import.meta.env.VITE_API_URL + const api = axios.create({ - baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8080', + baseURL: API_BASE_URL, timeout: 10000, headers: { 'Content-Type': 'application/json', @@ -10,12 +12,20 @@ const api = axios.create({ // Request interceptor api.interceptors.request.use( - (config) => { - const token = localStorage.getItem('accessToken') - if (token) { - config.headers.Authorization = `Bearer ${token}` + async (config) => { + try { + // Cognito 세션에서 토큰 가져오기 + const session = await fetchAuthSession() + const token = localStorage.getItem('accessToken') + + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + + return config + } catch (error) { + return config } - return config }, (error) => { return Promise.reject(error) @@ -25,10 +35,27 @@ api.interceptors.request.use( // Response interceptor api.interceptors.response.use( (response) => response, - (error) => { - if (error.response?.status === 401) { - localStorage.removeItem('accessToken') - window.location.href = '/login' + + async (error) => { + const originalRequest = error.config + + // 401 에러 && 재시도하지 않을 경우 + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true + + try { + // 토큰 갱신 시도 + const session = await fetchAuthSession({forceRefresh: true}) + const newToken = session.tokens?.accessToken?.toString() + + if (newToken) { + originalRequest.headers['Authorization'] = `Bearer ${newToken}` + return api(originalRequest) + } + } catch (refreshError) { + // 토큰 갱신 실패 시 로그인 페이지로 리다이렉트 + window.location.href = '/login' + } } return Promise.reject(error) } diff --git a/src/api/chatApi.js b/src/api/chatApi.js index 193dd13..0c79422 100644 --- a/src/api/chatApi.js +++ b/src/api/chatApi.js @@ -8,9 +8,13 @@ const chatApi = axios.create({ }, }) -// Request interceptor +// Request interceptor - JWT 토큰 자동 추가 chatApi.interceptors.request.use( (config) => { + const token = localStorage.getItem('accessToken') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } return config }, (error) => { @@ -18,11 +22,17 @@ chatApi.interceptors.request.use( } ) -// Response interceptor +// Response interceptor - 401 에러 시 로그인 페이지로 이동 chatApi.interceptors.response.use( (response) => response.data, (error) => { console.error('Chat API Error:', error.response?.data || error.message) + + if (error.response?.status === 401) { + localStorage.removeItem('accessToken') + window.location.href = '/login' + } + return Promise.reject(error) } ) diff --git a/src/api/vocabApi.js b/src/api/vocabApi.js index b18b7f8..8ab1c6f 100644 --- a/src/api/vocabApi.js +++ b/src/api/vocabApi.js @@ -8,9 +8,13 @@ const vocabApi = axios.create({ }, }) -// Request interceptor +// Request interceptor - JWT 토큰 자동 추가 vocabApi.interceptors.request.use( (config) => { + const token = localStorage.getItem('accessToken') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } return config }, (error) => { @@ -18,11 +22,17 @@ vocabApi.interceptors.request.use( } ) -// Response interceptor +// Response interceptor - 401 에러 시 로그인 페이지로 이동 vocabApi.interceptors.response.use( (response) => response.data, (error) => { console.error('Vocab API Error:', error.response?.data || error.message) + + if (error.response?.status === 401) { + localStorage.removeItem('accessToken') + window.location.href = '/login' + } + return Promise.reject(error) } ) diff --git a/src/aws-config.js b/src/aws-config.js new file mode 100644 index 0000000..d4d0a9d --- /dev/null +++ b/src/aws-config.js @@ -0,0 +1,22 @@ +const awsConfig = { + Auth: { + Cognito: { + userPoolId: 'ap-northeast-2_ezDwzFCzR', + userPoolClientId: '4ns077jcr1pkue2vvisr6qdpu5', + + loginWith: { + email: true, + }, + + signUpVerificationMethod: 'code', + + userAttributes: { + email: { + required: true, + }, + }, + } + } +}; + +export default awsConfig; \ No newline at end of file diff --git a/src/contexts/AuthContext.jsx b/src/contexts/AuthContext.jsx new file mode 100644 index 0000000..57f0bc1 --- /dev/null +++ b/src/contexts/AuthContext.jsx @@ -0,0 +1,215 @@ +import {createContext, useCallback, useContext, useEffect, useMemo, useState} from 'react' +import { + confirmSignUp, + fetchAuthSession, + getCurrentUser, + resendSignUpCode, + signIn, + signOut, + signUp, +} from 'aws-amplify/auth' + +const AuthContext = createContext(null) + +export function AuthProvider({children}) { + const [user, setUser] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [isAuthenticated, setIsAuthenticated] = useState(false) + + useEffect(() => { + checkAuthUser() + }, []) + + // 현재 인증된 사용자 확인 및 토큰 저장 + const checkAuthUser = async () => { + try { + const currentUser = await getCurrentUser() + + // Cognito 세션에서 토큰 가져와서 localStorage에 저장 + const session = await fetchAuthSession() + const idToken = session.tokens?.idToken?.toString() + if (idToken) { + localStorage.setItem('accessToken', idToken) + } + + setUser({ + ...currentUser, + email: currentUser.signInDetails?.loginId || currentUser.username, + }) + setIsAuthenticated(true) + } catch (error) { + // 로그인되지 않은 상태 + localStorage.removeItem('accessToken') + setUser(null) + setIsAuthenticated(false) + } finally { + setIsLoading(false) + } + } + + // 로그인 + const login = useCallback(async (email, password) => { + try { + const result = await signIn({username: email, password}) + + if (result.isSignedIn) { + await checkAuthUser() + return {success: true} + } + + return { + success: false, + message: '로그인에 실패했습니다.' + } + } catch (error) { + return { + success: false, + message: getAuthErrorMessage(error) + } + } + }, []) + + // 회원가입 + const register = useCallback(async (email, password) => { + try { + const result = await signUp({ + username: email, + password, + options: { + userAttributes: { + email: email, + } + } + }) + + return { + success: true, + nextStep: result.nextStep, + message: '인증 코드가 이메일로 발송되었습니다.' + } + } catch (error) { + console.error('회원가입 실패:', error) + return { + success: false, + message: getAuthErrorMessage(error) + } + } + }, []) + + // 이메일 인증 코드 확인 + const confirmEmail = useCallback(async (email, code) => { + try { + const result = await confirmSignUp({ + username: email, + confirmationCode: code, + }) + + return { + success: true, + message: '회원가입이 완료되었습니다! 로그인해주세요.' + } + } catch (error) { + console.error('이메일 인증 코드 확인 실패:', error) + return { + success: false, + message: getAuthErrorMessage(error) + } + } + }, []) + + // 인증 코드 재전송 + const resendCode = useCallback(async (email) => { + try { + await resendSignUpCode({username: email}) + return { + success: true, + message: '인증 코드가 재전송되었습니다.' + } + } catch (error) { + console.error('인증 코드 재전송 실패:', error) + return { + success: false, + message: getAuthErrorMessage(error) + } + } + }, []) + + + // 로그아웃 + const logout = useCallback(async () => { + try { + await signOut() + localStorage.removeItem('accessToken') + setUser(null) + setIsAuthenticated(false) + return {success: true} + } catch (error) { + console.error('Logout error:', error) + return { + success: false, + message: '로그아웃 실패' + } + } + }, []) + + const value = useMemo(() => ({ + user, + isLoading, + isAuthenticated, + login, + register, + confirmEmail, + resendCode, + logout, + checkAuthUser, + }), [ + user, + isLoading, + isAuthenticated, + login, + register, + confirmEmail, + resendCode, + logout, + checkAuthUser, + ]) + + return ( + + {children} + + ) +} + +export function useAuth() { + const context = useContext(AuthContext) + if (!context) { + throw new Error('useAuth는 AuthProvider 안에서 사용해야 합니다') + } + return context +} + +// Cognito 에러 메시지 변환 헬퍼 +function getAuthErrorMessage(error) { + const errorMessages = { + 'UserNotFoundException': '등록되지 않은 이메일입니다.', + 'NotAuthorizedException': '이메일 또는 비밀번호가 올바르지 않습니다.', + 'UserNotConfirmedException': '이메일 인증이 완료되지 않았습니다.', + 'UsernameExistsException': '이미 사용 중인 이메일입니다.', + 'InvalidPasswordException': '비밀번호 형식이 올바르지 않습니다. (8자 이상, 대소문자, 숫자, 특수문자 포함)', + 'CodeMismatchException': '인증 코드가 올바르지 않습니다.', + 'ExpiredCodeException': '인증 코드가 만료되었습니다. 다시 요청해주세요.', + 'LimitExceededException': '요청 횟수를 초과했습니다. 잠시 후 다시 시도해주세요.', + 'TooManyRequestsException': '요청이 너무 많습니다. 잠시 후 다시 시도해주세요.', + 'InvalidParameterException': '입력 정보가 올바르지 않습니다.', + } + + // Cognito 에러 코드로 메시지 찾기 + const errorName = error.name || error.code + if (errorMessages[errorName]) { + return errorMessages[errorName] + } + + // 기본 메시지 + return error.message || '오류가 발생했습니다. 다시 시도해주세요.' +} diff --git a/src/domains/auth/components/EmailVerification.jsx b/src/domains/auth/components/EmailVerification.jsx new file mode 100644 index 0000000..af96c81 --- /dev/null +++ b/src/domains/auth/components/EmailVerification.jsx @@ -0,0 +1,276 @@ +import {useEffect, useRef, useState} from 'react' +import {Alert, Box, Button, CircularProgress, Link, TextField, Typography,} from '@mui/material' +import {ArrowBack as ArrowBackIcon, Email as EmailIcon, Refresh as RefreshIcon,} from '@mui/icons-material' +import {useAuth} from '../../../contexts/AuthContext' + +export default function EmailVerification({email, onComplete, onBack}) { + const {confirmEmail, resendCode} = useAuth() + + const [code, setCode] = useState(['', '', '', '', '', '']) + const [isLoading, setIsLoading] = useState(false) + const [isResending, setIsResending] = useState(false) + const [error, setError] = useState('') + const [success, setSuccess] = useState('') + const [countdown, setCountdown] = useState(0) + + const inputRefs = useRef([]) + + // 카운트다운 타이머 + useEffect(() => { + if (countdown > 0) { + const timer = setTimeout(() => setCountdown(countdown - 1), 1000) + return () => clearTimeout(timer) + } + }, [countdown]) + + // 코드 입력 처리 + const handleCodeChange = (index, value) => { + // 숫자만 허용 + if (value && !/^\d+$/.test(value)) return + + const newCode = [...code] + + // 붙여넣기 처리 + if (value.length > 1) { + const pastedCode = value.slice(0, 6).split('') + pastedCode.forEach((char, i) => { + if (index + i < 6) { + newCode[index + i] = char + } + }) + setCode(newCode) + + // 마지막 입력 필드로 포커스 + const nextIndex = Math.min(index + pastedCode.length, 5) + inputRefs.current[nextIndex]?.focus() + return + } + + newCode[index] = value + setCode(newCode) + setError('') + + // 다음 입력 필드로 자동 이동 + if (value && index < 5) { + inputRefs.current[index + 1]?.focus() + } + } + + // 키 입력 처리 + const handleKeyDown = (index, e) => { + // 백스페이스: 이전 필드로 이동 + if (e.key === 'Backspace' && !code[index] && index > 0) { + inputRefs.current[index - 1]?.focus() + } + // Enter: 제출 + if (e.key === 'Enter') { + handleSubmit() + } + } + + // 인증 코드 확인 + const handleSubmit = async () => { + const verificationCode = code.join('') + + if (verificationCode.length !== 6) { + setError('6자리 인증 코드를 입력해주세요.') + return + } + + setError('') + setIsLoading(true) + + try { + const result = await confirmEmail(email, verificationCode) + + if (result.success) { + setSuccess(result.message) + setTimeout(() => { + onComplete() + }, 1500) + } else { + setError(result.message) + // 코드 초기화 + setCode(['', '', '', '', '', '']) + inputRefs.current[0]?.focus() + } + } catch (err) { + setError('인증 중 오류가 발생했습니다.') + } finally { + setIsLoading(false) + } + } + + // 인증 코드 재전송 + const handleResend = async () => { + if (countdown > 0) return + + setError('') + setSuccess('') + setIsResending(true) + + try { + const result = await resendCode(email) + + if (result.success) { + setSuccess(result.message) + setCountdown(60) // 60초 쿨다운 + // 코드 초기화 + setCode(['', '', '', '', '', '']) + inputRefs.current[0]?.focus() + } else { + setError(result.message) + } + } catch (err) { + setError('코드 재전송 중 오류가 발생했습니다.') + } finally { + setIsResending(false) + } + } + + return ( + + {/* 뒤로가기 */} + + + {/* 타이틀 */} + + + + + + 이메일 인증 + + + {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..947d69a 100644 --- a/src/domains/badge/components/BadgeCard.jsx +++ b/src/domains/badge/components/BadgeCard.jsx @@ -2,10 +2,13 @@ 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 {useThemeMode} from '../../../contexts/ThemeContext' import {BADGE_CATEGORY_COLORS, BADGE_DESCRIPTIONS_EN, BADGE_NAMES_EN,} from '../constants/badgeConstants' 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 @@ -101,14 +104,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', }, @@ -141,7 +144,7 @@ export default function BadgeCard({badge, size = 'medium'}) { position: 'relative', background: isEarned ? `linear-gradient(135deg, ${BADGE_CATEGORY_COLORS[badge.category]}20 0%, ${BADGE_CATEGORY_COLORS[badge.category]}10 100%)` - : '#f3f4f6', + : isDark ? '#3f3f46' : '#f3f4f6', border: isEarned ? `3px solid ${BADGE_CATEGORY_COLORS[badge.category]}` : '3px solid #d1d5db', boxShadow: isEarned ? `0 8px 24px -4px ${BADGE_CATEGORY_COLORS[badge.category]}40` @@ -189,7 +192,7 @@ export default function BadgeCard({badge, size = 'medium'}) { display: 'flex', alignItems: 'center', justifyContent: 'center', - border: '2px solid #fff', + border: isDark ? '2px solid #27272a' : '2px solid #fff', }} > 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/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..c62ca79 100644 --- a/src/domains/freetalk/components/ChatRoomModal.jsx +++ b/src/domains/freetalk/components/ChatRoomModal.jsx @@ -8,44 +8,55 @@ 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 {useAuth} from '../../../contexts/AuthContext' +import {useThemeMode} from '../../../contexts/ThemeContext' import {DESIGN_TOKENS, getChatStyles} from '../../../theme/theme' -import GameModePanel from './GameModePanel' +import {useChatWebSocket} from '../hooks/useChatWebSocket' const ChatRoomModal = ({open, onClose, room, onLeave}) => { const theme = useTheme() - const isDark = theme.palette.mode === 'dark' + 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) @@ -56,8 +67,8 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { 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 [opacity, setOpacity] = useState(100) + const [opacityAnchorEl, setOpacityAnchorEl] = useState(null) // 메시지 목록 조회 const fetchMessages = useCallback(async () => { if (!room?.id) return @@ -71,7 +82,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,58 +91,35 @@ 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) => { @@ -194,7 +182,7 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { } }, [isDragging, dragOffset]) - // 메시지 전송 + // 메시지 전송 (WebSocket 사용) const handleSendMessage = async () => { if (!newMessage.trim() || sendingMessage) return @@ -202,25 +190,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) + } } } @@ -278,10 +279,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 +297,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="투명도" + > + + + 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" + /> + + @@ -358,30 +408,6 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { {!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}}> @@ -389,16 +415,8 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { )} - {/* 게임 모드 */} - {activeTab === 1 && ( - - - - )} - - {/* 채팅 모드 - 메시지 영역 */} - {activeTab === 0 && ( - loading ? ( + {/* 메시지 영역 */} + {loading ? ( { maxWidth: '70%', }} > - {!message.isOwn && ( - - {message.userId} - - )} + + {message.userId} + { )}
- ) - )} + )} {/* 입력 영역 */} { borderTop: 1, borderColor: 'divider', bgcolor: 'background.paper', + pointerEvents: 'auto', }} > { +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}) => { + + + + ) +} + +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..7eeaf27 --- /dev/null +++ b/src/domains/games/components/WaitingChat.jsx @@ -0,0 +1,202 @@ +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' + +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 + + 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/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/services/gameService.js b/src/domains/games/services/gameService.js new file mode 100644 index 0000000..1c3958f --- /dev/null +++ b/src/domains/games/services/gameService.js @@ -0,0 +1,342 @@ +/** + * 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') + params.append('gameType', 'CATCHMIND') + + // 백엔드는 소문자 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 === 'CATCHMIND' + ) + } else if (Array.isArray(data)) { + data = data.filter(room => + room.type === 'GAME' || room.gameType === 'CATCHMIND' + ) + } + + 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 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: 'CATCHMIND', + gameSettings: { + maxRounds: data.maxRounds || 5, + roundTimeLimit: data.roundTimeLimit || 60, + }, + } + + 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/theme/gameTheme.js b/src/domains/games/theme/gameTheme.js new file mode 100644 index 0000000..c2d2322 --- /dev/null +++ b/src/domains/games/theme/gameTheme.js @@ -0,0 +1,184 @@ +/** + * Game Design System + * 캐치마인드 게임 전용 디자인 토큰 + */ + +export const GAME_COLORS = { + // 브랜드 + primary: '#8B5CF6', + primaryLight: '#A78BFA', + primaryBg: '#F3E8FF', + + // 게임 상태 + 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/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..0a9a65b 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}) { + 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..8aa1e35 100644 --- a/src/domains/vocab/pages/WordListPage.jsx +++ b/src/domains/vocab/pages/WordListPage.jsx @@ -28,8 +28,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,6 +48,10 @@ 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) @@ -87,7 +92,7 @@ export default function WordListPage() { 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 || [] @@ -168,7 +173,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,7 +280,7 @@ export default function WordListPage() { sx={{ mb: 3, '& .MuiOutlinedInput-root': { - backgroundColor: 'white', + backgroundColor: isDark ? '#27272a' : 'white', }, }} /> @@ -349,7 +354,7 @@ export default function WordListPage() { - + {word.english} {word.level && ( @@ -380,7 +385,7 @@ export default function WordListPage() { )} - + {word.korean} @@ -407,13 +412,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 +462,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..2426831 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} ), } @@ -392,7 +418,7 @@ export const myWordService = { // GET /user-words/review - 나의 단어 목록 (필터링) getList: (userId, {bookmarked, incorrectOnly, limit = 20, cursor} = {}) => withMock( - () => vocabApi.get('/user-words/review', { + () => vocabApi.get('/vocab/user-words', { params: {userId, bookmarked, incorrectOnly, limit, cursor} }), { @@ -406,21 +432,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 +459,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 +472,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 +490,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 +590,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..6ef4b88 100644 --- a/src/i18n/translations.js +++ b/src/i18n/translations.js @@ -375,6 +375,14 @@ export const translations = { ALL_BADGES: '마스터', }, }, + + // Games + games: { + title: '게임', + description: '재미있는 게임으로 영어 실력을 향상하세요', + catchmindTitle: '캐치마인드', + catchmindDesc: '그림 맞추기 게임', + }, }, en: { @@ -748,6 +756,14 @@ export const translations = { ALL_BADGES: 'Master', }, }, + + // Games + games: { + title: 'Games', + description: 'Improve your English with fun games', + catchmindTitle: 'Catchmind', + catchmindDesc: 'Drawing guessing game', + }, }, } 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..f93dfa8 100644 --- a/src/layouts/MainLayout/Header/index.jsx +++ b/src/layouts/MainLayout/Header/index.jsx @@ -28,6 +28,7 @@ import { import {useThemeMode} from '../../../contexts/ThemeContext' import {useSettings, useTranslation} from '../../../contexts/SettingsContext' import {LANGUAGE_LABELS, LANGUAGES} from '../../../i18n/translations' +import {useAuth} from '../../../contexts/AuthContext' const Header = ({onMenuClick, sidebarOpen}) => { const theme = useTheme() @@ -40,6 +41,7 @@ const Header = ({onMenuClick, sidebarOpen}) => { const [anchorEl, setAnchorEl] = useState(null) const [notificationAnchor, setNotificationAnchor] = useState(null) const [langAnchor, setLangAnchor] = useState(null) + const {logout} = useAuth() const handleProfileMenuOpen = (event) => { setAnchorEl(event.currentTarget) @@ -70,8 +72,9 @@ const Header = ({onMenuClick, sidebarOpen}) => { handleLangClose() } - const handleLogout = () => { + const handleLogout = async () => { handleProfileMenuClose() + await logout() // Cognito 로그아웃 (토큰 삭제) navigate('/login') } @@ -82,8 +85,8 @@ 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)', diff --git a/src/layouts/MainLayout/HorizontalNav/index.jsx b/src/layouts/MainLayout/HorizontalNav/index.jsx index c4ea8f4..3e11a9f 100644 --- a/src/layouts/MainLayout/HorizontalNav/index.jsx +++ b/src/layouts/MainLayout/HorizontalNav/index.jsx @@ -15,6 +15,7 @@ import { 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 +44,7 @@ const HorizontalNav = () => { id: 'speaking', label: t('sidebar.speaking'), icon: SpeakingIcon, - color: '#3b82f6', + color: '#059669', children: [ { id: 'opic', @@ -119,6 +120,21 @@ const HorizontalNav = () => { }, ], }, + { + id: 'games', + label: t('games.title'), + icon: GameIcon, + color: '#8b5cf6', + children: [ + { + id: 'catchmind', + label: t('games.catchmindTitle'), + icon: GameIcon, + path: '/games/catchmind', + desc: t('games.catchmindDesc') + }, + ], + }, { id: 'dashboard', label: t('nav.dashboard'), @@ -137,7 +153,7 @@ const HorizontalNav = () => { id: 'settings', label: t('nav.settings'), icon: SettingsIcon, - color: '#6b7280', + color: mode === 'dark' ? '#a1a1aa' : '#6b7280', path: '/settings', }, ] @@ -282,7 +298,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..2046c00 100644 --- a/src/layouts/MainLayout/Sidebar/index.jsx +++ b/src/layouts/MainLayout/Sidebar/index.jsx @@ -34,6 +34,7 @@ import { 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 +53,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 +68,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', @@ -146,6 +147,22 @@ const Sidebar = ({open, collapsed, onToggleCollapse, onClose}) => { }, ], }, + { + id: 'games', + label: t('games.title'), + icon: GameIcon, + color: '#8b5cf6', + bgColor: '#f3e8ff', + children: [ + { + id: 'catchmind', + label: t('games.catchmindTitle'), + icon: GameIcon, + path: '/games/catchmind', + description: t('games.catchmindDesc'), + }, + ], + }, ], }, { @@ -175,7 +192,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..e5b117e 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -8,6 +8,11 @@ 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 awsConfig from './aws-config' + +Amplify.configure(awsConfig) createRoot(document.getElementById('root')).render( @@ -16,7 +21,9 @@ 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/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',