diff --git a/.github/ISSUE_TEMPLATE/change_request.yml b/.github/ISSUE_TEMPLATE/change_request.yml index fa49294..f1a32dc 100644 --- a/.github/ISSUE_TEMPLATE/change_request.yml +++ b/.github/ISSUE_TEMPLATE/change_request.yml @@ -1,7 +1,7 @@ name: Change Request description: 설계/AC 변경 제안 title: "[CR] 제목" -labels: ["change-request"] +labels: [ "change-request" ] body: - type: input id: related diff --git a/.github/ISSUE_TEMPLATE/epic.yml b/.github/ISSUE_TEMPLATE/epic.yml index 41b05e9..ddaf48f 100644 --- a/.github/ISSUE_TEMPLATE/epic.yml +++ b/.github/ISSUE_TEMPLATE/epic.yml @@ -1,7 +1,7 @@ name: Epic description: 큰 기능(여러 Story로 쪼개질 수 있음) title: "[EPIC] 제목" -labels: ["epic"] +labels: [ "epic" ] body: - type: textarea id: goal diff --git a/.github/ISSUE_TEMPLATE/spike.yml b/.github/ISSUE_TEMPLATE/spike.yml index 68569fe..b62f1be 100644 --- a/.github/ISSUE_TEMPLATE/spike.yml +++ b/.github/ISSUE_TEMPLATE/spike.yml @@ -1,7 +1,7 @@ name: Spike description: 조사/실험(시간 제한) title: "[SPIKE] 제목" -labels: ["spike"] +labels: [ "spike" ] body: - type: input id: timebox diff --git a/.github/ISSUE_TEMPLATE/story.yml b/.github/ISSUE_TEMPLATE/story.yml index 4a7a076..71fe327 100644 --- a/.github/ISSUE_TEMPLATE/story.yml +++ b/.github/ISSUE_TEMPLATE/story.yml @@ -1,7 +1,7 @@ name: Story description: 사용자 시나리오와 수용 기준 title: "[STORY] 제목" -labels: ["story"] +labels: [ "story" ] body: - type: textarea id: background diff --git a/.github/ISSUE_TEMPLATE/task.yml b/.github/ISSUE_TEMPLATE/task.yml index aa3f801..4d6294f 100644 --- a/.github/ISSUE_TEMPLATE/task.yml +++ b/.github/ISSUE_TEMPLATE/task.yml @@ -1,7 +1,7 @@ name: Task description: 개발/테스트/데브옵스 단위 작업 title: "[TASK] 제목" -labels: ["task"] +labels: [ "task" ] body: - type: input id: parent diff --git a/.github/change_request.yml b/.github/change_request.yml index fa49294..f1a32dc 100644 --- a/.github/change_request.yml +++ b/.github/change_request.yml @@ -1,7 +1,7 @@ name: Change Request description: 설계/AC 변경 제안 title: "[CR] 제목" -labels: ["change-request"] +labels: [ "change-request" ] body: - type: input id: related diff --git a/.github/epic.yml b/.github/epic.yml index 41b05e9..ddaf48f 100644 --- a/.github/epic.yml +++ b/.github/epic.yml @@ -1,7 +1,7 @@ name: Epic description: 큰 기능(여러 Story로 쪼개질 수 있음) title: "[EPIC] 제목" -labels: ["epic"] +labels: [ "epic" ] body: - type: textarea id: goal diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 9511bc5..96acf62 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,23 +1,29 @@ 제목: refs #ISSUE 목적 + - 왜 이 PR이 필요한가? (관련 Story/Epic/CR 링크) 변경 요약 + - 핵심 변경 - 주요 파일/모듈 수용 기준 검증 + - [ ] AC1: ... - [ ] AC2: ... 브레이킹/마이그레이션 + - 스키마/데이터/호환성 테스트 + - 단위/통합/시나리오 - 수동 검증 방법 참조 + - Closes #ISSUE or refs #ISSUE - 디자인/스펙/ADR 링크 diff --git a/.github/spike.yml b/.github/spike.yml index 68569fe..b62f1be 100644 --- a/.github/spike.yml +++ b/.github/spike.yml @@ -1,7 +1,7 @@ name: Spike description: 조사/실험(시간 제한) title: "[SPIKE] 제목" -labels: ["spike"] +labels: [ "spike" ] body: - type: input id: timebox diff --git a/.github/story.yml b/.github/story.yml index 4a7a076..71fe327 100644 --- a/.github/story.yml +++ b/.github/story.yml @@ -1,7 +1,7 @@ name: Story description: 사용자 시나리오와 수용 기준 title: "[STORY] 제목" -labels: ["story"] +labels: [ "story" ] body: - type: textarea id: background diff --git a/.github/task.yml b/.github/task.yml index aa3f801..4d6294f 100644 --- a/.github/task.yml +++ b/.github/task.yml @@ -1,7 +1,7 @@ name: Task description: 개발/테스트/데브옵스 단위 작업 title: "[TASK] 제목" -labels: ["task"] +labels: [ "task" ] body: - type: input id: parent diff --git a/.github/workflows/auto-close-issues.yml b/.github/workflows/auto-close-issues.yml index 9bb3f45..476d63d 100644 --- a/.github/workflows/auto-close-issues.yml +++ b/.github/workflows/auto-close-issues.yml @@ -2,7 +2,7 @@ name: Auto Close Issues on: pull_request: - types: [closed] + types: [ closed ] branches: - develop - main diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index 21be9e5..d1d1c5a 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -1,7 +1,7 @@ name: Auto Label on: pull_request_target: - types: [opened, synchronize] + types: [ opened, synchronize ] permissions: contents: read diff --git a/.github/workflows/github-jira-issue-sync.yml b/.github/workflows/github-jira-issue-sync.yml new file mode 100644 index 0000000..21d30e9 --- /dev/null +++ b/.github/workflows/github-jira-issue-sync.yml @@ -0,0 +1,609 @@ +name: Issue-Jira Sync + +on: + issues: + types: [opened, reopened, edited, closed ] + + workflow_dispatch: + inputs: + process_all_open_issues: + description: '수동 트리거로 기존 이슈 일괄 처리' + type: boolean + default: true + +permissions: + issues: write + contents: read + +jobs: + sync-issue-to-jira: + if: github.event_name == 'issues' && (github.event.action == 'opened' || github.event.action == 'reopened') + runs-on: ubuntu-latest + steps: + - name: Check Existing Jira Link + id: check-jira + uses: actions/github-script@v7 + with: + script: | + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.issue.number + }); + const hasJira = comments.data.some(c => c.body.includes('Jira:')); + return hasJira; + + - name: Login to Jira + if: steps.check-jira.outputs.result == 'false' + uses: atlassian/gajira-login@v3 + env: + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + + - name: Parse Issue Template + if: steps.check-jira.outputs.result == 'false' + id: parse + uses: actions/github-script@v7 + with: + script: | + const issue = context.payload.issue; + 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')) jiraType = 'Epic'; + else if (labels.includes('story')) jiraType = 'Story'; + else if (labels.includes('bug')) jiraType = 'Bug'; + // task, change-request, spike → Task + + // 제목 prefix로 백업 판단 + if (jiraType === 'Task') { + if (/^\[EPIC\]/i.test(title)) jiraType = 'Epic'; + else if (/^\[STORY\]/i.test(title)) jiraType = 'Story'; + else if (/^\[BUG\]/i.test(title)) jiraType = 'Bug'; + } + + // 템플릿 필드 파싱 (### 헤더 기반) + const parseSection = (label) => { + const regex = new RegExp(`### ${label}\\s*\\n([\\s\\S]*?)(?=###|$)`); + const match = body.match(regex); + return match ? match[1].trim() : ''; + }; + + // 공통 + 템플릿별 필드 + const fields = { + // Epic + goal: parseSection('목표'), + scope: parseSection('범위 / Not-in-scope') || parseSection('작업 범위'), + breakdown: parseSection('하위 스토리\\(체크리스트\\)'), + milestone: parseSection('마일스톤'), + // Story + background: parseSection('배경'), + ac: parseSection('수용 기준\\(AC\\)'), + design: parseSection('디자인/문서 링크') || parseSection('디자인/계약 링크'), + notes: parseSection('구현 메모/리스크'), + epic: parseSection('연결된 Epic'), + // Task + parent: parseSection('연결된 Story/Epic'), + done: parseSection('Done 기준'), + // Change Request + related: parseSection('영향받는 Epic/Story/Task'), + change: parseSection('제안 변경 사항'), + impact: parseSection('영향도'), + decision: parseSection('결정/대안/근거\\(ADR 링크\\)'), + // Spike + timebox: parseSection('타임박스'), + questions: parseSection('핵심 질문'), + approach: parseSection('접근 방법'), + deliverables: parseSection('산출물\\(요약/ADR/POC 링크\\)') + }; + + // 템플릿 타입 판단 + 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'; + + core.setOutput('jira_type', jiraType); + core.setOutput('template_type', templateType); + core.setOutput('goal', fields.goal); + core.setOutput('scope', fields.scope); + core.setOutput('breakdown', fields.breakdown); + core.setOutput('background', fields.background); + core.setOutput('ac', fields.ac); + core.setOutput('design', fields.design); + core.setOutput('parent', fields.parent); + core.setOutput('change', fields.change); + core.setOutput('impact', fields.impact); + core.setOutput('timebox', fields.timebox); + core.setOutput('questions', fields.questions); + + - name: Build Jira Description + if: steps.check-jira.outputs.result == 'false' + id: description + uses: actions/github-script@v7 + with: + script: | + const templateType = '${{ steps.parse.outputs.template_type }}'; + + let desc = `h3. GitHub Issue\n${{ github.event.issue.html_url }}\n\nh3. Author\n${{ github.event.issue.user.login }}\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`; + 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`; + break; + case 'cr': + desc += `h3. 제안 변경 사항\n${{ steps.parse.outputs.change }}\n\n`; + desc += `h3. 영향도\n${{ steps.parse.outputs.impact }}\n`; + break; + case 'spike': + desc += `h3. 타임박스\n${{ steps.parse.outputs.timebox }}\n\n`; + desc += `h3. 핵심 질문\n${{ steps.parse.outputs.questions }}\n`; + break; + default: // task + desc += `h3. 연결된 Story/Epic\n${{ steps.parse.outputs.parent }}\n\n`; + desc += `h3. 작업 범위\n${{ steps.parse.outputs.scope }}\n`; + } + + core.setOutput('content', desc); + + - name: Create Jira Issue + if: steps.check-jira.outputs.result == 'false' + id: create-jira + uses: atlassian/gajira-create@v3 + with: + project: MESP + issuetype: ${{ steps.parse.outputs.jira_type }} + summary: '[Issue-#${{ github.event.issue.number }}] ${{ github.event.issue.title }}' + description: '${{ steps.description.outputs.content }}' + + - name: Add Jira Link Comment + if: steps.check-jira.outputs.result == 'false' + uses: actions/github-script@v7 + with: + script: | + const jiraKey = '${{ steps.create-jira.outputs.issue }}'; + const jiraUrl = '${{ secrets.JIRA_BASE_URL }}/browse/' + jiraKey; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.issue.number, + body: `Jira: [${jiraKey}](${jiraUrl})` + }); + + # Issue 수정 시 Jira 업데이트 + update-jira-on-edit: + if: github.event_name == 'issues' && github.event.action == 'edited' + runs-on: ubuntu-latest + steps: + - name: Get Jira Key from Comments + id: get-jira-key + uses: actions/github-script@v7 + with: + script: | + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.issue.number + }); + + for (const comment of comments.data) { + const match = comment.body.match(/Jira: \[([A-Z]+-\d+)\]/); + if (match) { + core.setOutput('jira_key', match[1]); + return match[1]; + } + } + core.setOutput('jira_key', ''); + return ''; + + - name: Parse Updated Issue + if: steps.get-jira-key.outputs.jira_key != '' + id: parse + uses: actions/github-script@v7 + with: + script: | + const issue = context.payload.issue; + const labels = issue.labels.map(l => l.name); + const title = issue.title; + const body = issue.body || ''; + + const parseSection = (label) => { + const regex = new RegExp(`### ${label}\\s*\\n([\\s\\S]*?)(?=###|$)`); + const match = body.match(regex); + return match ? match[1].trim() : '-'; + }; + + 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 descContent = [ + { type: 'paragraph', content: [{ type: 'text', text: `GitHub Issue: ${issue.html_url}` }] }, + { type: 'paragraph', content: [{ type: 'text', text: `Author: ${issue.user.login}` }] }, + { type: 'paragraph', content: [{ type: 'text', text: `Last Updated: ${new Date().toISOString()}` }] } + ]; + + switch (templateType) { + case 'epic': + descContent.push( + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '목표' }] }, + { type: 'paragraph', content: [{ type: 'text', text: parseSection('목표') }] }, + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '범위' }] }, + { type: 'paragraph', content: [{ type: 'text', text: parseSection('범위 / Not-in-scope') }] } + ); + break; + case 'story': + descContent.push( + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '배경' }] }, + { type: 'paragraph', content: [{ type: 'text', text: parseSection('배경') }] }, + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '수용 기준(AC)' }] }, + { type: 'paragraph', content: [{ type: 'text', text: parseSection('수용 기준\\(AC\\)') }] } + ); + break; + case 'cr': + descContent.push( + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '제안 변경 사항' }] }, + { type: 'paragraph', content: [{ type: 'text', text: parseSection('제안 변경 사항') }] }, + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '영향도' }] }, + { type: 'paragraph', content: [{ type: 'text', text: parseSection('영향도') }] } + ); + break; + case 'spike': + descContent.push( + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '타임박스' }] }, + { type: 'paragraph', content: [{ type: 'text', text: parseSection('타임박스') }] }, + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '핵심 질문' }] }, + { type: 'paragraph', content: [{ type: 'text', text: parseSection('핵심 질문') }] } + ); + break; + default: + descContent.push( + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '연결된 Story/Epic' }] }, + { type: 'paragraph', content: [{ type: 'text', text: parseSection('연결된 Story/Epic') }] }, + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '작업 범위' }] }, + { type: 'paragraph', content: [{ type: 'text', text: parseSection('작업 범위') }] } + ); + } + + core.setOutput('description', JSON.stringify(descContent)); + + - name: Update Jira Issue + if: steps.get-jira-key.outputs.jira_key != '' + uses: actions/github-script@v7 + env: + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + with: + script: | + const jiraKey = '${{ steps.get-jira-key.outputs.jira_key }}'; + const issue = context.payload.issue; + const descContent = ${{ steps.parse.outputs.description }}; + + const response = await fetch( + `${process.env.JIRA_BASE_URL}/rest/api/3/issue/${jiraKey}`, + { + method: 'PUT', + headers: { + 'Authorization': `Basic ${Buffer.from(`${process.env.JIRA_USER_EMAIL}:${process.env.JIRA_API_TOKEN}`).toString('base64')}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + fields: { + summary: `[Issue-#${issue.number}] ${issue.title}`, + description: { + type: 'doc', + version: 1, + content: descContent + } + } + }) + } + ); + + if (response.ok) { + console.log(`✓ Updated Jira ${jiraKey}`); + } else { + const error = await response.text(); + console.log(`✗ Failed to update Jira ${jiraKey}: ${error}`); + } + + # Issue 닫기 시 Jira 상태 변경 + close-jira-on-issue-close: + if: github.event_name == 'issues' && github.event.action == 'closed' + runs-on: ubuntu-latest + steps: + - name: Get Jira Key from Comments + id: get-jira-key + uses: actions/github-script@v7 + with: + script: | + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.issue.number + }); + + for (const comment of comments.data) { + const match = comment.body.match(/Jira: \[([A-Z]+-\d+)\]/); + if (match) { + core.setOutput('jira_key', match[1]); + return match[1]; + } + } + core.setOutput('jira_key', ''); + return ''; + + - name: Get Jira Transitions + if: steps.get-jira-key.outputs.jira_key != '' + id: get-transitions + uses: actions/github-script@v7 + env: + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + with: + script: | + const jiraKey = '${{ steps.get-jira-key.outputs.jira_key }}'; + + const response = await fetch( + `${process.env.JIRA_BASE_URL}/rest/api/3/issue/${jiraKey}/transitions`, + { + headers: { + 'Authorization': `Basic ${Buffer.from(`${process.env.JIRA_USER_EMAIL}:${process.env.JIRA_API_TOKEN}`).toString('base64')}`, + 'Content-Type': 'application/json' + } + } + ); + + const data = await response.json(); + console.log('Available transitions:', JSON.stringify(data.transitions, null, 2)); + + // "Done", "완료", "Closed" 등의 transition 찾기 + const doneTransition = data.transitions.find(t => + /done|완료|closed?|complete/i.test(t.name) + ); + + if (doneTransition) { + core.setOutput('transition_id', doneTransition.id); + console.log(`Found transition: ${doneTransition.name} (${doneTransition.id})`); + } else { + core.setOutput('transition_id', ''); + console.log('No matching transition found'); + } + + - name: Transition Jira to Done + if: steps.get-jira-key.outputs.jira_key != '' && steps.get-transitions.outputs.transition_id != '' + uses: actions/github-script@v7 + env: + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + with: + script: | + const jiraKey = '${{ steps.get-jira-key.outputs.jira_key }}'; + const transitionId = '${{ steps.get-transitions.outputs.transition_id }}'; + + const response = await fetch( + `${process.env.JIRA_BASE_URL}/rest/api/3/issue/${jiraKey}/transitions`, + { + method: 'POST', + headers: { + 'Authorization': `Basic ${Buffer.from(`${process.env.JIRA_USER_EMAIL}:${process.env.JIRA_API_TOKEN}`).toString('base64')}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + transition: { id: transitionId } + }) + } + ); + + if (response.ok) { + console.log(`✓ Transitioned Jira ${jiraKey} to Done`); + } else { + const error = await response.text(); + console.log(`✗ Failed to transition Jira ${jiraKey}: ${error}`); + } + + - name: Add Close Comment to Jira + if: steps.get-jira-key.outputs.jira_key != '' + uses: actions/github-script@v7 + env: + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + with: + script: | + const jiraKey = '${{ steps.get-jira-key.outputs.jira_key }}'; + const issue = context.payload.issue; + + await fetch( + `${process.env.JIRA_BASE_URL}/rest/api/3/issue/${jiraKey}/comment`, + { + method: 'POST', + headers: { + 'Authorization': `Basic ${Buffer.from(`${process.env.JIRA_USER_EMAIL}:${process.env.JIRA_API_TOKEN}`).toString('base64')}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + body: { + type: 'doc', + version: 1, + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: `GitHub Issue closed by ${issue.user.login} at ${new Date().toISOString()}` } + ] + } + ] + } + }) + } + ); + + # 기존 이슈 일괄 처리 + sync-all-open-issues: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - name: Process All Open Issues + uses: actions/github-script@v7 + env: + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + with: + script: | + const issues = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100 + }); + + const realIssues = issues.data.filter(issue => !issue.pull_request); + console.log(`Found ${realIssues.length} open issues`); + + for (const issue of realIssues) { + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + 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( + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '목표' }] }, + { type: 'paragraph', content: [{ type: 'text', text: parseSection('목표') }] }, + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '범위' }] }, + { type: 'paragraph', content: [{ type: 'text', text: parseSection('범위 / Not-in-scope') }] } + ); + break; + case 'story': + descContent.push( + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '배경' }] }, + { type: 'paragraph', content: [{ type: 'text', text: parseSection('배경') }] }, + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '수용 기준(AC)' }] }, + { type: 'paragraph', content: [{ type: 'text', text: parseSection('수용 기준\\(AC\\)') }] } + ); + break; + case 'cr': + descContent.push( + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '제안 변경 사항' }] }, + { type: 'paragraph', content: [{ type: 'text', text: parseSection('제안 변경 사항') }] }, + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '영향도' }] }, + { type: 'paragraph', content: [{ type: 'text', text: parseSection('영향도') }] } + ); + break; + case 'spike': + descContent.push( + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '타임박스' }] }, + { type: 'paragraph', content: [{ type: 'text', text: parseSection('타임박스') }] }, + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '핵심 질문' }] }, + { type: 'paragraph', content: [{ type: 'text', text: parseSection('핵심 질문') }] } + ); + break; + default: + descContent.push( + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '연결된 Story/Epic' }] }, + { type: 'paragraph', content: [{ type: 'text', text: parseSection('연결된 Story/Epic') }] }, + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '작업 범위' }] }, + { type: 'paragraph', content: [{ type: 'text', text: parseSection('작업 범위') }] } + ); + } + + const jiraResponse = await fetch( + `${process.env.JIRA_BASE_URL}/rest/api/3/issue`, + { + method: 'POST', + headers: { + 'Authorization': `Basic ${Buffer.from(`${process.env.JIRA_USER_EMAIL}:${process.env.JIRA_API_TOKEN}`).toString('base64')}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + fields: { + project: { key: 'MESP' }, + summary: `[Issue-#${issue.number}] ${issue.title}`, + description: { + type: 'doc', + version: 1, + content: descContent + }, + issuetype: { name: jiraType } + } + }) + } + ); + + const jiraData = await jiraResponse.json(); + + if (jiraData.key) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: `Jira: [${jiraData.key}](${process.env.JIRA_BASE_URL}/browse/${jiraData.key})` + }); + console.log(`Created Jira ${jiraData.key} for Issue #${issue.number}`); + } 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 new file mode 100644 index 0000000..f1d3aea --- /dev/null +++ b/.github/workflows/github-jira-pr-sync.yml @@ -0,0 +1,489 @@ +name: PR-Jira Sync + +on: + pull_request_target: + types: [opened, reopened, edited, closed] + branches: + - develop + - main + + workflow_dispatch: + inputs: + process_all_open_prs: + description: 'Process all open PRs' + type: boolean + default: true + +permissions: + issues: write + pull-requests: write + contents: read + +jobs: + sync-pr-to-jira: + if: github.event_name == 'pull_request_target' + runs-on: ubuntu-latest + steps: + - name: Check Existing Jira Link + id: check-jira + uses: actions/github-script@v7 + with: + script: | + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number + }); + const hasJira = comments.data.some(c => c.body.includes('Jira:')); + return hasJira; + + - name: Login to Jira + if: steps.check-jira.outputs.result == 'false' + uses: atlassian/gajira-login@v3 + env: + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + + - name: Parse PR Template + if: steps.check-jira.outputs.result == 'false' + id: parse + uses: actions/github-script@v7 + with: + script: | + const title = context.payload.pull_request.title; + const body = context.payload.pull_request.body || ''; + + // 제목에서 type 파싱: feat(scope): summary 또는 fix: summary + const typeMatch = title.match(/^(\w+)(?:\([^)]*\))?:/); + const type = typeMatch ? typeMatch[1].toLowerCase() : 'task'; + + // type → Jira Issue Type 매핑 + const typeMap = { + 'feat': 'Story', + 'feature': 'Story', + 'fix': 'Bug', + 'hotfix': 'Bug', + 'bug': 'Bug', + 'epic': 'Epic', + 'docs': 'Task', + 'chore': 'Task', + 'refactor': 'Task', + 'test': 'Task', + 'style': 'Task', + 'perf': 'Task' + }; + const jiraType = typeMap[type] || 'Task'; + + // 본문에서 섹션 파싱 + const sections = {}; + const sectionNames = ['목적', '변경 요약', '수용 기준 검증', '브레이킹/마이그레이션', '테스트', '참조']; + + for (const name of sectionNames) { + const regex = new RegExp(`${name}\\n([\\s\\S]*?)(?=\\n(?:${sectionNames.join('|')})|$)`); + const match = body.match(regex); + if (match) { + sections[name] = match[1].trim(); + } + } + + // refs #123 에서 이슈 번호 추출 + const issueRef = title.match(/refs #(\d+)/i); + const relatedIssue = issueRef ? issueRef[1] : null; + + core.setOutput('jira_type', jiraType); + core.setOutput('purpose', sections['목적'] || ''); + core.setOutput('changes', sections['변경 요약'] || ''); + core.setOutput('acceptance', sections['수용 기준 검증'] || ''); + core.setOutput('related_issue', relatedIssue || ''); + + - name: Create Jira Issue + if: steps.check-jira.outputs.result == 'false' + id: create-jira + uses: atlassian/gajira-create@v3 + with: + project: MESP + issuetype: ${{ steps.parse.outputs.jira_type }} + summary: '[PR-#${{ github.event.pull_request.number }}] ${{ github.event.pull_request.title }}' + description: | + h3. GitHub PR + ${{ github.event.pull_request.html_url }} + + h3. Author + ${{ github.event.pull_request.user.login }} + + h3. Branch + ${{ github.head_ref }} → ${{ github.base_ref }} + + h3. 목적 + ${{ steps.parse.outputs.purpose }} + + h3. 변경 요약 + ${{ steps.parse.outputs.changes }} + + h3. 수용 기준 + ${{ steps.parse.outputs.acceptance }} + + - name: Add Jira Link Comment + if: steps.check-jira.outputs.result == 'false' + uses: actions/github-script@v7 + with: + script: | + const jiraKey = '${{ steps.create-jira.outputs.issue }}'; + const jiraUrl = '${{ secrets.JIRA_BASE_URL }}/browse/' + jiraKey; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: `Jira: [${jiraKey}](${jiraUrl})` + }); + + # PR 수정 시 Jira 업데이트 + update-jira-on-pr-edit: + if: github.event_name == 'pull_request_target' && github.event.action == 'edited' + runs-on: ubuntu-latest + steps: + - name: Get Jira Key from Comments + id: get-jira-key + uses: actions/github-script@v7 + with: + script: | + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number + }); + + for (const comment of comments.data) { + const match = comment.body.match(/Jira: \[([A-Z]+-\d+)\]/); + if (match) { + core.setOutput('jira_key', match[1]); + return match[1]; + } + } + core.setOutput('jira_key', ''); + return ''; + + - name: Parse Updated PR + if: steps.get-jira-key.outputs.jira_key != '' + id: parse + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const body = pr.body || ''; + + const parseSection = (name, sectionNames) => { + const regex = new RegExp(`${name}\\n([\\s\\S]*?)(?=\\n(?:${sectionNames.join('|')})|$)`); + const match = body.match(regex); + return match ? match[1].trim() : '-'; + }; + + const sectionNames = ['목적', '변경 요약', '수용 기준 검증', '브레이킹/마이그레이션', '테스트', '참조']; + + const descContent = [ + { type: 'paragraph', content: [{ type: 'text', text: `GitHub PR: ${pr.html_url}` }] }, + { type: 'paragraph', content: [{ type: 'text', text: `Author: ${pr.user.login}` }] }, + { type: 'paragraph', content: [{ type: 'text', text: `Branch: ${pr.head.ref} → ${pr.base.ref}` }] }, + { type: 'paragraph', content: [{ type: 'text', text: `Last Updated: ${new Date().toISOString()}` }] }, + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '목적' }] }, + { type: 'paragraph', content: [{ type: 'text', text: parseSection('목적', sectionNames) }] }, + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '변경 요약' }] }, + { type: 'paragraph', content: [{ type: 'text', text: parseSection('변경 요약', sectionNames) }] }, + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '수용 기준 검증' }] }, + { type: 'paragraph', content: [{ type: 'text', text: parseSection('수용 기준 검증', sectionNames) }] } + ]; + + core.setOutput('description', JSON.stringify(descContent)); + + - name: Update Jira Issue + if: steps.get-jira-key.outputs.jira_key != '' + uses: actions/github-script@v7 + env: + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + with: + script: | + const jiraKey = '${{ steps.get-jira-key.outputs.jira_key }}'; + const pr = context.payload.pull_request; + const descContent = ${{ steps.parse.outputs.description }}; + + const response = await fetch( + `${process.env.JIRA_BASE_URL}/rest/api/3/issue/${jiraKey}`, + { + method: 'PUT', + headers: { + 'Authorization': `Basic ${Buffer.from(`${process.env.JIRA_USER_EMAIL}:${process.env.JIRA_API_TOKEN}`).toString('base64')}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + fields: { + summary: `[PR-#${pr.number}] ${pr.title}`, + description: { + type: 'doc', + version: 1, + content: descContent + } + } + }) + } + ); + + if (response.ok) { + console.log(`✓ Updated Jira ${jiraKey}`); + } else { + const error = await response.text(); + console.log(`✗ Failed to update Jira ${jiraKey}: ${error}`); + } + + # PR 닫기/머지 시 Jira 상태 변경 + close-jira-on-pr-close: + if: github.event_name == 'pull_request_target' && github.event.action == 'closed' + runs-on: ubuntu-latest + steps: + - name: Get Jira Key from Comments + id: get-jira-key + uses: actions/github-script@v7 + with: + script: | + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number + }); + + for (const comment of comments.data) { + const match = comment.body.match(/Jira: \[([A-Z]+-\d+)\]/); + if (match) { + core.setOutput('jira_key', match[1]); + return match[1]; + } + } + core.setOutput('jira_key', ''); + return ''; + + - name: Get Jira Transitions + if: steps.get-jira-key.outputs.jira_key != '' + id: get-transitions + uses: actions/github-script@v7 + env: + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + with: + script: | + const jiraKey = '${{ steps.get-jira-key.outputs.jira_key }}'; + + const response = await fetch( + `${process.env.JIRA_BASE_URL}/rest/api/3/issue/${jiraKey}/transitions`, + { + headers: { + 'Authorization': `Basic ${Buffer.from(`${process.env.JIRA_USER_EMAIL}:${process.env.JIRA_API_TOKEN}`).toString('base64')}`, + 'Content-Type': 'application/json' + } + } + ); + + const data = await response.json(); + console.log('Available transitions:', JSON.stringify(data.transitions, null, 2)); + + const doneTransition = data.transitions.find(t => + /done|완료|closed?|complete/i.test(t.name) + ); + + if (doneTransition) { + core.setOutput('transition_id', doneTransition.id); + console.log(`Found transition: ${doneTransition.name} (${doneTransition.id})`); + } else { + core.setOutput('transition_id', ''); + console.log('No matching transition found'); + } + + - name: Transition Jira to Done + if: steps.get-jira-key.outputs.jira_key != '' && steps.get-transitions.outputs.transition_id != '' + uses: actions/github-script@v7 + env: + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + with: + script: | + const jiraKey = '${{ steps.get-jira-key.outputs.jira_key }}'; + const transitionId = '${{ steps.get-transitions.outputs.transition_id }}'; + + const response = await fetch( + `${process.env.JIRA_BASE_URL}/rest/api/3/issue/${jiraKey}/transitions`, + { + method: 'POST', + headers: { + 'Authorization': `Basic ${Buffer.from(`${process.env.JIRA_USER_EMAIL}:${process.env.JIRA_API_TOKEN}`).toString('base64')}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + transition: { id: transitionId } + }) + } + ); + + if (response.ok) { + console.log(`✓ Transitioned Jira ${jiraKey} to Done`); + } else { + const error = await response.text(); + console.log(`✗ Failed to transition Jira ${jiraKey}: ${error}`); + } + + - name: Add Close Comment to Jira + if: steps.get-jira-key.outputs.jira_key != '' + uses: actions/github-script@v7 + env: + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + with: + script: | + const jiraKey = '${{ steps.get-jira-key.outputs.jira_key }}'; + const pr = context.payload.pull_request; + const merged = pr.merged ? 'merged' : 'closed without merge'; + + await fetch( + `${process.env.JIRA_BASE_URL}/rest/api/3/issue/${jiraKey}/comment`, + { + method: 'POST', + headers: { + 'Authorization': `Basic ${Buffer.from(`${process.env.JIRA_USER_EMAIL}:${process.env.JIRA_API_TOKEN}`).toString('base64')}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + body: { + type: 'doc', + version: 1, + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: `GitHub PR ${merged} by ${pr.user.login} at ${new Date().toISOString()}` } + ] + } + ] + } + }) + } + ); + + # 기존 PR 일괄 처리 + sync-all-open-prs: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - name: Process All Open PRs + uses: actions/github-script@v7 + env: + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + with: + script: | + const prs = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100 + }); + + console.log(`Found ${prs.data.length} open PRs`); + + const typeMap = { + 'feat': 'Story', + 'feature': 'Story', + 'fix': 'Bug', + 'hotfix': 'Bug', + 'bug': 'Bug', + 'epic': 'Epic', + 'docs': 'Task', + 'chore': 'Task', + 'refactor': 'Task', + 'test': 'Task', + 'style': 'Task', + 'perf': 'Task' + }; + + for (const pr of prs.data) { + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number + }); + + const hasJiraLink = comments.data.some(c => c.body.includes('Jira:')); + if (hasJiraLink) { + console.log(`PR #${pr.number} already has Jira link, skipping`); + continue; + } + + console.log(`Processing PR #${pr.number}: ${pr.title}`); + + // 제목에서 type 파싱 + const typeMatch = pr.title.match(/^(\w+)(?:\([^)]*\))?:/); + const type = typeMatch ? typeMatch[1].toLowerCase() : 'task'; + const jiraType = typeMap[type] || 'Task'; + + // 본문 파싱 + const body = pr.body || ''; + const sections = {}; + const sectionNames = ['목적', '변경 요약', '수용 기준 검증']; + + for (const name of sectionNames) { + const regex = new RegExp(`${name}\\n([\\s\\S]*?)(?=\\n(?:목적|변경 요약|수용 기준 검증|브레이킹|테스트|참조)|$)`); + const match = body.match(regex); + if (match) sections[name] = match[1].trim(); + } + + const jiraResponse = await fetch( + `${process.env.JIRA_BASE_URL}/rest/api/3/issue`, + { + method: 'POST', + headers: { + 'Authorization': `Basic ${Buffer.from(`${process.env.JIRA_USER_EMAIL}:${process.env.JIRA_API_TOKEN}`).toString('base64')}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + fields: { + project: { key: 'MESP' }, + summary: `[PR-#${pr.number}] ${pr.title}`, + description: { + type: 'doc', + version: 1, + content: [ + { type: 'paragraph', content: [{ type: 'text', text: `GitHub PR: ${pr.html_url}` }] }, + { type: 'paragraph', content: [{ type: 'text', text: `Author: ${pr.user.login}` }] }, + { type: 'paragraph', content: [{ type: 'text', text: `Branch: ${pr.head.ref} → ${pr.base.ref}` }] }, + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '목적' }] }, + { type: 'paragraph', content: [{ type: 'text', text: sections['목적'] || '-' }] }, + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '변경 요약' }] }, + { type: 'paragraph', content: [{ type: 'text', text: sections['변경 요약'] || '-' }] } + ] + }, + issuetype: { name: jiraType } + } + }) + } + ); + + const jiraData = await jiraResponse.json(); + + if (jiraData.key) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: `Jira: [${jiraData.key}](${process.env.JIRA_BASE_URL}/browse/${jiraData.key})` + }); + console.log(`Created Jira ${jiraData.key} for PR #${pr.number}`); + } else { + console.log(`Failed to create Jira for PR #${pr.number}:`, jiraData); + } + + await new Promise(resolve => setTimeout(resolve, 1000)); + } \ No newline at end of file diff --git a/PACKAGE_INFO.md b/PACKAGE_INFO.md index 41c11ea..377be0e 100644 --- a/PACKAGE_INFO.md +++ b/PACKAGE_INFO.md @@ -7,45 +7,51 @@ AI 기반 음성 인터뷰·언어 연습 시스템 프론트엔드 ## NPM Dependencies ### Core -| 패키지 | 버전 | 설명 | -|--------|------|------| -| `react` | ^19.0.0 | UI 라이브러리 | -| `react-dom` | ^19.0.0 | React DOM 렌더링 | -| `react-router-dom` | ^7.0.0 | 클라이언트 사이드 라우팅 | + +| 패키지 | 버전 | 설명 | +|--------------------|---------|---------------| +| `react` | ^19.0.0 | UI 라이브러리 | +| `react-dom` | ^19.0.0 | React DOM 렌더링 | +| `react-router-dom` | ^7.0.0 | 클라이언트 사이드 라우팅 | ### State Management -| 패키지 | 버전 | 설명 | -|--------|------|------| + +| 패키지 | 버전 | 설명 | +|--------------------|--------|------------------------------| | `@reduxjs/toolkit` | ^2.0.0 | Redux 상태관리 (slice, thunk 포함) | -| `react-redux` | ^9.0.0 | React-Redux 바인딩 | +| `react-redux` | ^9.0.0 | React-Redux 바인딩 | ### UI Framework -| 패키지 | 버전 | 설명 | -|--------|------|------| -| `@mui/material` | ^7.0.0 | Material UI 컴포넌트 | -| `@mui/icons-material` | ^7.0.0 | Material 아이콘 | -| `@mui/x-date-pickers` | ^8.0.0 | 날짜/시간 선택 컴포넌트 | -| `@emotion/react` | ^11.14.0 | CSS-in-JS (MUI 의존성) | -| `@emotion/styled` | ^11.14.0 | Styled components (MUI 의존성) | + +| 패키지 | 버전 | 설명 | +|-----------------------|----------|-----------------------------| +| `@mui/material` | ^7.0.0 | Material UI 컴포넌트 | +| `@mui/icons-material` | ^7.0.0 | Material 아이콘 | +| `@mui/x-date-pickers` | ^8.0.0 | 날짜/시간 선택 컴포넌트 | +| `@emotion/react` | ^11.14.0 | CSS-in-JS (MUI 의존성) | +| `@emotion/styled` | ^11.14.0 | Styled components (MUI 의존성) | ### HTTP & API -| 패키지 | 버전 | 설명 | -|--------|------|------| + +| 패키지 | 버전 | 설명 | +|---------|--------|--------------------------| | `axios` | ^1.7.0 | HTTP 클라이언트 (REST API 호출) | ### Utilities -| 패키지 | 버전 | 설명 | -|--------|------|------| + +| 패키지 | 버전 | 설명 | +|------------|--------|------------| | `date-fns` | ^4.1.0 | 날짜 처리 유틸리티 | ### Dev Dependencies -| 패키지 | 버전 | 설명 | -|--------|------|------| -| `vite` | ^6.0.0 | 빌드 도구 & 개발 서버 | -| `@vitejs/plugin-react` | ^5.0.0 | Vite React 플러그인 | -| `eslint` | ^9.0.0 | 코드 린팅 | -| `eslint-plugin-react-hooks` | ^5.0.0 | React Hooks 규칙 검사 | -| `eslint-plugin-react-refresh` | ^0.4.0 | Fast Refresh 지원 | + +| 패키지 | 버전 | 설명 | +|-------------------------------|--------|-------------------| +| `vite` | ^6.0.0 | 빌드 도구 & 개발 서버 | +| `@vitejs/plugin-react` | ^5.0.0 | Vite React 플러그인 | +| `eslint` | ^9.0.0 | 코드 린팅 | +| `eslint-plugin-react-hooks` | ^5.0.0 | React Hooks 규칙 검사 | +| `eslint-plugin-react-refresh` | ^0.4.0 | Fast Refresh 지원 | --- @@ -73,17 +79,20 @@ src/ ## Folder Details ### `/api` + ``` api/ └── axios.js # Axios 인스턴스 설정, 인터셉터 (토큰 주입, 에러 처리) ``` ### `/assets` + ``` assets/ # 이미지, 폰트, 오디오 등 정적 파일 ``` ### `/components` - 공통 컴포넌트 + ``` components/ ├── common/ # 범용 UI (Button, Modal, Card, Loading 등) @@ -93,11 +102,13 @@ components/ ``` ### `/contexts` + ``` contexts/ # React Context (AuthContext, ThemeContext 등) ``` ### `/domains` - 도메인별 기능 (핵심) + ``` domains/ ├── auth/ # 인증/인가 @@ -156,6 +167,7 @@ domains/ ``` #### 도메인 관계도 + ``` ┌─────────────────────────────────────────────────────────────┐ │ auth (인증) │ @@ -177,6 +189,7 @@ domains/ ``` ### `/hooks` - 공통 커스텀 훅 + ``` hooks/ ├── useAudio.js # 오디오 재생/녹음 제어 @@ -187,6 +200,7 @@ hooks/ ``` ### `/layouts` + ``` layouts/ ├── MainLayout/ # 메인 레이아웃 (헤더, 사이드바, 푸터) @@ -194,6 +208,7 @@ layouts/ ``` ### `/pages` + ``` pages/ ├── Home/ # 랜딩 페이지 @@ -203,6 +218,7 @@ pages/ ``` ### `/services` - 외부 서비스 연동 + ``` services/ ├── api/ # API 공통 래퍼 @@ -212,23 +228,27 @@ services/ ``` ### `/store` + ``` store/ └── index.js # Redux store 설정, 미들웨어, 슬라이스 결합 ``` ### `/styles` + ``` styles/ # 글로벌 CSS, CSS 변수, 애니메이션 ``` ### `/theme` + ``` theme/ └── theme.js # MUI 테마 설정 (색상, 타이포그래피, 컴포넌트 스타일) ``` ### `/utils` + ``` utils/ # 유틸리티 함수 (formatDate, validateEmail, etc.) ``` @@ -237,15 +257,15 @@ utils/ # 유틸리티 함수 (formatDate, validateEmail, etc.) ## AWS Service Integration Map -| 프론트 기능 | 연동 서비스 | 파일 위치 | -|-------------|-------------|-----------| -| 로그인/회원가입 | Cognito | `domains/auth/services/` | -| REST API 호출 | API Gateway | `api/axios.js` | -| 실시간 채팅 | WebSocket API | `services/chat/` | -| 음성 재생 (TTS) | Polly | `services/audio/` | -| 음성 녹음 (STT) | Transcribe + S3 | `services/audio/` | -| AI 대화/피드백 | Bedrock/Claude | `services/ai/` | -| 정적 파일 | S3 + CloudFront | 빌드 배포 | +| 프론트 기능 | 연동 서비스 | 파일 위치 | +|-------------|-----------------|--------------------------| +| 로그인/회원가입 | Cognito | `domains/auth/services/` | +| REST API 호출 | API Gateway | `api/axios.js` | +| 실시간 채팅 | WebSocket API | `services/chat/` | +| 음성 재생 (TTS) | Polly | `services/audio/` | +| 음성 녹음 (STT) | Transcribe + S3 | `services/audio/` | +| AI 대화/피드백 | Bedrock/Claude | `services/ai/` | +| 정적 파일 | S3 + CloudFront | 빌드 배포 | --- @@ -262,10 +282,10 @@ npm run lint # ESLint 검사 ## Environment Variables -| 변수명 | 설명 | 예시 | -|--------|------|------| -| `VITE_API_URL` | REST API 엔드포인트 | `https://api.example.com` | -| `VITE_WS_URL` | WebSocket 엔드포인트 | `wss://ws.example.com` | -| `VITE_COGNITO_POOL_ID` | Cognito User Pool ID | `ap-northeast-2_xxxxx` | -| `VITE_COGNITO_CLIENT_ID` | Cognito App Client ID | `xxxxxxxxxxxxxxxxxx` | -| `VITE_S3_BUCKET` | S3 버킷 URL | `https://bucket.s3.amazonaws.com` | +| 변수명 | 설명 | 예시 | +|--------------------------|-----------------------|-----------------------------------| +| `VITE_API_URL` | REST API 엔드포인트 | `https://api.example.com` | +| `VITE_WS_URL` | WebSocket 엔드포인트 | `wss://ws.example.com` | +| `VITE_COGNITO_POOL_ID` | Cognito User Pool ID | `ap-northeast-2_xxxxx` | +| `VITE_COGNITO_CLIENT_ID` | Cognito App Client ID | `xxxxxxxxxxxxxxxxxx` | +| `VITE_S3_BUCKET` | S3 버킷 URL | `https://bucket.s3.amazonaws.com` | diff --git a/docs/ISSUE_GUIDE.md b/docs/ISSUE_GUIDE.md index 5eb2158..c5bef69 100644 --- a/docs/ISSUE_GUIDE.md +++ b/docs/ISSUE_GUIDE.md @@ -12,52 +12,56 @@ Epic (에픽) └── Task (태스크) ``` -| 계층 | 설명 | 예시 | -|------|------|------| -| **Epic** | 대규모 기능 단위 (1~2주 이상) | 메인 레이아웃 구현, 인증 시스템 구축 | +| 계층 | 설명 | 예시 | +|-----------|----------------------|--------------------------| +| **Epic** | 대규모 기능 단위 (1~2주 이상) | 메인 레이아웃 구현, 인증 시스템 구축 | | **Story** | 사용자 관점의 기능 단위 (2~5일) | Header 컴포넌트 개발, 로그인 폼 구현 | -| **Task** | 개발자 작업 단위 (1일 이내) | 로고 배치, 네비게이션 메뉴 구현 | +| **Task** | 개발자 작업 단위 (1일 이내) | 로고 배치, 네비게이션 메뉴 구현 | --- ## 이슈 라벨 ### 계층 라벨 (필수) -| 라벨 | 색상 | 설명 | -|------|------|------| -| `epic` | `#6B21A8` (보라) | 에픽 이슈 | + +| 라벨 | 색상 | 설명 | +|---------|----------------|--------| +| `epic` | `#6B21A8` (보라) | 에픽 이슈 | | `story` | `#2563EB` (파랑) | 스토리 이슈 | -| `task` | `#16A34A` (초록) | 태스크 이슈 | +| `task` | `#16A34A` (초록) | 태스크 이슈 | ### 타입 라벨 -| 라벨 | 설명 | -|------|------| -| `feature` | 새 기능 | -| `bug` | 버그 수정 | -| `enhancement` | 개선 | -| `docs` | 문서 | -| `refactor` | 리팩토링 | -| `style` | UI/스타일 | + +| 라벨 | 설명 | +|---------------|--------| +| `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` | 레이아웃 | + +| 라벨 | 설명 | +|--------------------|------| +| `domain:auth` | 인증 | +| `domain:user` | 회원 | +| `domain:profile` | 프로필 | +| `domain:chat` | 채팅 | +| `domain:interview` | 면접 | +| `domain:opic` | OPIC | +| `domain:freetalk` | 프리토킹 | +| `domain:writing` | 작문 | +| `domain:report` | 리포트 | +| `domain:layout` | 레이아웃 | --- ## 이슈 템플릿 ### Epic 템플릿 + ```markdown ## Epic: [에픽 제목] @@ -79,6 +83,7 @@ Epic (에픽) ``` ### Story 템플릿 + ```markdown ## Story: [스토리 제목] @@ -102,6 +107,7 @@ Epic (에픽) ``` ### Task 템플릿 + ```markdown ## Task: [태스크 제목] @@ -125,6 +131,7 @@ Epic (에픽) ## 이슈 발행 CLI 명령어 ### 라벨 생성 (최초 1회) + ```bash # 계층 라벨 gh label create "epic" --color "6B21A8" --description "에픽 이슈" @@ -143,6 +150,7 @@ gh label create "domain:chat" --color "BBF7D0" --description "채팅" ``` ### Epic 발행 + ```bash gh issue create \ --title "[Epic] 메인 레이아웃 구현" \ @@ -169,6 +177,7 @@ gh issue create \ ``` ### Story 발행 + ```bash gh issue create \ --title "[Story] Header 컴포넌트 개발" \ @@ -192,6 +201,7 @@ gh issue create \ ``` ### Task 발행 + ```bash gh issue create \ --title "[Task] 네비게이션 메뉴 구현" \ @@ -222,6 +232,7 @@ feature/{에픽번호}/{스토리번호}/{태스크번호}/{브랜치명} ``` ### 예시 + ``` main └── develop @@ -235,6 +246,7 @@ main ``` ### 브랜치 생성 명령어 + ```bash # develop 브랜치에서 시작 git checkout develop @@ -270,6 +282,7 @@ gh pr create \ ## 커밋 메시지 컨벤션 ### 형식 + ``` {타입}({스코프}): {제목} (#{이슈번호}) @@ -277,20 +290,23 @@ gh pr create \ ``` ### 타입 -| 타입 | 설명 | -|------|------| -| `feat` | 새 기능 | -| `fix` | 버그 수정 | -| `docs` | 문서 수정 | -| `style` | 포맷팅, 세미콜론 등 | -| `refactor` | 리팩토링 | -| `test` | 테스트 추가 | -| `chore` | 빌드, 설정 등 | + +| 타입 | 설명 | +|------------|-------------| +| `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)" @@ -351,6 +367,7 @@ git commit -m "refactor(chat): WebSocket 연결 로직 개선 (#22)" ``` ### 대응 브랜치 + ``` feature/1/2/5/logo-title feature/1/2/6/navigation-menu diff --git a/eslint.config.js b/eslint.config.js index 70e2bd2..ccd22ea 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,31 +1,30 @@ -import js from '@eslint/js' import globals from 'globals' import reactHooks from 'eslint-plugin-react-hooks' import reactRefresh from 'eslint-plugin-react-refresh' export default [ - { ignores: ['dist'] }, - { - files: ['**/*.{js,jsx}'], - languageOptions: { - ecmaVersion: 2020, - globals: globals.browser, - parserOptions: { - ecmaVersion: 'latest', - ecmaFeatures: { jsx: true }, - sourceType: 'module', - }, + {ignores: ['dist']}, + { + files: ['**/*.{js,jsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: {jsx: true}, + sourceType: 'module', + }, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + {allowConstantExport: true}, + ], + }, }, - plugins: { - 'react-hooks': reactHooks, - 'react-refresh': reactRefresh, - }, - rules: { - ...reactHooks.configs.recommended.rules, - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true }, - ], - }, - }, ] diff --git a/index.html b/index.html index 689edbd..b287378 100644 --- a/index.html +++ b/index.html @@ -1,13 +1,13 @@ - - - - + + + + FE Repository - - -
- - + + +
+ + diff --git a/public/vite.svg b/public/vite.svg index 6a41099..5bbf88d 100644 --- a/public/vite.svg +++ b/public/vite.svg @@ -1 +1,17 @@ - + diff --git a/src/App.jsx b/src/App.jsx index 9ab39ae..e652c82 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,51 +1,805 @@ -import { Routes, Route } from 'react-router-dom' -import { Box, Typography, Container, Button, Stack } from '@mui/material' - -function Home() { - return ( - - - - Welcome to FE Repository - - - React + Vite + MUI 프로젝트가 준비되었습니다. - - - - - - - - - - - Primary Color: #0124ac - - - - - ) +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 { + ChevronRight as ChevronRightIcon, + Create as WritingCategoryIcon, + Edit as WritingIcon, + Headphones as OpicIcon, + LibraryBooks as WordListIcon, + MenuBook as VocabIcon, + Mic as SpeakingIcon, + People as PeopleIcon, + Quiz as QuizIcon, + School as LearnIcon, + SmartToy as AiIcon, + WavingHand as WaveIcon, +} from '@mui/icons-material' +import MainLayout from './layouts/MainLayout' +import FreetalkPeoplePage from './domains/freetalk/pages/FreetalkPeoplePage' +import ChatRoomPage from './domains/freetalk/pages/ChatRoomPage' +import ChatRoomModal from './domains/freetalk/components/ChatRoomModal' +import VocabDashboard from './domains/vocab/pages/VocabDashboard' +import DailyLearning from './domains/vocab/pages/DailyLearning' +import TestPage from './domains/vocab/pages/TestPage' +import WordListPage from './domains/vocab/pages/WordListPage' +import StatsPage from './domains/vocab/pages/StatsPage' +import {WritingPage} from './domains/grammar' +import {BadgeSection} from './domains/badge' +import {useChat} from './contexts/ChatContext' +import {useSettings} from './contexts/SettingsContext' + +// Dashboard Page +function Dashboard() { + const navigate = useNavigate() + const [expandedCard, setExpandedCard] = useState(null) + const {t} = useSettings() + + const learningModes = [ + { + id: 'speaking', + title: t('dashboard.speakingTitle'), + description: t('dashboard.speakingDesc'), + icon: SpeakingIcon, + color: '#3b82f6', + bgColor: '#eff6ff', + children: [ + { + id: 'opic', + title: t('dashboard.opicTitle'), + icon: OpicIcon, + path: '/opic', + description: t('dashboard.opicDesc') + }, + { + id: 'ai-talk', + title: t('dashboard.aiTalkTitle'), + icon: AiIcon, + path: '/freetalk/ai', + description: t('dashboard.aiTalkDesc') + }, + ], + }, + { + id: 'writing', + title: t('dashboard.writingTitle'), + description: t('dashboard.writingDesc'), + icon: WritingCategoryIcon, + color: '#10b981', + bgColor: '#ecfdf5', + children: [ + { + id: 'chat-people', + title: t('dashboard.chatTitle'), + icon: PeopleIcon, + path: '/freetalk/people', + description: t('dashboard.chatDesc') + }, + { + id: 'writing-practice', + title: t('dashboard.compositionTitle'), + icon: WritingIcon, + path: '/writing', + description: t('dashboard.compositionDesc') + }, + ], + }, + { + id: 'vocab', + title: t('dashboard.vocabTitle'), + description: t('dashboard.vocabDesc'), + icon: VocabIcon, + color: '#f97316', + bgColor: '#fff7ed', + children: [ + { + id: 'vocab-daily', + title: t('dashboard.dailyWordsTitle'), + icon: LearnIcon, + path: '/vocab', + description: t('dashboard.dailyWordsDesc') + }, + { + id: 'vocab-test', + title: t('dashboard.quizTitle'), + icon: QuizIcon, + path: '/vocab/test', + description: t('dashboard.quizDesc') + }, + { + id: 'vocab-words', + title: t('dashboard.wordListTitle'), + icon: WordListIcon, + path: '/vocab/words', + description: t('dashboard.wordListDesc') + }, + ], + }, + ] + + const handleCardHover = (modeId) => { + setExpandedCard(modeId) + } + + const handleCardLeave = () => { + setExpandedCard(null) + } + + const handleSubItemClick = (path, e) => { + e.stopPropagation() + navigate(path) + } + + return ( + + {/* Header */} + + + + + + + + {t('dashboard.greeting')} + + + {t('dashboard.subtitle')} + + + + + + {/* Learning Mode Cards */} + + {learningModes.map((mode) => { + const Icon = mode.icon + const isExpanded = expandedCard === mode.id + const hasChildren = mode.children && mode.children.length > 0 + + return ( + + handleCardHover(mode.id)} + onMouseLeave={handleCardLeave} + onClick={() => !hasChildren && mode.path && navigate(mode.path)} + sx={{ + cursor: 'pointer', + transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', + border: '2px solid transparent', + borderColor: isExpanded ? mode.color : 'transparent', + transform: isExpanded ? 'translateY(-4px)' : 'translateY(0)', + boxShadow: isExpanded + ? `0 20px 40px -12px ${mode.color}30` + : '0 1px 3px 0 rgb(0 0 0 / 0.1)', + '&:hover': { + borderColor: mode.color, + }, + height: 'auto', + minHeight: isExpanded ? 'auto' : 140, + }} + > + + + {/* Icon */} + + + + + {/* Text */} + + + + {mode.title} + + {hasChildren && ( + + )} + + + {mode.description} + + + + + {/* Sub-items */} + {hasChildren && ( + + 2 ? 'repeat(3, 1fr)' : 'repeat(2, 1fr)', + gap: 2, + mt: 3, + pt: 3, + borderTop: '1px solid', + borderColor: 'divider', + }} + > + {mode.children.map((child, index) => { + const ChildIcon = child.icon + return ( + handleSubItemClick(child.path, e)} + sx={{ + p: 2, + borderRadius: '14px', + backgroundColor: mode.bgColor, + cursor: 'pointer', + transition: 'all 0.2s ease', + transform: isExpanded ? 'translateY(0)' : 'translateY(-8px)', + opacity: isExpanded ? 1 : 0, + transitionDelay: `${index * 50}ms`, + '&:hover': { + backgroundColor: `${mode.color}20`, + transform: 'scale(1.02)', + }, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + textAlign: 'center', + minHeight: 100, + }} + > + + + + + {child.title} + + + {child.description} + + + ) + })} + + + )} + + + + ) + })} + + + {/* Recent Activity */} + + + {t('dashboard.recentActivity')} + + + + + + + + {t('dashboard.noHistory')} + + + {t('dashboard.startLearning')} + + + + + + + ) +} + +// Placeholder Pages +function OpicPage() { + return ( + + OPIC Practice + Level-based training + + ) +} + +function FreetalkAiPage() { + return ( + + AI Conversation + Free conversation with AI + + ) +} + + +function ReportsPage() { + const {isKorean} = useSettings() + + // 더미 통계 데이터 + const stats = { + totalStudyDays: 15, + totalWords: 285, + totalTests: 12, + averageScore: 82, + currentStreak: 5, + bestStreak: 8, + } + + return ( + + {/* 헤더 */} + + + + + + + + {isKorean ? '학습 리포트' : 'Learning Report'} + + + {isKorean ? '나의 학습 현황을 확인하세요' : 'Check your learning progress'} + + + + + + {/* 통계 요약 카드 */} + + + + + {isKorean ? '총 학습일' : 'Study Days'} + + + {stats.totalStudyDays} + + + {isKorean ? '일' : 'days'} + + + + + + + {isKorean ? '학습한 단어' : 'Words Learned'} + + + {stats.totalWords} + + + {isKorean ? '개' : 'words'} + + + + + + + {isKorean ? '테스트 완료' : 'Tests Taken'} + + + {stats.totalTests} + + + {isKorean ? '회' : 'tests'} + + + + + + + {isKorean ? '평균 점수' : 'Average Score'} + + + {stats.averageScore}% + + + {isKorean ? '정확도' : 'accuracy'} + + + + + + {/* 연속 학습 */} + + + {isKorean ? '연속 학습 기록' : 'Study Streak'} + + + + + + {stats.currentStreak} + + + {isKorean ? '현재 연속' : 'Current Streak'} + + + + + + + {stats.bestStreak} + + + {isKorean ? '최고 기록' : 'Best Streak'} + + + + + + + {/* 배지 섹션 */} + + + ) +} + +function SettingsPage() { + const {settings, setTtsVoice, setLanguage, t} = useSettings() + + const languageOptions = [ + {value: 'ko', label: '한국어', flag: '🇰🇷'}, + {value: 'en', label: 'English', flag: '🇺🇸'}, + ] + + return ( + + + + + + + + + {t('settings.title')} + + + {t('settings.subtitle')} + + + + + + + {/* Language Settings */} + + + + {t('settings.language')} + + + {t('settings.languageDesc')} + + + + + {languageOptions.map((option) => ( + + setLanguage(option.value)} + sx={{ + p: 2.5, + borderRadius: '16px', + border: '2px solid', + borderColor: settings.language === option.value ? '#3b82f6' : 'divider', + backgroundColor: settings.language === option.value ? '#eff6ff' : 'transparent', + cursor: 'pointer', + transition: 'all 0.2s ease', + textAlign: 'center', + '&:hover': { + borderColor: '#3b82f6', + backgroundColor: '#eff6ff', + }, + }} + > + + {option.flag} + + + {option.label} + + + + ))} + + + + + {/* TTS Voice Settings */} + + + + {t('settings.ttsVoice')} + + + {t('settings.ttsVoiceDesc')} + + + + + + setTtsVoice('FEMALE')} + sx={{ + p: 2.5, + borderRadius: '16px', + border: '2px solid', + borderColor: settings.ttsVoice === 'FEMALE' ? '#059669' : 'divider', + backgroundColor: settings.ttsVoice === 'FEMALE' ? '#ecfdf5' : 'transparent', + cursor: 'pointer', + transition: 'all 0.2s ease', + textAlign: 'center', + '&:hover': { + borderColor: '#059669', + backgroundColor: '#ecfdf5', + }, + }} + > + + 👩 + + + {t('settings.femaleVoice')} + + + + + setTtsVoice('MALE')} + sx={{ + p: 2.5, + borderRadius: '16px', + border: '2px solid', + borderColor: settings.ttsVoice === 'MALE' ? '#059669' : 'divider', + backgroundColor: settings.ttsVoice === 'MALE' ? '#ecfdf5' : 'transparent', + cursor: 'pointer', + transition: 'all 0.2s ease', + textAlign: 'center', + '&:hover': { + borderColor: '#059669', + backgroundColor: '#ecfdf5', + }, + }} + > + + 👨 + + + {t('settings.maleVoice')} + + + + + + + + + ) +} + +function NotFound() { + const navigate = useNavigate() + const {t} = useSettings() + + return ( + + + + 404 + + + {t('notFound.title')} + + + {t('notFound.message')} + + + + + ) } function App() { - return ( - - } /> - - ) + const {activeRoom, closeChatRoom} = useChat() + + const handleRefreshRooms = () => { + // Refresh rooms list after leaving a room + } + + return ( + <> + + {/* Chat room page (separate layout) */} + }/> + + {/* MainLayout routes */} + }> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + + + {/* 404 */} + }/> + + + {/* Global chat modal */} + + + ) } export default App diff --git a/src/api/axios.js b/src/api/axios.js index 9d80ccf..a740563 100644 --- a/src/api/axios.js +++ b/src/api/axios.js @@ -1,37 +1,37 @@ import axios from 'axios' const api = axios.create({ - baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8080', - timeout: 10000, - headers: { - 'Content-Type': 'application/json', - }, + baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8080', + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, }) // Request interceptor api.interceptors.request.use( - (config) => { - const token = localStorage.getItem('accessToken') - if (token) { - config.headers.Authorization = `Bearer ${token}` + (config) => { + const token = localStorage.getItem('accessToken') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + (error) => { + return Promise.reject(error) } - return config - }, - (error) => { - return Promise.reject(error) - } ) // Response interceptor api.interceptors.response.use( - (response) => response, - (error) => { - if (error.response?.status === 401) { - localStorage.removeItem('accessToken') - window.location.href = '/login' + (response) => response, + (error) => { + if (error.response?.status === 401) { + localStorage.removeItem('accessToken') + window.location.href = '/login' + } + return Promise.reject(error) } - return Promise.reject(error) - } ) export default api diff --git a/src/api/badgeApi.js b/src/api/badgeApi.js new file mode 100644 index 0000000..52654bb --- /dev/null +++ b/src/api/badgeApi.js @@ -0,0 +1,32 @@ +import axios from 'axios' + +const badgeApi = axios.create({ + baseURL: import.meta.env.VITE_BADGE_API_URL || import.meta.env.VITE_API_URL, + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, +}) + +// Request interceptor for JWT token +badgeApi.interceptors.request.use( + (config) => { + const token = localStorage.getItem('accessToken') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + (error) => Promise.reject(error) +) + +// Response interceptor for error handling +badgeApi.interceptors.response.use( + (response) => response.data, + (error) => { + console.error('Badge API Error:', error.response?.data || error.message) + return Promise.reject(error) + } +) + +export default badgeApi diff --git a/src/api/chatApi.js b/src/api/chatApi.js new file mode 100644 index 0000000..193dd13 --- /dev/null +++ b/src/api/chatApi.js @@ -0,0 +1,30 @@ +import axios from 'axios' + +const chatApi = axios.create({ + baseURL: import.meta.env.VITE_CHAT_API_URL, + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, +}) + +// Request interceptor +chatApi.interceptors.request.use( + (config) => { + return config + }, + (error) => { + return Promise.reject(error) + } +) + +// Response interceptor +chatApi.interceptors.response.use( + (response) => response.data, + (error) => { + console.error('Chat API Error:', error.response?.data || error.message) + return Promise.reject(error) + } +) + +export default chatApi diff --git a/src/api/grammarApi.js b/src/api/grammarApi.js new file mode 100644 index 0000000..54c4dee --- /dev/null +++ b/src/api/grammarApi.js @@ -0,0 +1,40 @@ +import axios from 'axios' + +const grammarApi = axios.create({ + baseURL: import.meta.env.VITE_GRAMMAR_API_URL || import.meta.env.VITE_API_URL, + timeout: 30000, + headers: { + 'Content-Type': 'application/json', + }, +}) + +// Request interceptor +grammarApi.interceptors.request.use( + (config) => { + const token = localStorage.getItem('accessToken') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + (error) => { + return Promise.reject(error) + } +) + +// Response interceptor +grammarApi.interceptors.response.use( + (response) => response.data, + (error) => { + console.error('Grammar API Error:', error.response?.data || error.message) + + if (error.response?.status === 401) { + localStorage.removeItem('accessToken') + window.location.href = '/login' + } + + return Promise.reject(error) + } +) + +export default grammarApi diff --git a/src/api/vocabApi.js b/src/api/vocabApi.js new file mode 100644 index 0000000..b18b7f8 --- /dev/null +++ b/src/api/vocabApi.js @@ -0,0 +1,30 @@ +import axios from 'axios' + +const vocabApi = axios.create({ + baseURL: import.meta.env.VITE_VOCAB_API_URL, + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, +}) + +// Request interceptor +vocabApi.interceptors.request.use( + (config) => { + return config + }, + (error) => { + return Promise.reject(error) + } +) + +// Response interceptor +vocabApi.interceptors.response.use( + (response) => response.data, + (error) => { + console.error('Vocab API Error:', error.response?.data || error.message) + return Promise.reject(error) + } +) + +export default vocabApi diff --git a/src/contexts/ChatContext.jsx b/src/contexts/ChatContext.jsx new file mode 100644 index 0000000..2449430 --- /dev/null +++ b/src/contexts/ChatContext.jsx @@ -0,0 +1,29 @@ +import {createContext, useCallback, useContext, useState} from 'react' + +const ChatContext = createContext(null) + +export const ChatProvider = ({children}) => { + const [activeRoom, setActiveRoom] = useState(null) + + const openChatRoom = useCallback((room) => { + setActiveRoom(room) + }, []) + + const closeChatRoom = useCallback(() => { + setActiveRoom(null) + }, []) + + return ( + + {children} + + ) +} + +export const useChat = () => { + const context = useContext(ChatContext) + if (!context) { + throw new Error('useChat must be used within a ChatProvider') + } + return context +} diff --git a/src/contexts/SettingsContext.jsx b/src/contexts/SettingsContext.jsx new file mode 100644 index 0000000..405fcc0 --- /dev/null +++ b/src/contexts/SettingsContext.jsx @@ -0,0 +1,81 @@ +import {createContext, useCallback, useContext, useEffect, useMemo, useState} from 'react' +import {LANGUAGES, translations} from '../i18n/translations' + +const SettingsContext = createContext(null) + +const STORAGE_KEY = 'app_settings' + +const defaultSettings = { + ttsVoice: 'FEMALE', // MALE | FEMALE + language: LANGUAGES.KO, // ko | en +} + +export const SettingsProvider = ({children}) => { + const [settings, setSettings] = useState(() => { + const stored = localStorage.getItem(STORAGE_KEY) + return stored ? {...defaultSettings, ...JSON.parse(stored)} : defaultSettings + }) + + useEffect(() => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)) + }, [settings]) + + const updateSettings = useCallback((updates) => { + setSettings((prev) => ({...prev, ...updates})) + }, []) + + const setTtsVoice = useCallback((voice) => { + updateSettings({ttsVoice: voice}) + }, [updateSettings]) + + const setLanguage = useCallback((lang) => { + updateSettings({language: lang}) + }, [updateSettings]) + + // Translation function + const t = useCallback((key) => { + const keys = key.split('.') + let value = translations[settings.language] + + for (const k of keys) { + if (value && typeof value === 'object') { + value = value[k] + } else { + return key // Return key if translation not found + } + } + + return value || key + }, [settings.language]) + + const value = useMemo(() => ({ + settings, + updateSettings, + setTtsVoice, + setLanguage, + t, + language: settings.language, + isKorean: settings.language === LANGUAGES.KO, + isEnglish: settings.language === LANGUAGES.EN, + }), [settings, updateSettings, setTtsVoice, setLanguage, t]) + + return ( + + {children} + + ) +} + +export const useSettings = () => { + const context = useContext(SettingsContext) + if (!context) { + throw new Error('useSettings must be used within a SettingsProvider') + } + return context +} + +// Convenience hook for translations +export const useTranslation = () => { + const {t, language, isKorean, isEnglish} = useSettings() + return {t, language, isKorean, isEnglish} +} diff --git a/src/contexts/ThemeContext.jsx b/src/contexts/ThemeContext.jsx new file mode 100644 index 0000000..1427487 --- /dev/null +++ b/src/contexts/ThemeContext.jsx @@ -0,0 +1,81 @@ +import {createContext, useContext, useEffect, useMemo, useState} from 'react' +import {ThemeProvider as MuiThemeProvider} from '@mui/material/styles' +import {CssBaseline} from '@mui/material' +import {darkTheme, lightTheme} from '../theme/theme' + +const ThemeContext = createContext({ + mode: 'light', + toggleTheme: () => { + }, + setMode: () => { + }, +}) + +export const useThemeMode = () => { + const context = useContext(ThemeContext) + if (!context) { + throw new Error('useThemeMode must be used within ThemeProvider') + } + return context +} + +export const ThemeProvider = ({children}) => { + // localStorage에서 테마 모드 불러오기 (시스템 설정 기본값) + const [mode, setMode] = useState(() => { + const savedMode = localStorage.getItem('themeMode') + if (savedMode) return savedMode + + // 시스템 다크모드 감지 + if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { + return 'dark' + } + return 'light' + }) + + // 시스템 테마 변경 감지 + useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') + const handleChange = (e) => { + const savedMode = localStorage.getItem('themeMode') + if (!savedMode) { + setMode(e.matches ? 'dark' : 'light') + } + } + + mediaQuery.addEventListener('change', handleChange) + return () => mediaQuery.removeEventListener('change', handleChange) + }, []) + + // 테마 변경 시 localStorage 저장 + useEffect(() => { + localStorage.setItem('themeMode', mode) + // body 클래스 추가 (CSS 변수 등에서 활용) + document.body.classList.remove('light-mode', 'dark-mode') + document.body.classList.add(`${mode}-mode`) + }, [mode]) + + const toggleTheme = () => { + setMode((prev) => (prev === 'light' ? 'dark' : 'light')) + } + + const theme = useMemo(() => { + return mode === 'dark' ? darkTheme : lightTheme + }, [mode]) + + const contextValue = useMemo(() => ({ + mode, + toggleTheme, + setMode, + }), [mode]) + + return ( + + + + {children} + + + ) +} + +export default ThemeContext diff --git a/src/domains/badge/components/BadgeCard.jsx b/src/domains/badge/components/BadgeCard.jsx new file mode 100644 index 0000000..f9a1b51 --- /dev/null +++ b/src/domains/badge/components/BadgeCard.jsx @@ -0,0 +1,230 @@ +import {useState} from 'react' +import {Box, Fade, LinearProgress, Tooltip, Typography,} from '@mui/material' +import {EmojiEvents as TrophyIcon, Lock as LockIcon,} from '@mui/icons-material' +import {useSettings} from '../../../contexts/SettingsContext' +import {BADGE_CATEGORY_COLORS, BADGE_DESCRIPTIONS_EN, BADGE_NAMES_EN,} from '../constants/badgeConstants' + +export default function BadgeCard({badge, size = 'medium'}) { + const {isKorean} = useSettings() + const [imageError, setImageError] = useState(false) + + const isEarned = badge.earned + const progress = Math.min((badge.progress / badge.threshold) * 100, 100) + + // Size configurations + const sizes = { + small: {card: 100, image: 60, icon: 24}, + medium: {card: 140, image: 80, icon: 32}, + large: {card: 180, image: 100, icon: 40}, + } + const config = sizes[size] || sizes.medium + + // Get localized badge info + const badgeName = isKorean ? badge.name : (BADGE_NAMES_EN[badge.badgeType] || badge.name) + const badgeDescription = isKorean + ? badge.description + : (BADGE_DESCRIPTIONS_EN[badge.badgeType] || badge.description) + + // Format earned date + const formatDate = (dateString) => { + if (!dateString) return '' + const date = new Date(dateString) + return isKorean + ? `${date.getFullYear()}년 ${date.getMonth() + 1}월 ${date.getDate()}일` + : date.toLocaleDateString('en-US', {year: 'numeric', month: 'short', day: 'numeric'}) + } + + // Tooltip content + const tooltipContent = ( + + + {badgeName} + + + {badgeDescription} + + + {!isEarned && ( + + + + {isKorean ? '진행도' : 'Progress'} + + + {badge.progress} / {badge.threshold} + + + + + )} + + {isEarned && badge.earnedAt && ( + + + + {formatDate(badge.earnedAt)} + + + )} + + ) + + return ( + + + {/* Badge Image Container */} + + {!imageError ? ( + setImageError(true)} + sx={{ + width: '70%', + height: '70%', + objectFit: 'contain', + filter: isEarned ? 'none' : 'grayscale(100%) blur(1px)', + opacity: isEarned ? 1 : 0.4, + transition: 'all 0.3s ease', + }} + /> + ) : ( + + )} + + {/* Lock overlay for unearned badges */} + {!isEarned && ( + + + + )} + + + {/* Badge Name */} + + {badgeName} + + + {/* Progress indicator for unearned */} + {!isEarned && size !== 'small' && ( + + {badge.progress}/{badge.threshold} + + )} + + + ) +} diff --git a/src/domains/badge/components/BadgeGrid.jsx b/src/domains/badge/components/BadgeGrid.jsx new file mode 100644 index 0000000..757b49f --- /dev/null +++ b/src/domains/badge/components/BadgeGrid.jsx @@ -0,0 +1,74 @@ +import {Box, Skeleton, Typography} from '@mui/material' +import BadgeCard from './BadgeCard' +import {useSettings} from '../../../contexts/SettingsContext' + +export default function BadgeGrid({badges = [], loading = false, size = 'medium'}) { + const {isKorean} = useSettings() + + if (loading) { + return ( + + {Array.from({length: 12}).map((_, index) => ( + + + + + ))} + + ) + } + + if (badges.length === 0) { + return ( + + + {isKorean ? '배지가 없습니다' : 'No badges available'} + + + ) + } + + // Sort badges: earned first, then by category + const sortedBadges = [...badges].sort((a, b) => { + if (a.earned && !b.earned) return -1 + if (!a.earned && b.earned) return 1 + return 0 + }) + + return ( + + {sortedBadges.map((badge) => ( + + ))} + + ) +} diff --git a/src/domains/badge/components/BadgeSection.jsx b/src/domains/badge/components/BadgeSection.jsx new file mode 100644 index 0000000..01890de --- /dev/null +++ b/src/domains/badge/components/BadgeSection.jsx @@ -0,0 +1,167 @@ +import {useEffect, useState} from 'react' +import {Alert, Box, Chip, LinearProgress, Paper, Typography,} from '@mui/material' +import {EmojiEvents as TrophyIcon, WorkspacePremium as BadgeIcon,} from '@mui/icons-material' +import BadgeGrid from './BadgeGrid' +import {badgeService} from '../services/badgeService' +import {useSettings} from '../../../contexts/SettingsContext' + +export default function BadgeSection() { + const {isKorean} = useSettings() + const [badges, setBadges] = useState([]) + const [totalCount, setTotalCount] = useState(0) + const [earnedCount, setEarnedCount] = useState(0) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + loadBadges() + }, []) + + const loadBadges = async () => { + try { + setLoading(true) + setError(null) + const response = await badgeService.getAll() + setBadges(response.badges || []) + setTotalCount(response.totalCount || 0) + setEarnedCount(response.earnedCount || 0) + } catch (err) { + console.error('Failed to load badges:', err) + setError(isKorean ? '배지를 불러오는데 실패했습니다' : 'Failed to load badges') + } finally { + setLoading(false) + } + } + + const progress = totalCount > 0 ? (earnedCount / totalCount) * 100 : 0 + + return ( + + {/* Header */} + + + + + + + + {isKorean ? '획득한 배지' : 'Earned Badges'} + + + {isKorean + ? '학습 목표를 달성하고 배지를 모아보세요!' + : 'Achieve learning goals and collect badges!'} + + + + + {/* Stats Chip */} + } + label={ + + {earnedCount} / {totalCount} + + } + sx={{ + px: 1, + height: 36, + borderRadius: '18px', + backgroundColor: earnedCount === totalCount ? '#ecfdf5' : '#fef3c7', + color: earnedCount === totalCount ? '#059669' : '#d97706', + border: `1px solid ${earnedCount === totalCount ? '#10b981' : '#f59e0b'}`, + '& .MuiChip-icon': { + color: earnedCount === totalCount ? '#059669' : '#d97706', + }, + }} + /> + + + {/* Progress Bar */} + + + + {isKorean ? '전체 진행률' : 'Overall Progress'} + + + {Math.round(progress)}% + + + + + + {/* Error Alert */} + {error && ( + + {error} + + )} + + {/* Badge Grid */} + + + {/* Completion Message */} + {!loading && earnedCount === totalCount && totalCount > 0 && ( + + + {isKorean + ? '축하합니다! 모든 배지를 획득했습니다! 🎉' + : 'Congratulations! You collected all badges! 🎉'} + + + )} + + ) +} diff --git a/src/domains/badge/constants/badgeConstants.js b/src/domains/badge/constants/badgeConstants.js new file mode 100644 index 0000000..079f3d2 --- /dev/null +++ b/src/domains/badge/constants/badgeConstants.js @@ -0,0 +1,131 @@ +/** + * Badge domain constants + * Based on Backend BadgeType enum + */ + +// 배지 타입 +export const BADGE_TYPES = { + FIRST_STEP: 'FIRST_STEP', + STREAK_3: 'STREAK_3', + STREAK_7: 'STREAK_7', + STREAK_30: 'STREAK_30', + WORDS_100: 'WORDS_100', + WORDS_500: 'WORDS_500', + WORDS_1000: 'WORDS_1000', + PERFECT_SCORE: 'PERFECT_SCORE', + TEST_10: 'TEST_10', + ACCURACY_90: 'ACCURACY_90', + GAME_FIRST_PLAY: 'GAME_FIRST_PLAY', + GAME_10_WINS: 'GAME_10_WINS', + QUICK_GUESSER: 'QUICK_GUESSER', + PERFECT_DRAWER: 'PERFECT_DRAWER', + MASTER: 'MASTER', +} + +// 배지 카테고리 +export const BADGE_CATEGORIES = { + FIRST_STUDY: 'FIRST_STUDY', + STREAK: 'STREAK', + WORDS_LEARNED: 'WORDS_LEARNED', + PERFECT_TEST: 'PERFECT_TEST', + TESTS_COMPLETED: 'TESTS_COMPLETED', + ACCURACY: 'ACCURACY', + GAMES_PLAYED: 'GAMES_PLAYED', + GAMES_WON: 'GAMES_WON', + QUICK_GUESSES: 'QUICK_GUESSES', + PERFECT_DRAWS: 'PERFECT_DRAWS', + ALL_BADGES: 'ALL_BADGES', +} + +// 배지 카테고리 라벨 (한국어) +export const BADGE_CATEGORY_LABELS_KO = { + FIRST_STUDY: '첫 시작', + STREAK: '연속 학습', + WORDS_LEARNED: '단어 학습', + PERFECT_TEST: '완벽한 테스트', + TESTS_COMPLETED: '테스트 완료', + ACCURACY: '정확도', + GAMES_PLAYED: '게임 참여', + GAMES_WON: '게임 승리', + QUICK_GUESSES: '빠른 정답', + PERFECT_DRAWS: '완벽한 출제', + ALL_BADGES: '마스터', +} + +// 배지 카테고리 라벨 (영어) +export const BADGE_CATEGORY_LABELS_EN = { + FIRST_STUDY: 'First Steps', + STREAK: 'Streak', + WORDS_LEARNED: 'Words Learned', + PERFECT_TEST: 'Perfect Test', + TESTS_COMPLETED: 'Tests Completed', + ACCURACY: 'Accuracy', + GAMES_PLAYED: 'Games Played', + GAMES_WON: 'Games Won', + QUICK_GUESSES: 'Quick Guesser', + PERFECT_DRAWS: 'Perfect Drawer', + ALL_BADGES: 'Master', +} + +// 배지 카테고리 색상 +export const BADGE_CATEGORY_COLORS = { + FIRST_STUDY: '#10b981', + STREAK: '#f59e0b', + WORDS_LEARNED: '#3b82f6', + PERFECT_TEST: '#8b5cf6', + TESTS_COMPLETED: '#06b6d4', + ACCURACY: '#ec4899', + GAMES_PLAYED: '#14b8a6', + GAMES_WON: '#f97316', + QUICK_GUESSES: '#6366f1', + PERFECT_DRAWS: '#84cc16', + ALL_BADGES: '#eab308', +} + +// 배지 이름 (영어 - 백엔드는 한국어만 제공하므로 프론트에서 번역) +export const BADGE_NAMES_EN = { + FIRST_STEP: 'First Step', + STREAK_3: '3-Day Streak', + STREAK_7: 'Week Streak', + STREAK_30: 'Month Streak', + WORDS_100: 'Word Collector', + WORDS_500: 'Word Expert', + WORDS_1000: 'Word Master', + PERFECT_SCORE: 'Perfectionist', + TEST_10: 'Test Challenger', + ACCURACY_90: 'Accuracy Pro', + GAME_FIRST_PLAY: 'First Game', + GAME_10_WINS: '10 Wins', + QUICK_GUESSER: 'Lightning Fast', + PERFECT_DRAWER: 'Perfect Host', + MASTER: 'Learning Master', +} + +// 배지 설명 (영어) +export const BADGE_DESCRIPTIONS_EN = { + FIRST_STEP: 'Completed your first study session', + STREAK_3: 'Studied for 3 consecutive days', + STREAK_7: 'Studied for 7 consecutive days', + STREAK_30: 'Studied for 30 consecutive days', + WORDS_100: 'Learned 100 words', + WORDS_500: 'Learned 500 words', + WORDS_1000: 'Learned 1000 words', + PERFECT_SCORE: 'Got a perfect score on a test', + TEST_10: 'Completed 10 tests', + ACCURACY_90: 'Achieved 90% overall accuracy', + GAME_FIRST_PLAY: 'Played your first game', + GAME_10_WINS: 'Won 10 games', + QUICK_GUESSER: 'Answered correctly within 5 seconds', + PERFECT_DRAWER: 'All players guessed your drawing correctly', + MASTER: 'Achieved all badges', +} + +export default { + BADGE_TYPES, + BADGE_CATEGORIES, + BADGE_CATEGORY_LABELS_KO, + BADGE_CATEGORY_LABELS_EN, + BADGE_CATEGORY_COLORS, + BADGE_NAMES_EN, + BADGE_DESCRIPTIONS_EN, +} diff --git a/src/domains/badge/index.js b/src/domains/badge/index.js new file mode 100644 index 0000000..b4e9b34 --- /dev/null +++ b/src/domains/badge/index.js @@ -0,0 +1,10 @@ +// Components +export {default as BadgeCard} from './components/BadgeCard' +export {default as BadgeGrid} from './components/BadgeGrid' +export {default as BadgeSection} from './components/BadgeSection' + +// Services +export * from './services/badgeService' + +// Constants +export * from './constants/badgeConstants' diff --git a/src/domains/badge/services/badgeService.js b/src/domains/badge/services/badgeService.js new file mode 100644 index 0000000..e449d89 --- /dev/null +++ b/src/domains/badge/services/badgeService.js @@ -0,0 +1,220 @@ +import badgeApi from '../../../api/badgeApi' + +// Mock 데이터 사용 여부 +const USE_MOCK = true + +// Placeholder 이미지 (실제 S3 이미지가 없을 경우 대비) +const PLACEHOLDER_BADGE = 'https://via.placeholder.com/100x100/FFD700/000000?text=Badge' + +// ============================================ +// Mock 데이터 +// ============================================ + +const mockBadges = [ + { + badgeType: 'FIRST_STEP', + name: '첫 걸음', + description: '첫 학습을 완료했습니다', + imageUrl: 'https://cdn-icons-png.flaticon.com/512/3135/3135715.png', + category: 'FIRST_STUDY', + threshold: 1, + progress: 1, + earned: true, + earnedAt: '2024-12-01T10:30:00Z', + }, + { + badgeType: 'STREAK_3', + name: '3일 연속 학습', + description: '3일 연속으로 학습했습니다', + imageUrl: 'https://cdn-icons-png.flaticon.com/512/3135/3135783.png', + category: 'STREAK', + threshold: 3, + progress: 3, + earned: true, + earnedAt: '2024-12-05T09:15:00Z', + }, + { + badgeType: 'STREAK_7', + name: '일주일 연속 학습', + description: '7일 연속으로 학습했습니다', + imageUrl: 'https://cdn-icons-png.flaticon.com/512/3135/3135789.png', + category: 'STREAK', + threshold: 7, + progress: 5, + earned: false, + earnedAt: null, + }, + { + badgeType: 'STREAK_30', + name: '한 달 연속 학습', + description: '30일 연속으로 학습했습니다', + imageUrl: 'https://cdn-icons-png.flaticon.com/512/3135/3135810.png', + category: 'STREAK', + threshold: 30, + progress: 5, + earned: false, + earnedAt: null, + }, + { + badgeType: 'WORDS_100', + name: '단어 수집가', + description: '100개의 단어를 학습했습니다', + imageUrl: 'https://cdn-icons-png.flaticon.com/512/2232/2232688.png', + category: 'WORDS_LEARNED', + threshold: 100, + progress: 85, + earned: false, + earnedAt: null, + }, + { + badgeType: 'WORDS_500', + name: '단어 전문가', + description: '500개의 단어를 학습했습니다', + imageUrl: 'https://cdn-icons-png.flaticon.com/512/2232/2232691.png', + category: 'WORDS_LEARNED', + threshold: 500, + progress: 85, + earned: false, + earnedAt: null, + }, + { + badgeType: 'WORDS_1000', + name: '단어 마스터', + description: '1000개의 단어를 학습했습니다', + imageUrl: 'https://cdn-icons-png.flaticon.com/512/2232/2232696.png', + category: 'WORDS_LEARNED', + threshold: 1000, + progress: 85, + earned: false, + earnedAt: null, + }, + { + badgeType: 'PERFECT_SCORE', + name: '완벽주의자', + description: '테스트에서 만점을 받았습니다', + imageUrl: 'https://cdn-icons-png.flaticon.com/512/3176/3176293.png', + category: 'PERFECT_TEST', + threshold: 1, + progress: 1, + earned: true, + earnedAt: '2024-12-10T14:20:00Z', + }, + { + badgeType: 'TEST_10', + name: '테스트 도전자', + description: '10회의 테스트를 완료했습니다', + imageUrl: 'https://cdn-icons-png.flaticon.com/512/3176/3176366.png', + category: 'TESTS_COMPLETED', + threshold: 10, + progress: 7, + earned: false, + earnedAt: null, + }, + { + badgeType: 'ACCURACY_90', + name: '정확도 달인', + description: '전체 정확도 90%를 달성했습니다', + imageUrl: 'https://cdn-icons-png.flaticon.com/512/3176/3176271.png', + category: 'ACCURACY', + threshold: 90, + progress: 78, + earned: false, + earnedAt: null, + }, + { + badgeType: 'GAME_FIRST_PLAY', + name: '첫 게임', + description: '첫 게임에 참여했습니다', + imageUrl: 'https://cdn-icons-png.flaticon.com/512/3612/3612569.png', + category: 'GAMES_PLAYED', + threshold: 1, + progress: 1, + earned: true, + earnedAt: '2024-12-08T16:45:00Z', + }, + { + badgeType: 'GAME_10_WINS', + name: '게임 10승', + description: '게임에서 10번 1등을 했습니다', + imageUrl: 'https://cdn-icons-png.flaticon.com/512/3612/3612530.png', + category: 'GAMES_WON', + threshold: 10, + progress: 3, + earned: false, + earnedAt: null, + }, + { + badgeType: 'QUICK_GUESSER', + name: '번개 정답', + description: '5초 내에 정답을 맞췄습니다', + imageUrl: 'https://cdn-icons-png.flaticon.com/512/3612/3612557.png', + category: 'QUICK_GUESSES', + threshold: 1, + progress: 0, + earned: false, + earnedAt: null, + }, + { + badgeType: 'PERFECT_DRAWER', + name: '완벽한 출제자', + description: '출제 시 전원이 정답을 맞췄습니다', + imageUrl: 'https://cdn-icons-png.flaticon.com/512/3612/3612537.png', + category: 'PERFECT_DRAWS', + threshold: 1, + progress: 0, + earned: false, + earnedAt: null, + }, + { + badgeType: 'MASTER', + name: '학습 마스터', + description: '모든 업적을 달성했습니다', + imageUrl: 'https://cdn-icons-png.flaticon.com/512/3135/3135706.png', + category: 'ALL_BADGES', + threshold: 1, + progress: 0, + earned: false, + earnedAt: null, + }, +] + +// ============================================ +// API with Mock fallback +// ============================================ + +const withMock = (apiCall, mockData) => { + if (USE_MOCK) { + return new Promise((resolve) => { + setTimeout(() => resolve(mockData), 500) + }) + } + return apiCall().catch(() => mockData) +} + +/** + * Badge API Service + */ +export const badgeService = { + // GET /badges - 전체 배지 목록 (획득 여부, 진행도 포함) + getAll: () => + withMock( + () => badgeApi.get('/badges'), + { + badges: mockBadges, + totalCount: mockBadges.length, + earnedCount: mockBadges.filter((b) => b.earned).length, + } + ), + + // GET /badges/earned - 획득한 배지만 조회 + getEarned: () => + withMock( + () => badgeApi.get('/badges/earned'), + { + badges: mockBadges.filter((b) => b.earned), + count: mockBadges.filter((b) => b.earned).length, + } + ), +} + +export default badgeService diff --git a/src/domains/chat/services/chatService.js b/src/domains/chat/services/chatService.js new file mode 100644 index 0000000..f81918d --- /dev/null +++ b/src/domains/chat/services/chatService.js @@ -0,0 +1,592 @@ +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 데이터 (백엔드 API 응답 형식과 동일) +// ============================================ + +const mockChatRooms = [ + { + roomId: '550e8400-e29b-41d4-a716-446655440001', + name: 'English Practice Room', + description: 'Daily conversation practice for beginners', + level: 'beginner', + currentMembers: 5, + maxMembers: 10, + isPrivate: false, + createdBy: 'user1', + memberIds: ['user1', 'user2', 'user3', 'user4', 'user5'], + createdAt: new Date(Date.now() - 86400000 * 3).toISOString(), + lastMessageAt: new Date(Date.now() - 3600000).toISOString(), + }, + { + roomId: '550e8400-e29b-41d4-a716-446655440002', + name: 'Business English', + description: 'Professional English for business situations', + level: 'intermediate', + currentMembers: 3, + maxMembers: 8, + isPrivate: false, + createdBy: 'user2', + memberIds: ['user2', 'user5', 'user6'], + createdAt: new Date(Date.now() - 86400000 * 2).toISOString(), + lastMessageAt: new Date(Date.now() - 7200000).toISOString(), + }, + { + roomId: '550e8400-e29b-41d4-a716-446655440003', + name: 'OPIC 준비방', + description: 'OPIC 시험 준비를 위한 스터디 그룹', + level: 'intermediate', + currentMembers: 4, + maxMembers: 6, + isPrivate: true, + createdBy: 'user3', + memberIds: ['user1', 'user3', 'user7', 'user8'], + createdAt: new Date(Date.now() - 86400000).toISOString(), + lastMessageAt: new Date(Date.now() - 1800000).toISOString(), + }, + { + roomId: '550e8400-e29b-41d4-a716-446655440004', + name: 'Advanced Discussion', + description: 'Deep discussions on various topics in English', + level: 'advanced', + currentMembers: 2, + maxMembers: 5, + isPrivate: false, + createdBy: 'user4', + memberIds: ['user4', 'user9'], + createdAt: new Date(Date.now() - 86400000 * 5).toISOString(), + lastMessageAt: new Date(Date.now() - 86400000).toISOString(), + }, + { + roomId: '550e8400-e29b-41d4-a716-446655440005', + name: 'Free Talk Zone', + description: 'Casual conversations about anything', + level: 'beginner', + currentMembers: 8, + maxMembers: 15, + isPrivate: false, + createdBy: 'user5', + memberIds: ['user1', 'user2', 'user5', 'user6', 'user7', 'user8', 'user9', 'user10'], + createdAt: new Date(Date.now() - 3600000).toISOString(), + lastMessageAt: new Date(Date.now() - 400000).toISOString(), + }, +] + +const mockMessages = { + '550e8400-e29b-41d4-a716-446655440001': [ + { + messageId: 'msg-001', + roomId: '550e8400-e29b-41d4-a716-446655440001', + userId: 'user2', + content: 'Hello everyone!', + messageType: 'TEXT', + createdAt: new Date(Date.now() - 3600000).toISOString() + }, + { + messageId: 'msg-002', + roomId: '550e8400-e29b-41d4-a716-446655440001', + userId: 'user1', + content: 'Hi! How are you today?', + messageType: 'TEXT', + createdAt: new Date(Date.now() - 3500000).toISOString() + }, + { + messageId: 'msg-003', + roomId: '550e8400-e29b-41d4-a716-446655440001', + userId: 'user3', + content: "I'm doing great, thanks for asking!", + messageType: 'TEXT', + createdAt: new Date(Date.now() - 3400000).toISOString() + }, + { + messageId: 'msg-004', + roomId: '550e8400-e29b-41d4-a716-446655440001', + userId: 'user2', + content: 'What topic should we practice today?', + messageType: 'TEXT', + createdAt: new Date(Date.now() - 3300000).toISOString() + }, + { + messageId: 'msg-005', + roomId: '550e8400-e29b-41d4-a716-446655440001', + userId: 'user4', + content: 'How about discussing weekend plans?', + messageType: 'TEXT', + createdAt: new Date(Date.now() - 3200000).toISOString() + }, + ], + '550e8400-e29b-41d4-a716-446655440002': [ + { + messageId: 'msg-006', + roomId: '550e8400-e29b-41d4-a716-446655440002', + userId: 'user2', + content: "Let's practice business email writing.", + messageType: 'TEXT', + createdAt: new Date(Date.now() - 7200000).toISOString() + }, + { + messageId: 'msg-007', + roomId: '550e8400-e29b-41d4-a716-446655440002', + userId: 'user5', + content: 'That sounds like a great idea!', + messageType: 'TEXT', + createdAt: new Date(Date.now() - 7100000).toISOString() + }, + ], + '550e8400-e29b-41d4-a716-446655440003': [ + { + messageId: 'msg-008', + roomId: '550e8400-e29b-41d4-a716-446655440003', + userId: 'user3', + content: 'OPIC 시험 일주일 남았어요!', + messageType: 'TEXT', + createdAt: new Date(Date.now() - 1800000).toISOString() + }, + { + messageId: 'msg-009', + roomId: '550e8400-e29b-41d4-a716-446655440003', + userId: 'user1', + content: "화이팅! Let's practice together.", + messageType: 'TEXT', + createdAt: new Date(Date.now() - 1700000).toISOString() + }, + ], + '550e8400-e29b-41d4-a716-446655440004': [ + { + messageId: 'msg-010', + roomId: '550e8400-e29b-41d4-a716-446655440004', + userId: 'user4', + content: 'What are your thoughts on AI technology?', + messageType: 'TEXT', + createdAt: new Date(Date.now() - 86400000).toISOString() + }, + ], + '550e8400-e29b-41d4-a716-446655440005': [ + { + messageId: 'msg-011', + roomId: '550e8400-e29b-41d4-a716-446655440005', + userId: 'user5', + content: 'Anyone watched any good movies lately?', + messageType: 'TEXT', + createdAt: new Date(Date.now() - 600000).toISOString() + }, + { + messageId: 'msg-012', + roomId: '550e8400-e29b-41d4-a716-446655440005', + userId: 'user6', + content: 'I just watched Inception again!', + messageType: 'TEXT', + createdAt: new Date(Date.now() - 500000).toISOString() + }, + { + messageId: 'msg-013', + roomId: '550e8400-e29b-41d4-a716-446655440005', + userId: 'user7', + content: 'That movie is a classic!', + messageType: 'TEXT', + createdAt: new Date(Date.now() - 400000).toISOString() + }, + ], +} + +// ============================================ +// API with Mock fallback +// ============================================ + +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 + }) + } + return apiCall().catch(() => ({ + success: true, + message: 'Fallback mock data', + data: mockData + })) +} + +/** + * 채팅방 API - Backend: POST /rooms, GET /rooms, GET /rooms/{roomId}, POST /rooms/{roomId}/join, POST /rooms/{roomId}/leave + */ +export const chatRoomService = { + // POST /rooms - 채팅방 생성 + create: async (data) => { + const newRoom = { + roomId: `room-${Date.now()}`, + name: data.name, + description: data.description || '', + level: data.level || 'beginner', + currentMembers: 1, + maxMembers: data.maxMembers || 6, + isPrivate: data.isPrivate || false, + createdBy: TEMP_USER_ID, + memberIds: [TEMP_USER_ID], + createdAt: new Date().toISOString(), + lastMessageAt: new Date().toISOString(), + } + return withMock( + () => chatApi.post('/rooms', {...data, createdBy: TEMP_USER_ID}), + newRoom + ) + }, + + // GET /rooms - 채팅방 목록 조회 + getList: async (params = {}) => { + const {limit = 10, level, joined, cursor} = params + + return withMock( + () => { + const queryParams = new URLSearchParams() + queryParams.append('limit', limit) + if (level) queryParams.append('level', level) + if (joined) { + queryParams.append('joined', 'true') + queryParams.append('userId', TEMP_USER_ID) + } + if (cursor) queryParams.append('cursor', cursor) + return chatApi.get(`/rooms?${queryParams.toString()}`) + }, + { + rooms: mockChatRooms + .filter(room => { + if (level && room.level !== level.toLowerCase()) return false + if (joined && !room.memberIds.includes(TEMP_USER_ID)) return false + return true + }) + .slice(0, limit), + nextCursor: null, + hasMore: false, + } + ) + }, + + // GET /rooms/{roomId} - 채팅방 상세 조회 + getDetail: async (roomId) => { + return withMock( + () => chatApi.get(`/rooms/${roomId}`), + mockChatRooms.find(r => r.roomId === roomId) || mockChatRooms[0] + ) + }, + + // POST /rooms/{roomId}/join - 채팅방 입장 + join: async (roomId, password) => { + const room = mockChatRooms.find(r => r.roomId === roomId) + return withMock( + () => chatApi.post(`/rooms/${roomId}/join`, { + userId: TEMP_USER_ID, + ...(password && {password}), + }), + { + room: { + roomId: room?.roomId || roomId, + name: room?.name || 'Chat Room', + currentMembers: (room?.currentMembers || 1) + 1, + }, + roomToken: `token-${Date.now()}`, + tokenExpiresAt: Math.floor(Date.now() / 1000) + 300, // 5분 후 + } + ) + }, + + // POST /rooms/{roomId}/leave - 채팅방 퇴장 + leave: async (roomId) => { + return withMock( + () => chatApi.post(`/rooms/${roomId}/leave`, {userId: TEMP_USER_ID}), + {roomId, currentMembers: 2} + ) + }, +} + +/** + * 메시지 API - Backend: WebSocket sendMessage, GET /rooms/{roomId}/messages (메시지 조회) + */ +export const messageService = { + // 메시지 전송 (REST fallback - 실제로는 WebSocket 사용) + send: async (roomId, content, messageType = 'TEXT') => { + const newMessage = { + messageId: `msg-${Date.now()}`, + roomId, + userId: TEMP_USER_ID, + content, + messageType, + createdAt: new Date().toISOString(), + } + return withMock( + () => chatApi.post(`/rooms/${roomId}/messages`, { + userId: TEMP_USER_ID, + content, + messageType, + }), + newMessage + ) + }, + + // GET /rooms/{roomId}/messages - 메시지 목록 조회 + getList: async (roomId, params = {}) => { + const {limit = 20, cursor} = params + + return withMock( + () => { + const queryParams = new URLSearchParams() + queryParams.append('limit', limit) + if (cursor) queryParams.append('cursor', cursor) + return chatApi.get(`/rooms/${roomId}/messages?${queryParams.toString()}`) + }, + { + messages: (mockMessages[roomId] || []).slice(0, limit), + nextCursor: null, + hasMore: false, + } + ) + }, +} + +/** + * 음성 API - Backend: POST /voice/synthesize + */ +export const voiceService = { + // POST /voice/synthesize - TTS 변환 + synthesize: async (messageId, roomId, voice = 'FEMALE') => { + return withMock( + () => chatApi.post('/voice/synthesize', {messageId, roomId, voice}), + {audioUrl: null, cached: false} + ) + }, +} + +// 캐치마인드 게임 Mock 상태 +let mockGameState = { + gameStatus: 'NONE', // NONE, PLAYING, FINISHED + currentRound: 0, + totalRounds: 5, + currentDrawerId: null, + currentWord: null, + roundStartTime: null, + roundTimeLimit: 60, + drawerOrder: [], + scores: {}, + hintUsed: false, + correctGuessers: [], +} + +const mockGameWords = [ + {wordId: 'gw1', korean: '사과', english: 'apple'}, + {wordId: 'gw2', korean: '바나나', english: 'banana'}, + {wordId: 'gw3', korean: '컴퓨터', english: 'computer'}, + {wordId: 'gw4', korean: '의자', english: 'chair'}, + {wordId: 'gw5', korean: '책상', english: 'desk'}, +] + +/** + * 캐치마인드 게임 API - Backend: POST /rooms/{roomId}/game/start, stop, GET status, scores + */ +export const gameService = { + // POST /rooms/{roomId}/game/start - 게임 시작 + start: async (roomId) => { + const mockDrawerOrder = ['user1', 'user2', 'user3'] + const firstWord = mockGameWords[0] + + mockGameState = { + gameStatus: 'PLAYING', + currentRound: 1, + totalRounds: 5, + currentDrawerId: TEMP_USER_ID, + currentWord: firstWord.korean, + currentWordEnglish: firstWord.english, + roundStartTime: Date.now(), + roundTimeLimit: 60, + drawerOrder: mockDrawerOrder, + scores: {}, + hintUsed: false, + correctGuessers: [], + } + + return withMock( + () => chatApi.post(`/rooms/${roomId}/game/start`, {userId: TEMP_USER_ID}), + { + gameStatus: mockGameState.gameStatus, + currentRound: mockGameState.currentRound, + totalRounds: mockGameState.totalRounds, + currentDrawerId: mockGameState.currentDrawerId, + roundStartTime: mockGameState.roundStartTime, + roundTimeLimit: mockGameState.roundTimeLimit, + drawerOrder: mockGameState.drawerOrder, + scores: mockGameState.scores, + // 출제자에게만 단어 제공 + currentWord: mockGameState.currentWord, + currentWordEnglish: mockGameState.currentWordEnglish, + } + ) + }, + + // POST /rooms/{roomId}/game/stop - 게임 중단 + stop: async (roomId) => { + const finalScores = {...mockGameState.scores} + mockGameState = { + gameStatus: 'NONE', + currentRound: 0, + totalRounds: 5, + currentDrawerId: null, + currentWord: null, + roundStartTime: null, + roundTimeLimit: 60, + drawerOrder: [], + scores: {}, + hintUsed: false, + correctGuessers: [], + } + + return withMock( + () => chatApi.post(`/rooms/${roomId}/game/stop`, {userId: TEMP_USER_ID}), + {message: '게임이 종료되었습니다.', scores: finalScores} + ) + }, + + // GET /rooms/{roomId}/game/status - 게임 상태 조회 + getStatus: async (roomId) => { + return withMock( + () => chatApi.get(`/rooms/${roomId}/game/status`), + { + gameStatus: mockGameState.gameStatus, + currentRound: mockGameState.currentRound, + totalRounds: mockGameState.totalRounds, + currentDrawerId: mockGameState.currentDrawerId, + roundStartTime: mockGameState.roundStartTime, + roundTimeLimit: mockGameState.roundTimeLimit, + drawerOrder: mockGameState.drawerOrder, + scores: mockGameState.scores, + hintUsed: mockGameState.hintUsed, + correctGuessers: mockGameState.correctGuessers, + // 출제자에게만 단어 제공 (실제로는 서버에서 userId 체크) + currentWord: mockGameState.currentWord, + } + ) + }, + + // GET /rooms/{roomId}/game/scores - 점수 조회 + getScores: async (roomId) => { + const sortedScores = Object.entries(mockGameState.scores) + .sort(([, a], [, b]) => b - a) + .map(([userId, score], index) => ({rank: index + 1, userId, score})) + + return withMock( + () => chatApi.get(`/rooms/${roomId}/game/scores`), + {scores: sortedScores, currentRound: mockGameState.currentRound, totalRounds: mockGameState.totalRounds} + ) + }, + + // 정답 체크 (Mock에서 사용 - 실제로는 WebSocket으로 처리) + checkAnswer: async (roomId, answer) => { + const isCorrect = answer.trim().toLowerCase() === mockGameState.currentWord?.toLowerCase() + + if (isCorrect && !mockGameState.correctGuessers.includes(TEMP_USER_ID)) { + const elapsedTime = Date.now() - mockGameState.roundStartTime + const timeBonus = Math.max(0, Math.floor((mockGameState.roundTimeLimit * 1000 - elapsedTime) / 1000 * 0.5)) + const score = 10 + timeBonus + + mockGameState.correctGuessers.push(TEMP_USER_ID) + mockGameState.scores[TEMP_USER_ID] = (mockGameState.scores[TEMP_USER_ID] || 0) + score + + return withMock( + () => Promise.resolve({data: {correct: true, score, elapsedTime}}), + {correct: true, score, elapsedTime, scores: mockGameState.scores} + ) + } + + return withMock( + () => Promise.resolve({data: {correct: false}}), + {correct: false} + ) + }, + + // 힌트 요청 (출제자만) + requestHint: async (roomId) => { + if (mockGameState.hintUsed) { + return withMock( + () => Promise.reject(new Error('이미 힌트를 사용했습니다.')), + {error: '이미 힌트를 사용했습니다.'} + ) + } + + const hint = mockGameState.currentWord?.charAt(0) + '○'.repeat((mockGameState.currentWord?.length || 1) - 1) + mockGameState.hintUsed = true + + return withMock( + () => chatApi.post(`/rooms/${roomId}/game/hint`, {userId: TEMP_USER_ID}), + {hint, hintUsed: true} + ) + }, + + // 라운드 스킵 (출제자만) + skipRound: async (roomId) => { + if (mockGameState.currentRound >= mockGameState.totalRounds) { + mockGameState.gameStatus = 'FINISHED' + return withMock( + () => chatApi.post(`/rooms/${roomId}/game/skip`, {userId: TEMP_USER_ID}), + {gameStatus: 'FINISHED', message: '게임이 종료되었습니다.'} + ) + } + + const nextRound = mockGameState.currentRound + 1 + const nextWord = mockGameWords[nextRound - 1] || mockGameWords[0] + const nextDrawerIndex = (nextRound - 1) % mockGameState.drawerOrder.length + + mockGameState = { + ...mockGameState, + currentRound: nextRound, + currentDrawerId: mockGameState.drawerOrder[nextDrawerIndex], + currentWord: nextWord.korean, + currentWordEnglish: nextWord.english, + roundStartTime: Date.now(), + hintUsed: false, + correctGuessers: [], + } + + return withMock( + () => chatApi.post(`/rooms/${roomId}/game/skip`, {userId: TEMP_USER_ID}), + { + currentRound: mockGameState.currentRound, + currentDrawerId: mockGameState.currentDrawerId, + currentWord: mockGameState.currentWord, + message: `라운드 ${nextRound} 시작!`, + } + ) + }, +} + +// 메시지 타입 상수 +export const MESSAGE_TYPES = { + TEXT: 'text', + IMAGE: 'image', + VOICE: 'voice', + AI_RESPONSE: 'ai_response', + GAME_START: 'game_start', + GAME_END: 'game_end', + ROUND_START: 'round_start', + ROUND_END: 'round_end', + DRAWING: 'drawing', + DRAWING_CLEAR: 'drawing_clear', + CORRECT_ANSWER: 'correct_answer', + SCORE_UPDATE: 'score_update', + SYSTEM_COMMAND: 'system_command', + HINT: 'hint', +} + +// 게임 상태 상수 +export const GAME_STATUS = { + NONE: 'NONE', + PLAYING: 'PLAYING', + FINISHED: 'FINISHED', +} + +export {TEMP_USER_ID} diff --git a/src/domains/freetalk/components/ChatRoomCard.jsx b/src/domains/freetalk/components/ChatRoomCard.jsx new file mode 100644 index 0000000..7a3c34f --- /dev/null +++ b/src/domains/freetalk/components/ChatRoomCard.jsx @@ -0,0 +1,138 @@ +import {Box, Button, Card, CardContent, Chip, Typography} from '@mui/material' +import {AccessTime as TimeIcon, Lock as LockIcon, People as PeopleIcon} from '@mui/icons-material' + +const levelColors = { + beginner: {bg: '#e8f5e9', color: '#2e7d32', label: '초급'}, + intermediate: {bg: '#fff3e0', color: '#ef6c00', label: '중급'}, + advanced: {bg: '#fce4ec', color: '#c2185b', label: '고급'}, +} + +const formatTimeAgo = (date) => { + const now = new Date() + const diff = Math.floor((now - new Date(date)) / 1000) + + if (diff < 60) return '방금 전' + if (diff < 3600) return `${Math.floor(diff / 60)}분 전` + if (diff < 86400) return `${Math.floor(diff / 3600)}시간 전` + return `${Math.floor(diff / 86400)}일 전` +} + +const formatDate = (date) => { + const d = new Date(date) + const year = d.getFullYear().toString().slice(2) + const month = (d.getMonth() + 1).toString().padStart(2, '0') + const day = d.getDate().toString().padStart(2, '0') + return `${year}/${month}/${day}` +} + +const ChatRoomCard = ({room, onClick}) => { + const level = levelColors[room.level] || levelColors.beginner + + const handleEnterClick = (e) => { + e.stopPropagation() + onClick?.(room) + } + + return ( + + + {/* 상단: 레벨 뱃지 + 방 이름 + 입장 버튼 */} + + + + + {room.isPrivate && ( + + )} + + {room.name} + + + + + + + {/* 중단: 소개 */} + {room.description && ( + + {room.description} + + )} + + {/* 하단: 인원, 마지막 대화, 생성일 */} + + + + + + {room.currentMembers} + + /{room.maxMembers} + + + + + + + {formatTimeAgo(room.lastMessageAt)} + + + + + · 생성: {formatDate(room.createdAt)} + + + + + ) +} + +export default ChatRoomCard diff --git a/src/domains/freetalk/components/ChatRoomModal.jsx b/src/domains/freetalk/components/ChatRoomModal.jsx new file mode 100644 index 0000000..bedd277 --- /dev/null +++ b/src/domains/freetalk/components/ChatRoomModal.jsx @@ -0,0 +1,642 @@ +import {useCallback, useEffect, useRef, useState} from 'react' +import { + Alert, + Avatar, + Box, + Chip, + CircularProgress, + Fade, + IconButton, + Paper, + Tab, + Tabs, + TextField, + Typography, + useTheme, +} from '@mui/material' +import { + Chat as ChatIcon, + Close as CloseIcon, + ExitToApp as ExitToAppIcon, + Minimize as MinimizeIcon, + OpenInFull as MaximizeIcon, + Refresh as RefreshIcon, + Send as SendIcon, + SportsEsports as GameIcon, + VolumeUp as VolumeUpIcon, +} from '@mui/icons-material' +import { + chatRoomService, + GAME_STATUS, + gameService, + MESSAGE_TYPES, + messageService, + TEMP_USER_ID, + voiceService +} from '../../chat/services/chatService' +import {useSettings} from '../../../contexts/SettingsContext' +import {DESIGN_TOKENS, getChatStyles} from '../../../theme/theme' +import GameModePanel from './GameModePanel' + +const ChatRoomModal = ({open, onClose, room, onLeave}) => { + const theme = useTheme() + const isDark = theme.palette.mode === 'dark' + const {settings} = useSettings() + const messagesEndRef = useRef(null) + const dragRef = useRef(null) + + const [messages, setMessages] = useState([]) + const [newMessage, setNewMessage] = useState('') + const [loading, setLoading] = useState(true) + const [sendingMessage, setSendingMessage] = useState(false) + const [error, setError] = useState(null) + const [playingTTS, setPlayingTTS] = useState(null) + const [minimized, setMinimized] = useState(false) + const [position, setPosition] = useState({x: 0, y: 0}) + const [savedPosition, setSavedPosition] = useState({x: 0, y: 0}) + const [isDragging, setIsDragging] = useState(false) + const [dragOffset, setDragOffset] = useState({x: 0, y: 0}) + const [activeTab, setActiveTab] = useState(0) // 0: 채팅, 1: 게임 + const [gameStatus, setGameStatus] = useState(GAME_STATUS.NONE) + // 메시지 목록 조회 + const fetchMessages = useCallback(async () => { + if (!room?.id) return + + try { + const response = await messageService.getList(room.id, {limit: 50}) + const responseData = response.data || response + const transformedMessages = (responseData.messages || []).map((msg) => ({ + id: msg.messageId || msg.pk?.replace('MESSAGE#', ''), + content: msg.content, + userId: msg.userId, + messageType: msg.messageType, + createdAt: new Date(msg.createdAt), + isOwn: msg.userId === TEMP_USER_ID, + })) + setMessages(transformedMessages.reverse()) + } catch (err) { + console.error('Failed to fetch messages:', err) + setError('메시지를 불러오는데 실패했습니다') + } + }, [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) + } + }, [room?.id]) + + // 초기 로드 + useEffect(() => { + if (open && room?.id) { + 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, + } + 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) + } + } + + // 스크롤 맨 아래로 + const scrollToBottom = (instant = false) => { + messagesEndRef.current?.scrollIntoView({behavior: instant ? 'instant' : 'smooth'}) + } + + // 메시지 로드 완료 후 스크롤 + useEffect(() => { + if (!loading && messages.length > 0) { + // 처음 로드 시 즉시 스크롤 + setTimeout(() => scrollToBottom(true), 100) + } + }, [loading]) + + // 새 메시지 추가 시 부드럽게 스크롤 + useEffect(() => { + if (messages.length > 0 && !loading) { + scrollToBottom(false) + } + }, [messages.length]) + + // 드래그 핸들러 + const handleMouseDown = (e) => { + // 버튼, 입력창, 슬라이더, 팝오버 클릭 시 드래그 방지 + if ( + e.target.closest('button') || + e.target.closest('input') || + e.target.closest('.MuiSlider-root') || + e.target.closest('.MuiPopover-root') + ) return + setIsDragging(true) + const rect = dragRef.current?.getBoundingClientRect() + setDragOffset({ + x: e.clientX - (rect?.left || 0), + y: e.clientY - (rect?.top || 0), + }) + } + + useEffect(() => { + const handleMouseMove = (e) => { + if (!isDragging) return + setPosition({ + x: e.clientX - dragOffset.x, + y: e.clientY - dragOffset.y, + }) + } + + const handleMouseUp = () => { + setIsDragging(false) + } + + if (isDragging) { + window.addEventListener('mousemove', handleMouseMove) + window.addEventListener('mouseup', handleMouseUp) + } + + return () => { + window.removeEventListener('mousemove', handleMouseMove) + window.removeEventListener('mouseup', handleMouseUp) + } + }, [isDragging, dragOffset]) + + // 메시지 전송 + const handleSendMessage = async () => { + if (!newMessage.trim() || sendingMessage) return + + setSendingMessage(true) + 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]) + + 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) + } + } + + const handleKeyPress = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSendMessage() + } + } + + // TTS 재생 (모든 메시지에서 가능) + const handlePlayTTS = async (messageId) => { + if (playingTTS === messageId) return + + setPlayingTTS(messageId) + try { + const response = await voiceService.synthesize(messageId, room.id, settings.ttsVoice) + const responseData = response.data || response + if (responseData.audioUrl) { + const audio = new Audio(responseData.audioUrl) + audio.onended = () => setPlayingTTS(null) + audio.onerror = () => setPlayingTTS(null) + await audio.play() + } + } catch (err) { + console.error('Failed to play TTS:', err) + setPlayingTTS(null) + } + } + + // 최소화/최대화 토글 + const handleToggleMinimize = () => { + if (!minimized) { + // 최소화: 현재 위치 저장 후 우측 하단으로 이동 + setSavedPosition(position) + setPosition({x: 0, y: 0}) + } else { + // 최대화: 저장된 위치로 복원 후 스크롤 맨 아래로 + setPosition(savedPosition) + setTimeout(() => scrollToBottom(true), 100) + } + setMinimized(!minimized) + } + + // 채팅방 퇴장 + const handleLeaveRoom = async () => { + try { + await chatRoomService.leave(room.id) + onLeave?.() + onClose() + } catch (err) { + console.error('Failed to leave room:', err) + setError('채팅방 퇴장에 실패했습니다') + } + } + + const formatTime = (date) => { + return new Intl.DateTimeFormat('ko-KR', { + hour: '2-digit', + minute: '2-digit', + }).format(date) + } + + if (!open) return null + + return ( + + + {/* 헤더 - 드래그 가능 */} + + + + {room?.name || '채팅방'} + + {room?.level && ( + + )} + + + + + + + {minimized ? : } + + + + + + + + + + + {!minimized && ( + <> + {/* 탭 (채팅/게임) */} + setActiveTab(v)} + variant="fullWidth" + sx={{ + minHeight: 36, + '& .MuiTab-root': {minHeight: 36, py: 0.5}, + }} + > + } + iconPosition="start" + label="채팅" + sx={{fontSize: '0.75rem'}} + /> + } + iconPosition="start" + label="캐치마인드" + sx={{fontSize: '0.75rem'}} + /> + + + {/* 에러 메시지 */} + {error && ( + setError(null)} sx={{borderRadius: 0}}> + {error} + + )} + + {/* 게임 모드 */} + {activeTab === 1 && ( + + + + )} + + {/* 채팅 모드 - 메시지 영역 */} + {activeTab === 0 && ( + loading ? ( + + + + ) : ( + + {messages.length === 0 ? ( + + + 첫 메시지를 보내보세요! + + + ) : ( + messages.map((message) => ( + + {/* 시스템 메시지 */} + {message.isSystem ? ( + + ) : ( + <> + {!message.isOwn && ( + + {message.userId?.charAt(0)?.toUpperCase() || 'U'} + + )} + + + {!message.isOwn && ( + + {message.userId} + + )} + + + {message.isOwn && ( + <> + handlePlayTTS(message.id)} + disabled={playingTTS === message.id} + sx={{p: 0.25}} + > + {playingTTS === message.id ? ( + + ) : ( + + )} + + + {formatTime(message.createdAt)} + + + )} + + + + {message.content} + + + + {!message.isOwn && ( + + handlePlayTTS(message.id)} + disabled={playingTTS === message.id} + sx={{p: 0.25}} + > + {playingTTS === message.id ? ( + + ) : ( + + )} + + + {formatTime(message.createdAt)} + + + )} + + + + )} + + )) + )} +
+ + ) + )} + + {/* 입력 영역 */} + + setNewMessage(e.target.value)} + onKeyPress={handleKeyPress} + size="small" + multiline + maxRows={2} + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: 2, + fontSize: '0.85rem', + }, + '& .MuiOutlinedInput-input': { + py: 0.75, + }, + }} + /> + + {sendingMessage ? : + } + + + + )} + + + ) +} + +export default ChatRoomModal diff --git a/src/domains/freetalk/components/CreateRoomModal.jsx b/src/domains/freetalk/components/CreateRoomModal.jsx new file mode 100644 index 0000000..fa5abe8 --- /dev/null +++ b/src/domains/freetalk/components/CreateRoomModal.jsx @@ -0,0 +1,188 @@ +import {useState} from 'react' +import { + Alert, + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + FormControlLabel, + InputLabel, + MenuItem, + Select, + Switch, + TextField, +} from '@mui/material' +import {chatRoomService} from '../../chat/services/chatService' + +const CreateRoomModal = ({open, onClose, onSuccess}) => { + const [formData, setFormData] = useState({ + name: '', + description: '', + level: 'BEGINNER', + maxParticipants: 6, + isPrivate: false, + password: '', + }) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const handleChange = (field) => (e) => { + const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value + setFormData((prev) => ({ + ...prev, + [field]: value, + })) + setError(null) + } + + const handleSubmit = async () => { + if (!formData.name.trim()) { + setError('채팅방 이름을 입력해주세요') + return + } + + if (formData.isPrivate && !formData.password) { + setError('비밀방은 비밀번호가 필요합니다') + return + } + + setLoading(true) + setError(null) + + try { + const payload = { + name: formData.name.trim(), + description: formData.description.trim() || undefined, + level: formData.level, + maxParticipants: formData.maxParticipants, + isPrivate: formData.isPrivate, + ...(formData.isPrivate && {password: formData.password}), + } + + await chatRoomService.create(payload) + handleClose() + if (onSuccess) onSuccess() + } catch (err) { + console.error('Failed to create room:', err) + setError('채팅방 생성에 실패했습니다. 다시 시도해주세요.') + } finally { + setLoading(false) + } + } + + const handleClose = () => { + setFormData({ + name: '', + description: '', + level: 'BEGINNER', + maxParticipants: 6, + isPrivate: false, + password: '', + }) + setError(null) + onClose() + } + + return ( + + 새 채팅방 만들기 + + + {error && ( + setError(null)}> + {error} + + )} + + + + + + + 레벨 + + + + + 최대 인원 + + + + + } + label="비밀방으로 설정" + /> + + {formData.isPrivate && ( + + )} + + + + + + + + ) +} + +export default CreateRoomModal diff --git a/src/domains/freetalk/components/GameModePanel.jsx b/src/domains/freetalk/components/GameModePanel.jsx new file mode 100644 index 0000000..f77c21d --- /dev/null +++ b/src/domains/freetalk/components/GameModePanel.jsx @@ -0,0 +1,412 @@ +import {useCallback, useEffect, useRef, useState} from 'react' +import {Box, Button, Chip, IconButton, LinearProgress, Tooltip, Typography, useTheme,} from '@mui/material' +import { + Delete as ClearIcon, + EmojiEvents as TrophyIcon, + Lightbulb as HintIcon, + PlayArrow as PlayIcon, + SkipNext as SkipIcon, + Stop as StopIcon, +} from '@mui/icons-material' +import {GAME_STATUS, gameService, TEMP_USER_ID} from '../../chat/services/chatService' +import {DESIGN_TOKENS} from '../../../theme/theme' + +const CANVAS_WIDTH = 340 +const CANVAS_HEIGHT = 200 + +const GameModePanel = ({roomId, onGameMessage}) => { + const theme = useTheme() + const isDark = theme.palette.mode === 'dark' + const canvasRef = useRef(null) + const [gameState, setGameState] = useState({ + gameStatus: GAME_STATUS.NONE, + currentRound: 0, + totalRounds: 5, + currentDrawerId: null, + currentWord: null, + roundStartTime: null, + roundTimeLimit: 60, + scores: {}, + hintUsed: false, + }) + const [timeLeft, setTimeLeft] = useState(60) + const [isDrawing, setIsDrawing] = useState(false) + const [brushColor, setBrushColor] = useState('#000000') + const [brushSize, setBrushSize] = useState(3) + const [loading, setLoading] = useState(false) + + const isDrawer = gameState.currentDrawerId === TEMP_USER_ID + const isGameActive = gameState.gameStatus === GAME_STATUS.PLAYING + + // 게임 상태 조회 + const fetchGameStatus = useCallback(async () => { + try { + const response = await gameService.getStatus(roomId) + const data = response.data || response + setGameState(data) + } catch (err) { + console.error('Failed to fetch game status:', err) + } + }, [roomId]) + + // 타이머 + useEffect(() => { + if (!isGameActive || !gameState.roundStartTime) return + + 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) { + // 시간 초과 처리 + clearInterval(interval) + } + }, 1000) + + 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) + } + } + + // 게임 종료 + 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) + } + } + + // 라운드 스킵 + const handleSkipRound = async () => { + try { + const response = await gameService.skipRound(roomId) + const data = response.data || response + if (data.gameStatus === GAME_STATUS.FINISHED) { + setGameState(prev => ({...prev, gameStatus: GAME_STATUS.FINISHED})) + } else { + setGameState(prev => ({ + ...prev, + currentRound: data.currentRound, + currentDrawerId: data.currentDrawerId, + currentWord: data.currentWord, + roundStartTime: Date.now(), + hintUsed: false, + })) + } + onGameMessage?.({ + type: 'round_end', + content: data.message, + }) + } catch (err) { + console.error('Failed to skip round:', err) + } + } + + // 힌트 제공 + const handleHint = async () => { + try { + const response = await gameService.requestHint(roomId) + const data = response.data || response + if (data.hint) { + setGameState(prev => ({...prev, hintUsed: true})) + onGameMessage?.({ + type: 'hint', + content: `💡 힌트: ${data.hint}`, + }) + } + } catch (err) { + console.error('Failed to get hint:', err) + } + } + + // 캔버스 초기화 + const clearCanvas = () => { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, canvas.width, canvas.height) + } + + // 캔버스 드로잉 + const startDrawing = (e) => { + if (!isDrawer) return + setIsDrawing(true) + draw(e) + } + + const stopDrawing = () => { + setIsDrawing(false) + const canvas = canvasRef.current + if (canvas) { + const ctx = canvas.getContext('2d') + ctx.beginPath() + } + } + + const draw = (e) => { + if (!isDrawing || !isDrawer) return + const canvas = canvasRef.current + if (!canvas) return + + const ctx = canvas.getContext('2d') + const rect = canvas.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top + + ctx.lineWidth = brushSize + ctx.lineCap = 'round' + ctx.strokeStyle = brushColor + ctx.lineTo(x, y) + ctx.stroke() + ctx.beginPath() + ctx.moveTo(x, y) + } + + // 캔버스 초기화 + useEffect(() => { + if (canvasRef.current) { + clearCanvas() + } + }, [gameState.currentRound]) + + // 점수 정렬 + const sortedScores = Object.entries(gameState.scores || {}) + .sort(([, a], [, b]) => b - a) + .slice(0, 5) + + if (gameState.gameStatus === GAME_STATUS.NONE) { + return ( + + + 캐치마인드 게임을 시작해보세요! + + + + 또는 채팅창에 /start 입력 + + + ) + } + + return ( + + {/* 게임 헤더 */} + + + + + 출제자: {gameState.currentDrawerId} + + + + + {Math.floor(timeLeft / 60)}:{(timeLeft % 60).toString().padStart(2, '0')} + + + + + {/* 타이머 진행바 */} + + + {/* 출제어 (출제자만 보임) */} + {isDrawer && ( + + + 제시어 + + + {gameState.currentWord} + + + )} + + {/* 캔버스 영역 */} + + + + {/* 그리기 도구 (출제자만) */} + {isDrawer && ( + + {['#000000', '#ff0000', '#0000ff', '#00ff00', '#ffff00', '#ff00ff'].map((color) => ( + setBrushColor(color)} + sx={{ + width: 20, + height: 20, + bgcolor: color, + borderRadius: '50%', + border: brushColor === color + ? `2px solid ${isDark ? 'white' : '#333'}` + : `1px solid ${isDark ? 'rgba(255,255,255,0.3)' : '#ccc'}`, + cursor: 'pointer', + }} + /> + ))} + + + + + + + )} + + + {/* 점수판 */} + {sortedScores.length > 0 && ( + + + + + 점수 + + + + {sortedScores.map(([userId, score], idx) => ( + + ))} + + + )} + + {/* 게임 컨트롤 */} + + {isDrawer ? ( + <> + + + + ) : ( + + 정답을 채팅으로 입력하세요! + + )} + + + + ) +} + +export default GameModePanel diff --git a/src/domains/freetalk/pages/ChatRoomPage.jsx b/src/domains/freetalk/pages/ChatRoomPage.jsx new file mode 100644 index 0000000..77f9d75 --- /dev/null +++ b/src/domains/freetalk/pages/ChatRoomPage.jsx @@ -0,0 +1,385 @@ +import {useCallback, useEffect, useRef, useState} from 'react' +import {useNavigate, useParams} from 'react-router-dom' +import { + Alert, + AppBar, + Avatar, + Box, + Chip, + CircularProgress, + IconButton, + Paper, + TextField, + Toolbar, + Typography, +} from '@mui/material' +import { + ArrowBack as ArrowBackIcon, + ExitToApp as ExitToAppIcon, + Refresh as RefreshIcon, + Send as SendIcon, + VolumeUp as VolumeUpIcon, +} from '@mui/icons-material' +import {chatRoomService, messageService, TEMP_USER_ID, voiceService} from '../../chat/services/chatService' + +const levelColors = { + beginner: {bg: '#e8f5e9', color: '#2e7d32', label: '초급'}, + intermediate: {bg: '#fff3e0', color: '#ef6c00', label: '중급'}, + advanced: {bg: '#fce4ec', color: '#c2185b', label: '고급'}, +} + +const ChatRoomPage = () => { + const {roomId} = useParams() + const navigate = useNavigate() + const messagesEndRef = useRef(null) + const messagesContainerRef = useRef(null) + + const [room, setRoom] = useState(null) + const [messages, setMessages] = useState([]) + const [newMessage, setNewMessage] = useState('') + const [loading, setLoading] = useState(true) + const [sendingMessage, setSendingMessage] = useState(false) + const [error, setError] = useState(null) + const [playingTTS, setPlayingTTS] = useState(null) + + // 채팅방 정보 조회 + const fetchRoomDetail = useCallback(async () => { + try { + const response = await chatRoomService.getDetail(roomId) + setRoom({ + id: response.roomId, + name: response.name, + description: response.description, + level: response.level?.toLowerCase() || 'beginner', + currentMembers: response.currentParticipants || 0, + maxMembers: response.maxParticipants || 6, + }) + } catch (err) { + console.error('Failed to fetch room detail:', err) + setError('채팅방 정보를 불러오는데 실패했습니다') + } + }, [roomId]) + + // 메시지 목록 조회 + const fetchMessages = useCallback(async () => { + try { + const response = await messageService.getList(roomId, {limit: 50}) + const transformedMessages = (response.messages || []).map((msg) => ({ + id: msg.messageId, + content: msg.content, + userId: msg.userId, + messageType: msg.messageType, + createdAt: new Date(msg.createdAt), + isOwn: msg.userId === TEMP_USER_ID, + })) + // 오래된 메시지가 위에 오도록 정렬 + setMessages(transformedMessages.reverse()) + } catch (err) { + console.error('Failed to fetch messages:', err) + setError('메시지를 불러오는데 실패했습니다') + } + }, [roomId]) + + // 초기 로드 + useEffect(() => { + const loadData = async () => { + setLoading(true) + await Promise.all([fetchRoomDetail(), fetchMessages()]) + setLoading(false) + } + loadData() + }, [fetchRoomDetail, fetchMessages]) + + // 스크롤 맨 아래로 + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({behavior: 'smooth'}) + } + + useEffect(() => { + scrollToBottom() + }, [messages]) + + // 메시지 전송 + const handleSendMessage = async () => { + if (!newMessage.trim() || sendingMessage) return + + setSendingMessage(true) + const messageContent = newMessage.trim() + setNewMessage('') + + // Optimistic update + const tempMessage = { + id: `temp-${Date.now()}`, + content: messageContent, + userId: TEMP_USER_ID, + messageType: 'TEXT', + createdAt: new Date(), + isOwn: true, + } + setMessages((prev) => [...prev, tempMessage]) + + try { + await messageService.send(roomId, messageContent) + // 전송 성공 시 메시지 새로고침 + await fetchMessages() + } catch (err) { + console.error('Failed to send message:', err) + // 실패 시 임시 메시지 제거 + setMessages((prev) => prev.filter((m) => m.id !== tempMessage.id)) + setError('메시지 전송에 실패했습니다') + } finally { + setSendingMessage(false) + } + } + + // 엔터 키 전송 + const handleKeyPress = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSendMessage() + } + } + + // TTS 재생 + const handlePlayTTS = async (messageId) => { + if (playingTTS === messageId) return + + setPlayingTTS(messageId) + try { + const response = await voiceService.synthesize(messageId, roomId) + const responseData = response.data || response + if (responseData.audioUrl) { + const audio = new Audio(responseData.audioUrl) + audio.onended = () => setPlayingTTS(null) + audio.onerror = () => setPlayingTTS(null) + await audio.play() + } + } catch (err) { + console.error('Failed to play TTS:', err) + setPlayingTTS(null) + } + } + + // 채팅방 퇴장 + const handleLeaveRoom = async () => { + try { + await chatRoomService.leave(roomId) + navigate('/freetalk/people') + } catch (err) { + console.error('Failed to leave room:', err) + setError('채팅방 퇴장에 실패했습니다') + } + } + + // 새로고침 + const handleRefresh = () => { + fetchMessages() + } + + // 시간 포맷 + const formatTime = (date) => { + return new Intl.DateTimeFormat('ko-KR', { + hour: '2-digit', + minute: '2-digit', + }).format(date) + } + + if (loading) { + return ( + + + + ) + } + + return ( + + {/* 헤더 */} + + + navigate('/freetalk/people')} sx={{mr: 1}}> + + + + + {room?.name || '채팅방'} + + + {room?.level && ( + + )} + + {room?.currentMembers}/{room?.maxMembers}명 + + + + + + + + + + + + + {/* 에러 메시지 */} + {error && ( + setError(null)} sx={{m: 1}}> + {error} + + )} + + {/* 메시지 영역 */} + + {messages.length === 0 ? ( + + + 아직 메시지가 없습니다. 첫 메시지를 보내보세요! + + + ) : ( + messages.map((message) => ( + + {/* 아바타 (상대방만) */} + {!message.isOwn && ( + + {message.userId?.charAt(0)?.toUpperCase() || 'U'} + + )} + + + {/* 사용자 이름 (상대방만) */} + {!message.isOwn && ( + + {message.userId} + + )} + + {/* 메시지 버블 */} + + {message.isOwn && ( + + {formatTime(message.createdAt)} + + )} + + + + {message.content} + + + + {!message.isOwn && ( + + handlePlayTTS(message.id)} + disabled={playingTTS === message.id} + sx={{p: 0.5}} + > + {playingTTS === message.id ? ( + + ) : ( + + )} + + + {formatTime(message.createdAt)} + + + )} + + + + )) + )} +
+ + + {/* 입력 영역 */} + + setNewMessage(e.target.value)} + onKeyPress={handleKeyPress} + size="small" + multiline + maxRows={3} + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: 3, + }, + }} + /> + + {sendingMessage ? : } + + + + ) +} + +export default ChatRoomPage diff --git a/src/domains/freetalk/pages/FreetalkPeoplePage.jsx b/src/domains/freetalk/pages/FreetalkPeoplePage.jsx new file mode 100644 index 0000000..3d8ee47 --- /dev/null +++ b/src/domains/freetalk/pages/FreetalkPeoplePage.jsx @@ -0,0 +1,444 @@ +import {useCallback, useEffect, useRef, useState} from 'react' +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Container, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Fab, + Grid, + InputAdornment, + TextField, + ToggleButton, + ToggleButtonGroup, + Typography, +} from '@mui/material' +import { + Add as AddIcon, + Lock as LockIcon, + People as PeopleIcon, + Refresh as RefreshIcon, + Search as SearchIcon, +} from '@mui/icons-material' +import ChatRoomCard from '../components/ChatRoomCard' +import CreateRoomModal from '../components/CreateRoomModal' +import {chatRoomService} from '../../chat/services/chatService' +import {useChat} from '../../../contexts/ChatContext' + +const levelColors = { + beginner: {bg: '#e8f5e9', color: '#2e7d32', label: '초급'}, + intermediate: {bg: '#fff3e0', color: '#ef6c00', label: '중급'}, + advanced: {bg: '#fce4ec', color: '#c2185b', label: '고급'}, +} + +const FreetalkPeoplePage = () => { + const {openChatRoom} = useChat() + const [searchQuery, setSearchQuery] = useState('') + const [levelFilter, setLevelFilter] = useState('all') + const [selectedRoom, setSelectedRoom] = useState(null) + const [modalOpen, setModalOpen] = useState(false) + const [password, setPassword] = useState('') + const [passwordError, setPasswordError] = useState('') + + // API 관련 state + const [rooms, setRooms] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [cursor, setCursor] = useState(null) + const [hasMore, setHasMore] = useState(true) + const [joining, setJoining] = useState(false) + const [createModalOpen, setCreateModalOpen] = useState(false) + const observerRef = useRef(null) + + // API에서 채팅방 데이터를 프론트엔드 형식으로 변환 + const transformRoomData = (apiRoom) => { + // pk가 "ROOM#uuid" 형식이므로 ID 추출 + const roomId = apiRoom.pk?.replace('ROOM#', '') || apiRoom.roomId + // 참여자 수 필드명 여러 가지 체크 + const currentMembers = apiRoom.currentParticipants || apiRoom.participantCount || apiRoom.memberCount || apiRoom.currentMembers || 0 + const maxMembers = apiRoom.maxParticipants || apiRoom.maxMembers || 6 + return { + id: roomId, + name: apiRoom.name || '채팅방', + description: apiRoom.description || '', + level: (apiRoom.level || 'beginner').toLowerCase(), + currentMembers, + maxMembers, + lastMessageAt: apiRoom.lastMessageAt ? new Date(apiRoom.lastMessageAt) : null, + createdAt: apiRoom.createdAt ? new Date(apiRoom.createdAt) : new Date(), + isPrivate: apiRoom.isPrivate || false, + isJoined: apiRoom.isJoined || false, + } + } + + // 채팅방 목록 조회 + const fetchRooms = useCallback(async (isLoadMore = false) => { + if (loading) return + + setLoading(true) + setError(null) + + try { + const params = { + limit: 10, + ...(levelFilter !== 'all' && levelFilter !== 'joined' && {level: levelFilter.toUpperCase()}), + ...(levelFilter === 'joined' && {joined: true}), + ...(isLoadMore && cursor && {cursor}), + } + + const response = await chatRoomService.getList(params) + // API 응답: { success, message, data: { rooms, hasMore, nextCursor } } + const responseData = response.data || response + const transformedRooms = (responseData.rooms || []).map(transformRoomData) + + if (isLoadMore) { + setRooms((prev) => [...prev, ...transformedRooms]) + } else { + setRooms(transformedRooms) + } + + setCursor(responseData.nextCursor || null) + setHasMore(!!responseData.nextCursor) + } catch (err) { + console.error('Failed to fetch rooms:', err) + setError('채팅방 목록을 불러오는데 실패했습니다') + } finally { + setLoading(false) + } + }, [levelFilter, cursor, loading]) + + // 초기 로드 및 필터 변경 시 재조회 + useEffect(() => { + const loadRooms = async () => { + setLoading(true) + setError(null) + setCursor(null) + setHasMore(true) + setRooms([]) + + try { + const params = { + limit: 10, + ...(levelFilter !== 'all' && levelFilter !== 'joined' && {level: levelFilter.toUpperCase()}), + ...(levelFilter === 'joined' && {joined: true}), + } + + const response = await chatRoomService.getList(params) + const responseData = response.data || response + const transformedRooms = (responseData.rooms || []).map(transformRoomData) + setRooms(transformedRooms) + setCursor(responseData.nextCursor || null) + setHasMore(!!responseData.nextCursor) + } catch (err) { + console.error('Failed to fetch rooms:', err) + setError('채팅방 목록을 불러오는데 실패했습니다') + } finally { + setLoading(false) + } + } + + loadRooms() + }, [levelFilter]) + + // 무한 스크롤 + const lastRoomRef = useCallback((node) => { + if (loading) return + if (observerRef.current) observerRef.current.disconnect() + + observerRef.current = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && hasMore && !loading) { + fetchRooms(true) + } + }) + + if (node) observerRef.current.observe(node) + }, [loading, hasMore, fetchRooms]) + + // 새로고침 + const handleRefresh = async () => { + setLoading(true) + setError(null) + setCursor(null) + setHasMore(true) + setRooms([]) + + try { + const params = { + limit: 10, + ...(levelFilter !== 'all' && levelFilter !== 'joined' && {level: levelFilter.toUpperCase()}), + ...(levelFilter === 'joined' && {joined: true}), + } + + const response = await chatRoomService.getList(params) + const responseData = response.data || response + const transformedRooms = (responseData.rooms || []).map(transformRoomData) + setRooms(transformedRooms) + setCursor(responseData.nextCursor || null) + setHasMore(!!responseData.nextCursor) + } catch (err) { + console.error('Failed to fetch rooms:', err) + setError('채팅방 목록을 불러오는데 실패했습니다') + } finally { + setLoading(false) + } + } + + const handleLevelChange = (event, newLevel) => { + if (newLevel !== null) { + setLevelFilter(newLevel) + } + } + + const handleRoomClick = (room) => { + setSelectedRoom(room) + setPassword('') + setPasswordError('') + setModalOpen(true) + } + + const handleCloseModal = () => { + setModalOpen(false) + setSelectedRoom(null) + setPassword('') + setPasswordError('') + } + + const handleEnterRoom = async () => { + if (!selectedRoom) return + + setJoining(true) + setPasswordError('') + + try { + await chatRoomService.join(selectedRoom.id, selectedRoom.isPrivate ? password : undefined) + // 전역 채팅 모달 열기 + openChatRoom(selectedRoom) + handleCloseModal() + } catch (err) { + console.error('Failed to join room:', err) + if (err.response?.status === 401 || err.response?.data?.message?.includes('password')) { + setPasswordError('비밀번호가 일치하지 않습니다') + } else if (err.response?.status === 409) { + // 이미 참여중인 경우 바로 입장 + openChatRoom(selectedRoom) + handleCloseModal() + } else { + setPasswordError('입장에 실패했습니다. 다시 시도해주세요.') + } + } finally { + setJoining(false) + } + } + + // 클라이언트 사이드 검색 필터 + const filteredRooms = rooms.filter((room) => { + const matchesSearch = room.name.toLowerCase().includes(searchQuery.toLowerCase()) + return matchesSearch + }) + + return ( + + {/* 헤더 */} + + + 사람들과 프리토킹 + + + 다른 학습자들과 함께 영어로 자유롭게 대화해보세요 + + + + {/* 필터 영역 */} + + {/* 검색 */} + setSearchQuery(e.target.value)} + sx={{flex: 1, maxWidth: {sm: 300}}} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + {/* 레벨 필터 */} + + 전체 + 초급 + 중급 + 고급 + 참여중 + + + {/* 새로고침 버튼 */} + + + + {/* 에러 메시지 */} + {error && ( + setError(null)}> + {error} + + )} + + {/* 채팅방 목록 */} + + {filteredRooms.map((room, index) => ( + + + + ))} + + + {/* 로딩 인디케이터 */} + {loading && ( + + + + )} + + {/* 빈 상태 */} + {!loading && filteredRooms.length === 0 && ( + + + {error ? '데이터를 불러올 수 없습니다' : '채팅방이 없습니다'} + + + {error ? '새로고침 버튼을 눌러 다시 시도해주세요' : '새 채팅방을 만들어보세요'} + + + )} + + {/* 채팅방 만들기 FAB */} + setCreateModalOpen(true)} + > + + + + {/* 채팅방 생성 모달 */} + setCreateModalOpen(false)} + onSuccess={handleRefresh} + /> + + {/* 입장 모달 */} + + {selectedRoom && ( + <> + + + {selectedRoom.isPrivate && ( + + )} + + {selectedRoom.name} + + + + + {/* 방 정보 */} + + + + + + + {selectedRoom.currentMembers}/{selectedRoom.maxMembers}명 + + + + {selectedRoom.description && ( + + {selectedRoom.description} + + )} + + + {/* 비밀번호 입력 (비밀방인 경우) */} + {selectedRoom.isPrivate && ( + { + setPassword(e.target.value) + setPasswordError('') + }} + error={!!passwordError} + helperText={passwordError} + size="small" + autoFocus + /> + )} + + + + + + + )} + + + ) +} + +export default FreetalkPeoplePage diff --git a/src/domains/grammar/components/ChatInput.jsx b/src/domains/grammar/components/ChatInput.jsx new file mode 100644 index 0000000..de97179 --- /dev/null +++ b/src/domains/grammar/components/ChatInput.jsx @@ -0,0 +1,199 @@ +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 {GRAMMAR_LEVEL_COLORS, GRAMMAR_LEVELS, TEXT_LIMITS,} from '../constants/grammarConstants' + +export default function ChatInput({onSend, loading = false, level, onLevelChange}) { + const {t, isKorean} = useSettings() + const [message, setMessage] = useState('') + + const handleSend = () => { + if (message.trim().length < TEXT_LIMITS.MIN_SENTENCE_LENGTH || loading) return + onSend?.(message.trim()) + setMessage('') + } + + const handleKeyDown = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSend() + } + } + + const canSend = message.trim().length >= TEXT_LIMITS.MIN_SENTENCE_LENGTH && !loading + + const levelOptions = [ + {value: GRAMMAR_LEVELS.BEGINNER, label: isKorean ? '초급' : 'Beginner'}, + {value: GRAMMAR_LEVELS.INTERMEDIATE, label: isKorean ? '중급' : 'Intermediate'}, + {value: GRAMMAR_LEVELS.ADVANCED, label: isKorean ? '고급' : 'Advanced'}, + ] + + return ( + + + {/* Level Selector */} + + + + + + + {/* Text Input */} + setMessage(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={ + isKorean + ? '영어 문장을 입력하세요...' + : 'Type your English sentence...' + } + disabled={loading} + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: '16px', + backgroundColor: '#f9fafb', + '&:hover': { + backgroundColor: '#f3f4f6', + }, + '&.Mui-focused': { + backgroundColor: '#fff', + }, + '& fieldset': { + borderColor: '#e5e7eb', + }, + '&:hover fieldset': { + borderColor: '#d1d5db', + }, + '&.Mui-focused fieldset': { + borderColor: GRAMMAR_LEVEL_COLORS[level], + }, + }, + '& .MuiInputBase-input': { + py: 1.5, + px: 2, + '&::placeholder': { + color: '#9ca3af', + opacity: 1, + }, + }, + }} + /> + + {/* Send Button */} + + {loading ? ( + + ) : ( + + )} + + + + {/* Character count hint */} + + TEXT_LIMITS.MAX_SENTENCE_LENGTH + ? '#ef4444' + : '#9ca3af', + }} + > + {message.length > 0 && `${message.length}/${TEXT_LIMITS.MAX_SENTENCE_LENGTH}`} + + + + ) +} diff --git a/src/domains/grammar/components/ChatMessage.jsx b/src/domains/grammar/components/ChatMessage.jsx new file mode 100644 index 0000000..5703418 --- /dev/null +++ b/src/domains/grammar/components/ChatMessage.jsx @@ -0,0 +1,284 @@ +import {Box, Chip, Collapse, IconButton, keyframes, Typography} from '@mui/material' +import { + CheckCircle as CheckIcon, + Error as ErrorIcon, + ExpandMore as ExpandMoreIcon, + Person as PersonIcon, + SmartToy as AiIcon, +} from '@mui/icons-material' +import {useState} from 'react' +import {useSettings} from '../../../contexts/SettingsContext' +import {getScoreColor, GRAMMAR_ERROR_BG_COLORS, GRAMMAR_ERROR_COLORS,} from '../constants/grammarConstants' + +// 커서 깜빡임 애니메이션 +const blink = keyframes` + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0; } +` + +export default function ChatMessage({ + message, + isUser = false, + isStreaming = false, + streamingText = '', + }) { + const {t, isKorean} = useSettings() + const [showDetails, setShowDetails] = useState(false) + + const { + content, + correctedContent, + grammarScore, + errors = [], + aiResponse, + conversationTip, + } = message + + const hasErrors = errors && errors.length > 0 + const scoreColor = grammarScore ? getScoreColor(grammarScore) : '#059669' + + // 스트리밍 중일 때 표시할 텍스트 + const displayText = isStreaming ? streamingText : (aiResponse || content) + + if (isUser) { + return ( + + + {/* User Message Bubble */} + + + {content} + + + + {/* Grammar Score Badge */} + {grammarScore !== undefined && ( + + {grammarScore >= 90 ? ( + + ) : ( + + )} + + {grammarScore} + + {hasErrors && ( + setShowDetails(!showDetails)} + sx={{p: 0.25}} + > + + + )} + + )} + + {/* Correction Details */} + + + {/* Corrected Sentence */} + {correctedContent && correctedContent !== content && ( + + + {t('grammar.corrected')} + + + {correctedContent} + + + )} + + {/* Errors */} + + {errors.map((error, idx) => ( + + + + + + {error.original} + + {' → '} + + {error.corrected} + + + + + {error.explanation} + + + ))} + + + + + + {/* User Avatar */} + + + + + ) + } + + // AI Message + return ( + + {/* AI Avatar */} + + + + + + {/* AI Message Bubble */} + + + {displayText} + {/* 스트리밍 커서 */} + {isStreaming && ( + + )} + + + + {/* Conversation Tip - 스트리밍 완료 후에만 표시 */} + {!isStreaming && conversationTip && ( + + + 💡 {conversationTip} + + + )} + + + ) +} diff --git a/src/domains/grammar/components/GrammarInput.jsx b/src/domains/grammar/components/GrammarInput.jsx new file mode 100644 index 0000000..b87871b --- /dev/null +++ b/src/domains/grammar/components/GrammarInput.jsx @@ -0,0 +1,285 @@ +import {useState} from 'react' +import { + Box, + Button, + CircularProgress, + Paper, + TextField, + ToggleButton, + ToggleButtonGroup, + Typography, +} from '@mui/material' +import {School as SchoolIcon, Spellcheck as SpellcheckIcon,} from '@mui/icons-material' +import {useSettings} from '../../../contexts/SettingsContext' +import { + GRAMMAR_LEVEL_BG_COLORS, + GRAMMAR_LEVEL_COLORS, + GRAMMAR_LEVELS, + TEXT_LIMITS, +} from '../constants/grammarConstants' + +export default function GrammarInput({onCheck, loading = false}) { + const {t, isKorean} = useSettings() + const [sentence, setSentence] = useState('') + const [level, setLevel] = useState(GRAMMAR_LEVELS.BEGINNER) + const [error, setError] = useState('') + + const handleSentenceChange = (e) => { + const value = e.target.value + setSentence(value) + + if (value.length > TEXT_LIMITS.MAX_SENTENCE_LENGTH) { + setError(t('grammar.maxLength')) + } else { + setError('') + } + } + + const handleLevelChange = (_, newLevel) => { + if (newLevel) { + setLevel(newLevel) + } + } + + const handleCheck = () => { + if (sentence.trim().length < TEXT_LIMITS.MIN_SENTENCE_LENGTH) { + setError(t('grammar.minLength')) + return + } + + if (sentence.length > TEXT_LIMITS.MAX_SENTENCE_LENGTH) { + setError(t('grammar.maxLength')) + return + } + + setError('') + onCheck?.(sentence.trim(), level) + } + + const handleKeyDown = (e) => { + if (e.key === 'Enter' && !e.shiftKey && !loading) { + e.preventDefault() + handleCheck() + } + } + + const charCount = sentence.length + const isOverLimit = charCount > TEXT_LIMITS.MAX_SENTENCE_LENGTH + const canSubmit = + sentence.trim().length >= TEXT_LIMITS.MIN_SENTENCE_LENGTH && + !isOverLimit && + !loading + + const levelOptions = [ + { + value: GRAMMAR_LEVELS.BEGINNER, + label: t('grammar.beginner'), + desc: t('grammar.beginnerDesc'), + }, + { + value: GRAMMAR_LEVELS.INTERMEDIATE, + label: t('grammar.intermediate'), + desc: t('grammar.intermediateDesc'), + }, + { + value: GRAMMAR_LEVELS.ADVANCED, + label: t('grammar.advanced'), + desc: t('grammar.advancedDesc'), + }, + ] + + return ( + + {/* Level Selection */} + + + + + {t('grammar.selectLevel')} + + + + {levelOptions.map((option) => ( + + + + {option.label} + + + {option.desc} + + + + ))} + + + + {/* Text Input */} + + + + {/* Character Count */} + + + {charCount} / {TEXT_LIMITS.MAX_SENTENCE_LENGTH} + + + + + {/* Submit Button */} + + + ) +} diff --git a/src/domains/grammar/components/GrammarResult.jsx b/src/domains/grammar/components/GrammarResult.jsx new file mode 100644 index 0000000..9cfbddb --- /dev/null +++ b/src/domains/grammar/components/GrammarResult.jsx @@ -0,0 +1,407 @@ +import {Box, Chip, Collapse, Divider, IconButton, Paper, Typography} from '@mui/material' +import { + ArrowForward as ArrowForwardIcon, + CheckCircle as CheckCircleIcon, + Error as ErrorIcon, + ExpandMore as ExpandMoreIcon, + Lightbulb as LightbulbIcon, +} from '@mui/icons-material' +import {useState} from 'react' +import {useSettings} from '../../../contexts/SettingsContext' +import { + getScoreColor, + getScoreGrade, + GRAMMAR_ERROR_BG_COLORS, + GRAMMAR_ERROR_COLORS, +} from '../constants/grammarConstants' + +export default function GrammarResult({result}) { + const {t, isKorean} = useSettings() + const [expandedError, setExpandedError] = useState(null) + + if (!result) return null + + const { + originalSentence, + correctedSentence, + score, + isCorrect, + errors = [], + feedback, + } = result + + const scoreGrade = getScoreGrade(score) + const scoreColor = getScoreColor(score) + + const toggleError = (index) => { + setExpandedError(expandedError === index ? null : index) + } + + // 문장에서 오류 부분을 하이라이트하는 함수 + const renderHighlightedSentence = (sentence, sentenceErrors, isCorrected = false) => { + if (!sentenceErrors || sentenceErrors.length === 0) { + return {sentence} + } + + // 오류를 시작 인덱스 기준으로 정렬 + const sortedErrors = [...sentenceErrors].sort((a, b) => a.startIndex - b.startIndex) + const parts = [] + let lastIndex = 0 + + sortedErrors.forEach((error, idx) => { + // 오류 이전 텍스트 + if (error.startIndex > lastIndex) { + parts.push( + + {sentence.slice(lastIndex, error.startIndex)} + + ) + } + + // 오류 부분 하이라이트 + const highlightText = isCorrected ? error.corrected : error.original + parts.push( + + {highlightText} + + ) + + lastIndex = error.endIndex + }) + + // 마지막 오류 이후 텍스트 + if (lastIndex < sentence.length) { + parts.push( + {sentence.slice(lastIndex)} + ) + } + + return ( + + {parts} + + ) + } + + return ( + + {/* Score Header */} + + + + {isCorrect ? ( + + + + ) : ( + + + + )} + + + {isCorrect + ? t('grammar.perfect') + : isKorean + ? scoreGrade.labelKo + : scoreGrade.label} + + + {isCorrect + ? t('grammar.noProblem') + : `${errors.length} ${t('grammar.errorCount')}`} + + + + + {/* Score Circle */} + + + {score} + + + {t('grammar.score')} + + + + + + {/* Original vs Corrected */} + + {/* Original Sentence */} + + + {t('grammar.original')} + + + {renderHighlightedSentence(originalSentence, errors, false)} + + + + {/* Arrow */} + {!isCorrect && ( + + + + + + )} + + {/* Corrected Sentence */} + {!isCorrect && ( + + + {t('grammar.corrected')} + + + + {correctedSentence} + + + + )} + + {/* Errors List */} + {errors.length > 0 && ( + <> + + + {t('grammar.errors')} + + + {errors.map((error, index) => ( + + toggleError(index)} + sx={{ + p: 2, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + cursor: 'pointer', + backgroundColor: expandedError === index + ? GRAMMAR_ERROR_BG_COLORS[error.type] + : 'transparent', + '&:hover': { + backgroundColor: GRAMMAR_ERROR_BG_COLORS[error.type], + }, + }} + > + + + + + {error.original} + + + + {error.corrected} + + + + + + + + + + + {error.explanation} + + + + + ))} + + + )} + + {/* Feedback */} + {feedback && ( + <> + + + + + + + {t('grammar.feedback')} + + + {feedback} + + + + + + )} + + + ) +} diff --git a/src/domains/grammar/components/SessionSidebar.jsx b/src/domains/grammar/components/SessionSidebar.jsx new file mode 100644 index 0000000..5282ba6 --- /dev/null +++ b/src/domains/grammar/components/SessionSidebar.jsx @@ -0,0 +1,272 @@ +import {useState} from 'react' +import { + Box, + Button, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + List, + ListItem, + ListItemButton, + ListItemText, + Typography, +} 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 {GRAMMAR_LEVEL_BG_COLORS, GRAMMAR_LEVEL_COLORS} from '../constants/grammarConstants' + +export default function SessionSidebar({ + sessions = [], + currentSessionId, + onSelectSession, + onNewSession, + onDeleteSession, + loading = false, + }) { + const {t, isKorean} = useSettings() + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [sessionToDelete, setSessionToDelete] = useState(null) + + const handleDeleteClick = (session, e) => { + e.stopPropagation() + setSessionToDelete(session) + setDeleteDialogOpen(true) + } + + const handleConfirmDelete = () => { + if (sessionToDelete) { + onDeleteSession?.(sessionToDelete.sessionId) + } + setDeleteDialogOpen(false) + setSessionToDelete(null) + } + + const formatDate = (dateString) => { + const date = new Date(dateString) + const now = new Date() + const diffMs = now - date + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMs / 3600000) + const diffDays = Math.floor(diffMs / 86400000) + + if (diffMins < 1) return isKorean ? '방금 전' : 'Just now' + if (diffMins < 60) return isKorean ? `${diffMins}분 전` : `${diffMins}m ago` + if (diffHours < 24) return isKorean ? `${diffHours}시간 전` : `${diffHours}h ago` + if (diffDays < 7) return isKorean ? `${diffDays}일 전` : `${diffDays}d ago` + return date.toLocaleDateString() + } + + const getLevelLabel = (level) => { + if (!level) return '' + const labels = { + BEGINNER: isKorean ? '초급' : 'Beginner', + INTERMEDIATE: isKorean ? '중급' : 'Intermediate', + ADVANCED: isKorean ? '고급' : 'Advanced', + } + return labels[level] || level + } + + return ( + + {/* Header */} + + + + + {/* Sessions List */} + + + + + + {isKorean ? '대화 기록' : 'History'} + + + + + {loading ? ( + + + + ) : sessions.length === 0 ? ( + + + + {isKorean ? '대화 기록이 없습니다' : 'No conversations yet'} + + + ) : ( + + {sessions.map((session) => ( + handleDeleteClick(session, e)} + sx={{ + opacity: 0, + transition: 'opacity 0.2s', + '.MuiListItem-root:hover &': { + opacity: 1, + }, + }} + > + + + } + > + onSelectSession?.(session.sessionId)} + sx={{ + borderRadius: '10px', + py: 1.5, + '&.Mui-selected': { + backgroundColor: '#ecfdf5', + '&:hover': { + backgroundColor: '#d1fae5', + }, + }, + '&:hover': { + backgroundColor: '#f3f4f6', + }, + }} + > + + + {session.topic || (isKorean ? '대화' : 'Chat')} + + {session.level && ( + + )} + + } + secondary={ + + + {session.lastMessage || `${session.messageCount} ${isKorean ? '메시지' : 'messages'}`} + + + {formatDate(session.updatedAt)} + + + } + /> + + + ))} + + )} + + + {/* Delete Confirmation Dialog */} + setDeleteDialogOpen(false)} + PaperProps={{ + sx: {borderRadius: '16px'}, + }} + > + + {isKorean ? '대화 삭제' : 'Delete Conversation'} + + + + {isKorean + ? '이 대화를 삭제하시겠습니까? 삭제된 대화는 복구할 수 없습니다.' + : 'Are you sure you want to delete this conversation? This action cannot be undone.'} + + + + + + + + + ) +} diff --git a/src/domains/grammar/constants/grammarConstants.js b/src/domains/grammar/constants/grammarConstants.js new file mode 100644 index 0000000..11c650f --- /dev/null +++ b/src/domains/grammar/constants/grammarConstants.js @@ -0,0 +1,190 @@ +/** + * Grammar domain constants + * Based on Backend GrammarErrorType enum + */ + +// 문법 오류 타입 +export const GRAMMAR_ERROR_TYPES = { + VERB_TENSE: 'VERB_TENSE', + SUBJECT_VERB_AGREEMENT: 'SUBJECT_VERB_AGREEMENT', + ARTICLE: 'ARTICLE', + PREPOSITION: 'PREPOSITION', + WORD_ORDER: 'WORD_ORDER', + PLURAL_SINGULAR: 'PLURAL_SINGULAR', + PRONOUN: 'PRONOUN', + SPELLING: 'SPELLING', + PUNCTUATION: 'PUNCTUATION', + WORD_CHOICE: 'WORD_CHOICE', + SENTENCE_STRUCTURE: 'SENTENCE_STRUCTURE', + OTHER: 'OTHER', +} + +// 문법 오류 타입 라벨 (한국어) +export const GRAMMAR_ERROR_LABELS_KO = { + VERB_TENSE: '동사 시제', + SUBJECT_VERB_AGREEMENT: '주어-동사 일치', + ARTICLE: '관사', + PREPOSITION: '전치사', + WORD_ORDER: '어순', + PLURAL_SINGULAR: '단수/복수', + PRONOUN: '대명사', + SPELLING: '철자', + PUNCTUATION: '구두점', + WORD_CHOICE: '단어 선택', + SENTENCE_STRUCTURE: '문장 구조', + OTHER: '기타', +} + +// 문법 오류 타입 라벨 (영어) +export const GRAMMAR_ERROR_LABELS_EN = { + VERB_TENSE: 'Verb Tense', + SUBJECT_VERB_AGREEMENT: 'Subject-Verb Agreement', + ARTICLE: 'Article', + PREPOSITION: 'Preposition', + WORD_ORDER: 'Word Order', + PLURAL_SINGULAR: 'Singular/Plural', + PRONOUN: 'Pronoun', + SPELLING: 'Spelling', + PUNCTUATION: 'Punctuation', + WORD_CHOICE: 'Word Choice', + SENTENCE_STRUCTURE: 'Sentence Structure', + OTHER: 'Other', +} + +// 문법 오류 타입별 색상 +export const GRAMMAR_ERROR_COLORS = { + VERB_TENSE: '#ef4444', + SUBJECT_VERB_AGREEMENT: '#f97316', + ARTICLE: '#eab308', + PREPOSITION: '#22c55e', + WORD_ORDER: '#06b6d4', + PLURAL_SINGULAR: '#3b82f6', + PRONOUN: '#8b5cf6', + SPELLING: '#ec4899', + PUNCTUATION: '#6b7280', + WORD_CHOICE: '#14b8a6', + SENTENCE_STRUCTURE: '#f59e0b', + OTHER: '#9ca3af', +} + +// 문법 오류 타입별 배경 색상 (밝은 버전) +export const GRAMMAR_ERROR_BG_COLORS = { + VERB_TENSE: '#fef2f2', + SUBJECT_VERB_AGREEMENT: '#fff7ed', + ARTICLE: '#fefce8', + PREPOSITION: '#f0fdf4', + WORD_ORDER: '#ecfeff', + PLURAL_SINGULAR: '#eff6ff', + PRONOUN: '#f5f3ff', + SPELLING: '#fdf2f8', + PUNCTUATION: '#f9fafb', + WORD_CHOICE: '#f0fdfa', + SENTENCE_STRUCTURE: '#fffbeb', + OTHER: '#f3f4f6', +} + +// 학습 레벨 +export const GRAMMAR_LEVELS = { + BEGINNER: 'BEGINNER', + INTERMEDIATE: 'INTERMEDIATE', + ADVANCED: 'ADVANCED', +} + +// 학습 레벨 라벨 (한국어) +export const GRAMMAR_LEVEL_LABELS_KO = { + BEGINNER: '초급', + INTERMEDIATE: '중급', + ADVANCED: '고급', +} + +// 학습 레벨 라벨 (영어) +export const GRAMMAR_LEVEL_LABELS_EN = { + BEGINNER: 'Beginner', + INTERMEDIATE: 'Intermediate', + ADVANCED: 'Advanced', +} + +// 학습 레벨 설명 (한국어) +export const GRAMMAR_LEVEL_DESC_KO = { + BEGINNER: '한국어 번역 포함, 쉬운 설명', + INTERMEDIATE: '영어 설명, 일반적인 패턴 학습', + ADVANCED: '상세한 문법 규칙, 뉘앙스 학습', +} + +// 학습 레벨 설명 (영어) +export const GRAMMAR_LEVEL_DESC_EN = { + BEGINNER: 'Korean translations, simple explanations', + INTERMEDIATE: 'English explanations, common patterns', + ADVANCED: 'Detailed rules, nuanced usage', +} + +// 학습 레벨 색상 +export const GRAMMAR_LEVEL_COLORS = { + BEGINNER: '#059669', + INTERMEDIATE: '#f97316', + ADVANCED: '#ef4444', +} + +// 학습 레벨 배경 색상 +export const GRAMMAR_LEVEL_BG_COLORS = { + BEGINNER: '#ecfdf5', + INTERMEDIATE: '#fff7ed', + ADVANCED: '#fef2f2', +} + +// 점수 등급 +export const SCORE_GRADES = { + EXCELLENT: {min: 90, max: 100, label: 'Excellent', labelKo: '훌륭해요!'}, + GOOD: {min: 70, max: 89, label: 'Good', labelKo: '잘했어요!'}, + FAIR: {min: 50, max: 69, label: 'Fair', labelKo: '괜찮아요'}, + POOR: {min: 0, max: 49, label: 'Needs Practice', labelKo: '더 연습해요'}, +} + +// 점수에 따른 등급 반환 +export const getScoreGrade = (score) => { + if (score >= 90) return SCORE_GRADES.EXCELLENT + if (score >= 70) return SCORE_GRADES.GOOD + if (score >= 50) return SCORE_GRADES.FAIR + return SCORE_GRADES.POOR +} + +// 점수에 따른 색상 반환 +export const getScoreColor = (score) => { + if (score >= 90) return '#059669' + if (score >= 70) return '#f97316' + if (score >= 50) return '#eab308' + return '#ef4444' +} + +// 메시지 역할 +export const MESSAGE_ROLES = { + USER: 'USER', + ASSISTANT: 'ASSISTANT', +} + +// 텍스트 제한 +export const TEXT_LIMITS = { + MIN_SENTENCE_LENGTH: 3, + MAX_SENTENCE_LENGTH: 500, + MAX_MESSAGE_LENGTH: 1000, +} + +export default { + GRAMMAR_ERROR_TYPES, + GRAMMAR_ERROR_LABELS_KO, + GRAMMAR_ERROR_LABELS_EN, + GRAMMAR_ERROR_COLORS, + GRAMMAR_ERROR_BG_COLORS, + GRAMMAR_LEVELS, + GRAMMAR_LEVEL_LABELS_KO, + GRAMMAR_LEVEL_LABELS_EN, + GRAMMAR_LEVEL_DESC_KO, + GRAMMAR_LEVEL_DESC_EN, + GRAMMAR_LEVEL_COLORS, + GRAMMAR_LEVEL_BG_COLORS, + SCORE_GRADES, + getScoreGrade, + getScoreColor, + MESSAGE_ROLES, + TEXT_LIMITS, +} diff --git a/src/domains/grammar/hooks/useGrammarStream.js b/src/domains/grammar/hooks/useGrammarStream.js new file mode 100644 index 0000000..b33b73d --- /dev/null +++ b/src/domains/grammar/hooks/useGrammarStream.js @@ -0,0 +1,146 @@ +import {useCallback, useEffect, useRef, useState} from 'react' +import {grammarStreamService} from '../services/grammarStreamService' + +/** + * Grammar WebSocket 스트리밍 훅 + * 실시간 AI 응답을 위한 상태 관리 + */ +export function useGrammarStream() { + // 스트리밍 상태 + const [isStreaming, setIsStreaming] = useState(false) + const [isConnected, setIsConnected] = useState(false) + const [streamingText, setStreamingText] = useState('') + const [sessionId, setSessionId] = useState(null) + const [error, setError] = useState(null) + + // 최종 결과 + const [result, setResult] = useState(null) + + // 연결 상태 추적 + const isConnectedRef = useRef(false) + + /** + * WebSocket 연결 + */ + const connect = useCallback(async (token = null) => { + try { + setError(null) + await grammarStreamService.connect(token) + setIsConnected(true) + isConnectedRef.current = true + } catch (err) { + console.error('[useGrammarStream] Connection error:', err) + setError('연결에 실패했습니다') + setIsConnected(false) + isConnectedRef.current = false + } + }, []) + + /** + * 연결 종료 + */ + const disconnect = useCallback(() => { + grammarStreamService.disconnect() + setIsConnected(false) + isConnectedRef.current = false + }, []) + + /** + * 스트리밍 메시지 전송 + */ + const sendMessage = useCallback((message, options = {}) => { + // 상태 초기화 + setIsStreaming(true) + setStreamingText('') + setResult(null) + setError(null) + + // 콜백 설정 + grammarStreamService.setCallbacks({ + onStart: (data) => { + console.log('[useGrammarStream] Stream started:', data.sessionId) + setSessionId(data.sessionId) + }, + + onToken: (data) => { + setStreamingText((prev) => prev + data.token) + }, + + onComplete: (data) => { + console.log('[useGrammarStream] Stream complete') + setResult(data) + setIsStreaming(false) + }, + + onError: (data) => { + console.error('[useGrammarStream] Stream error:', data.message) + setError(data.message || '스트리밍 중 오류가 발생했습니다') + setIsStreaming(false) + }, + + onClose: (event) => { + if (event.code !== 1000) { + console.log('[useGrammarStream] Unexpected close:', event.code) + } + }, + }) + + // 메시지 전송 + grammarStreamService.send(message, options) + }, []) + + /** + * 스트리밍 취소 + */ + const cancelStream = useCallback(() => { + setIsStreaming(false) + setStreamingText('') + // 연결은 유지하고 스트리밍만 취소 + }, []) + + /** + * 상태 초기화 + */ + const reset = useCallback(() => { + setIsStreaming(false) + setStreamingText('') + setResult(null) + setError(null) + setSessionId(null) + }, []) + + /** + * 컴포넌트 언마운트 시 정리 + */ + useEffect(() => { + return () => { + if (isConnectedRef.current) { + grammarStreamService.disconnect() + } + } + }, []) + + return { + // 상태 + isStreaming, + isConnected, + streamingText, + sessionId, + result, + error, + + // 액션 + connect, + disconnect, + sendMessage, + cancelStream, + reset, + + // 편의 getter + grammarCheck: result?.grammarCheck || null, + aiResponse: result?.aiResponse || streamingText, + conversationTip: result?.conversationTip || null, + } +} + +export default useGrammarStream diff --git a/src/domains/grammar/index.js b/src/domains/grammar/index.js new file mode 100644 index 0000000..cafe8f3 --- /dev/null +++ b/src/domains/grammar/index.js @@ -0,0 +1,19 @@ +// Pages +export {default as WritingPage} from './pages/WritingPage' + +// Components +export {default as GrammarInput} from './components/GrammarInput' +export {default as GrammarResult} from './components/GrammarResult' +export {default as ChatMessage} from './components/ChatMessage' +export {default as ChatInput} from './components/ChatInput' +export {default as SessionSidebar} from './components/SessionSidebar' + +// Services +export * from './services/grammarService' +export * from './services/grammarStreamService' + +// Hooks +export {useGrammarStream} from './hooks/useGrammarStream' + +// Constants +export * from './constants/grammarConstants' diff --git a/src/domains/grammar/pages/WritingPage.jsx b/src/domains/grammar/pages/WritingPage.jsx new file mode 100644 index 0000000..1f479ef --- /dev/null +++ b/src/domains/grammar/pages/WritingPage.jsx @@ -0,0 +1,411 @@ +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 ChatMessage from '../components/ChatMessage' +import ChatInput from '../components/ChatInput' +import SessionSidebar from '../components/SessionSidebar' +import {sessionService} from '../services/grammarService' +import {useGrammarStream} from '../hooks/useGrammarStream' +import {GRAMMAR_LEVELS} from '../constants/grammarConstants' + +export default function WritingPage() { + const {t, isKorean} = useSettings() + const theme = useTheme() + const isMobile = useMediaQuery(theme.breakpoints.down('md')) + + const [sidebarOpen, setSidebarOpen] = useState(!isMobile) + const [sessions, setSessions] = useState([]) + const [currentSessionId, setCurrentSessionId] = useState(null) + const [messages, setMessages] = useState([]) + const [level, setLevel] = useState(GRAMMAR_LEVELS.BEGINNER) + const [loading, setLoading] = useState(false) + const [sessionsLoading, setSessionsLoading] = useState(true) + const [error, setError] = useState(null) + + // 스트리밍 상태 + const [streamingMessageId, setStreamingMessageId] = useState(null) + + const messagesEndRef = useRef(null) + + // WebSocket 스트리밍 훅 + const { + isStreaming, + streamingText, + result: streamResult, + error: streamError, + sendMessage: sendStreamMessage, + connect: connectStream, + grammarCheck: streamGrammarCheck, + conversationTip: streamConversationTip, + } = useGrammarStream() + + // 컴포넌트 마운트 시 WebSocket 연결 + useEffect(() => { + const token = localStorage.getItem('accessToken') + connectStream(token) + }, [connectStream]) + + // Load sessions on mount + useEffect(() => { + loadSessions() + }, []) + + // Scroll to bottom when messages change or streaming text updates + useEffect(() => { + messagesEndRef.current?.scrollIntoView({behavior: 'smooth'}) + }, [messages, streamingText]) + + // 스트리밍 완료 시 메시지 업데이트 + useEffect(() => { + if (streamResult && streamingMessageId) { + // 사용자 메시지에 문법 검사 결과 추가 + setMessages((prev) => + prev.map((msg) => { + if (msg.id === streamingMessageId) { + return { + ...msg, + correctedContent: streamResult.grammarCheck?.correctedSentence, + grammarScore: streamResult.grammarCheck?.score, + errors: streamResult.grammarCheck?.errors || [], + } + } + return msg + }) + ) + + // AI 응답 메시지 추가 + const aiMessage = { + id: `ai-${Date.now()}`, + content: streamResult.aiResponse, + aiResponse: streamResult.aiResponse, + conversationTip: streamResult.conversationTip, + isUser: false, + createdAt: new Date().toISOString(), + } + setMessages((prev) => [...prev, aiMessage]) + + // 세션 ID 업데이트 + if (!currentSessionId && streamResult.sessionId) { + setCurrentSessionId(streamResult.sessionId) + loadSessions() + } + + setStreamingMessageId(null) + setLoading(false) + } + }, [streamResult, streamingMessageId, currentSessionId]) + + // 스트리밍 에러 처리 + useEffect(() => { + if (streamError) { + setError(streamError) + setStreamingMessageId(null) + setLoading(false) + } + }, [streamError]) + + const loadSessions = async () => { + try { + setSessionsLoading(true) + const response = await sessionService.getList({limit: 20}) + setSessions(response.sessions || []) + } catch (err) { + console.error('Failed to load sessions:', err) + } finally { + setSessionsLoading(false) + } + } + + const loadSessionMessages = async (sessionId) => { + try { + setLoading(true) + const response = await sessionService.getDetail(sessionId) + 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, + })) + setMessages(formattedMessages) + } catch (err) { + console.error('Failed to load session messages:', err) + setError(isKorean ? '메시지를 불러오는데 실패했습니다' : 'Failed to load messages') + } finally { + setLoading(false) + } + } + + const handleSelectSession = (sessionId) => { + setCurrentSessionId(sessionId) + loadSessionMessages(sessionId) + if (isMobile) { + setSidebarOpen(false) + } + } + + const handleNewSession = () => { + setCurrentSessionId(null) + setMessages([]) + setError(null) + if (isMobile) { + setSidebarOpen(false) + } + } + + const handleDeleteSession = async (sessionId) => { + try { + await sessionService.delete(sessionId) + setSessions((prev) => prev.filter((s) => s.sessionId !== sessionId)) + if (currentSessionId === sessionId) { + handleNewSession() + } + } catch (err) { + console.error('Failed to delete session:', err) + } + } + + // 스트리밍 모드로 메시지 전송 + const handleSendMessage = useCallback(async (message) => { + try { + setLoading(true) + setError(null) + + // 사용자 메시지 즉시 추가 + const userMessageId = `user-${Date.now()}` + const userMessage = { + id: userMessageId, + content: message, + isUser: true, + createdAt: new Date().toISOString(), + } + setMessages((prev) => [...prev, userMessage]) + setStreamingMessageId(userMessageId) + + // WebSocket 스트리밍 시작 + sendStreamMessage(message, { + level, + sessionId: currentSessionId, + }) + } catch (err) { + console.error('Failed to send message:', err) + setError(isKorean ? '메시지 전송에 실패했습니다' : 'Failed to send message') + setMessages((prev) => prev.filter((msg) => !msg.id.startsWith('user-'))) + setStreamingMessageId(null) + setLoading(false) + } + }, [level, currentSessionId, sendStreamMessage, isKorean]) + + const sidebarContent = ( + + ) + + return ( + + {/* Sidebar - Desktop */} + {!isMobile && sidebarOpen && sidebarContent} + + {/* Sidebar - Mobile Drawer */} + {isMobile && ( + setSidebarOpen(false)} + PaperProps={{ + sx: {width: 280}, + }} + > + {sidebarContent} + + )} + + {/* Main Chat Area */} + + {/* Header */} + + setSidebarOpen(!sidebarOpen)} sx={{mr: 0.5}}> + + + + + + + + + + {t('grammar.title')} + + + {t('grammar.subtitle')} + + + + + {/* Messages Area */} + + {error && ( + + {error} + + )} + + {messages.length === 0 && !isStreaming ? ( + + + + + + {isKorean ? 'AI 문법 교정 도우미' : 'AI Grammar Assistant'} + + + {isKorean + ? '영어 문장을 입력하면 문법을 교정하고, 자연스러운 대화로 영어 실력을 향상시켜 드립니다.' + : 'Type an English sentence to get grammar corrections and improve your English through natural conversation.'} + + + + + {isKorean ? '예시 문장:' : 'Example sentences:'} + + + • "I go to school yesterday." + + + • "She have a beautiful car." + + + • "The informations is useful." + + + + ) : ( + <> + {messages.map((msg) => ( + + ))} + + {/* 스트리밍 중인 AI 응답 */} + {isStreaming && streamingText && ( + + )} + +
+ + )} + + + {/* Input Area */} + + + + ) +} diff --git a/src/domains/grammar/services/grammarService.js b/src/domains/grammar/services/grammarService.js new file mode 100644 index 0000000..30de7fa --- /dev/null +++ b/src/domains/grammar/services/grammarService.js @@ -0,0 +1,228 @@ +import grammarApi from '../../../api/grammarApi' + +// Mock 데이터 사용 여부 (true: 목 데이터 사용, false: 실제 API 호출) +const USE_MOCK = true + +// ============================================ +// Mock 데이터 +// ============================================ + +const mockGrammarCheckResponse = { + originalSentence: 'She go to the school yesterday.', + correctedSentence: 'She went to school yesterday.', + score: 60, + isCorrect: false, + errors: [ + { + type: 'VERB_TENSE', + original: 'go', + corrected: 'went', + explanation: '과거 시제에서는 "go"가 "went"로 변합니다.', + startIndex: 4, + endIndex: 6, + }, + { + type: 'ARTICLE', + original: 'the school', + corrected: 'school', + explanation: '일반적인 장소를 나타낼 때는 관사를 생략합니다.', + startIndex: 11, + endIndex: 21, + }, + ], + feedback: + '동사 시제와 관사 사용에 주의가 필요합니다. 과거의 일을 말할 때는 과거 시제를 사용하세요.', +} + +const mockConversationHistory = [ + { + messageId: 'msg-1', + role: 'USER', + content: 'Hello, how are you?', + correctedContent: 'Hello, how are you?', + grammarScore: 100, + createdAt: new Date(Date.now() - 300000).toISOString(), + }, + { + messageId: 'msg-2', + role: 'ASSISTANT', + content: "I'm doing great, thank you for asking! How about you?", + createdAt: new Date(Date.now() - 240000).toISOString(), + }, + { + messageId: 'msg-3', + role: 'USER', + content: 'I am fine. I go to work today.', + correctedContent: 'I am fine. I went to work today.', + grammarScore: 85, + errorsJson: JSON.stringify([ + { + type: 'VERB_TENSE', + original: 'go', + corrected: 'went', + explanation: '과거의 일을 말할 때는 과거 시제를 사용합니다.', + }, + ]), + createdAt: new Date(Date.now() - 180000).toISOString(), + }, + { + messageId: 'msg-4', + role: 'ASSISTANT', + content: "That's great! What kind of work do you do?", + createdAt: new Date(Date.now() - 120000).toISOString(), + }, +] + +const mockSessions = [ + { + sessionId: 'session-1', + userId: 'user1', + level: 'BEGINNER', + topic: 'Daily Conversation', + messageCount: 8, + lastMessage: 'Thank you for practicing with me!', + createdAt: new Date(Date.now() - 86400000).toISOString(), + updatedAt: new Date(Date.now() - 3600000).toISOString(), + }, + { + sessionId: 'session-2', + userId: 'user1', + level: 'INTERMEDIATE', + topic: 'Business English', + messageCount: 12, + lastMessage: 'I will schedule a meeting for next week.', + createdAt: new Date(Date.now() - 172800000).toISOString(), + updatedAt: new Date(Date.now() - 7200000).toISOString(), + }, + { + sessionId: 'session-3', + userId: 'user1', + level: 'BEGINNER', + topic: 'Travel Conversation', + messageCount: 6, + lastMessage: 'Where is the nearest subway station?', + createdAt: new Date(Date.now() - 259200000).toISOString(), + updatedAt: new Date(Date.now() - 86400000).toISOString(), + }, +] + +// ============================================ +// API with Mock fallback +// ============================================ + +const withMock = (apiCall, mockData) => { + if (USE_MOCK) { + return new Promise((resolve) => { + setTimeout(() => resolve(mockData), 800) + }) + } + return apiCall().catch(() => mockData) +} + +/** + * 문법 검사 API - Backend: POST /grammar/check + */ +export const grammarCheckService = { + // POST /grammar/check - 문법 검사 + check: (sentence, level = 'BEGINNER') => + withMock( + () => grammarApi.post('/grammar/check', {sentence, level}), + { + ...mockGrammarCheckResponse, + originalSentence: sentence, + correctedSentence: + sentence.length > 10 + ? sentence.replace(/go /g, 'went ').replace(/the school/g, 'school') + : sentence, + isCorrect: sentence.length <= 10, + score: sentence.length <= 10 ? 100 : Math.floor(Math.random() * 40) + 60, + errors: + sentence.length <= 10 + ? [] + : mockGrammarCheckResponse.errors.slice(0, Math.floor(Math.random() * 2) + 1), + } + ), +} + +/** + * 대화형 문법 교정 API - Backend: POST /grammar/conversation + */ +export const conversationService = { + // POST /grammar/conversation - AI 대화 + send: (message, sessionId = null, level = 'BEGINNER') => + withMock( + () => grammarApi.post('/grammar/conversation', {message, sessionId, level}), + { + sessionId: sessionId || `session-${Date.now()}`, + grammarCheck: { + originalSentence: message, + correctedSentence: message.replace(/go /g, 'went '), + score: Math.floor(Math.random() * 30) + 70, + isCorrect: !message.includes('go '), + errors: message.includes('go ') + ? [ + { + type: 'VERB_TENSE', + original: 'go', + corrected: 'went', + explanation: '과거 시제를 사용해야 합니다.', + startIndex: message.indexOf('go '), + endIndex: message.indexOf('go ') + 2, + }, + ] + : [], + feedback: message.includes('go ') + ? '동사 시제에 주의하세요!' + : '훌륭한 문장입니다!', + }, + aiResponse: + "That's interesting! Could you tell me more about it?", + conversationTip: + 'Try using more descriptive adjectives in your next sentence.', + } + ), +} + +/** + * 세션 관리 API - Backend: GET/DELETE /grammar/sessions + */ +export const sessionService = { + // GET /grammar/sessions - 세션 목록 조회 + getList: ({limit = 10, cursor} = {}) => + withMock( + () => grammarApi.get('/grammar/sessions', {params: {limit, cursor}}), + { + sessions: mockSessions.slice(0, limit), + nextCursor: null, + hasMore: false, + } + ), + + // GET /grammar/sessions/{sessionId} - 세션 상세 조회 + getDetail: (sessionId, {messageLimit = 50} = {}) => + withMock( + () => + grammarApi.get(`/grammar/sessions/${sessionId}`, { + params: {messageLimit}, + }), + { + session: + mockSessions.find((s) => s.sessionId === sessionId) || mockSessions[0], + messages: mockConversationHistory.slice(0, messageLimit), + } + ), + + // DELETE /grammar/sessions/{sessionId} - 세션 삭제 + delete: (sessionId) => + withMock(() => grammarApi.delete(`/grammar/sessions/${sessionId}`), { + code: 'SUCCESS', + message: 'Session deleted successfully', + }), +} + +// 단일 export로 모든 서비스 묶기 +export default { + grammarCheckService, + conversationService, + sessionService, +} diff --git a/src/domains/grammar/services/grammarStreamService.js b/src/domains/grammar/services/grammarStreamService.js new file mode 100644 index 0000000..0d311da --- /dev/null +++ b/src/domains/grammar/services/grammarStreamService.js @@ -0,0 +1,286 @@ +/** + * Grammar WebSocket Streaming Service + * 실시간 토큰 단위 AI 응답을 위한 WebSocket 서비스 + */ + +// WebSocket URL - 환경변수에서 가져오거나 기본값 사용 +const WS_URL = import.meta.env.VITE_GRAMMAR_WS_URL || + 'wss://placeholder.execute-api.ap-northeast-2.amazonaws.com/dev' + +// Mock 모드 (WebSocket 서버가 없을 때 테스트용) +const USE_MOCK = true +const MOCK_DELAY = 50 // 토큰 간 딜레이 (ms) + +/** + * Mock 스트리밍 응답 생성 + */ +const createMockStream = (message, callbacks) => { + const {onStart, onToken, onComplete, onError} = callbacks + + const sessionId = `session-${Date.now()}` + const mockAiResponse = "That's an interesting sentence! I noticed a few grammar points we can work on together. Keep practicing and you'll improve quickly!" + const mockGrammarCheck = { + originalSentence: message, + correctedSentence: message.replace(/go /g, 'went ').replace(/goed/g, 'went'), + score: Math.floor(Math.random() * 30) + 70, + isCorrect: !message.toLowerCase().includes('go '), + errors: message.toLowerCase().includes('go ') ? [ + { + type: 'VERB_TENSE', + original: 'go', + corrected: 'went', + explanation: '과거 시제를 사용해야 합니다. "go"의 과거형은 "went"입니다.', + startIndex: message.toLowerCase().indexOf('go '), + endIndex: message.toLowerCase().indexOf('go ') + 2, + } + ] : [], + feedback: message.toLowerCase().includes('go ') + ? '동사 시제에 주의하세요! 과거의 일을 말할 때는 과거 시제를 사용합니다.' + : '훌륭한 문장입니다! 문법적으로 정확해요.', + } + + // Start event + setTimeout(() => { + onStart?.({type: 'start', sessionId}) + }, 100) + + // Token events (simulate streaming) + const tokens = mockAiResponse.split(' ') + let tokenIndex = 0 + + const streamTokens = () => { + if (tokenIndex < tokens.length) { + const token = tokens[tokenIndex] + (tokenIndex < tokens.length - 1 ? ' ' : '') + onToken?.({type: 'token', token}) + tokenIndex++ + setTimeout(streamTokens, MOCK_DELAY + Math.random() * 30) + } else { + // Complete event + setTimeout(() => { + onComplete?.({ + type: 'complete', + sessionId, + grammarCheck: mockGrammarCheck, + aiResponse: mockAiResponse, + conversationTip: 'Try using more descriptive words in your sentences!', + }) + }, 100) + } + } + + setTimeout(streamTokens, 300) + + // Return mock close function + return { + close: () => { + }, + readyState: 1, + } +} + +/** + * Grammar Streaming 클래스 + */ +class GrammarStreamConnection { + constructor() { + this.ws = null + this.callbacks = {} + this.isConnected = false + this.reconnectAttempts = 0 + this.maxReconnectAttempts = 3 + } + + /** + * WebSocket 연결 + */ + connect(token) { + return new Promise((resolve, reject) => { + if (USE_MOCK) { + this.isConnected = true + resolve() + return + } + + try { + const url = token ? `${WS_URL}?token=${token}` : WS_URL + this.ws = new WebSocket(url) + + this.ws.onopen = () => { + this.isConnected = true + this.reconnectAttempts = 0 + console.log('[GrammarStream] Connected') + resolve() + } + + this.ws.onclose = (event) => { + this.isConnected = false + console.log('[GrammarStream] Disconnected:', event.code) + this.callbacks.onClose?.(event) + } + + this.ws.onerror = (error) => { + console.error('[GrammarStream] Error:', error) + this.callbacks.onError?.({type: 'error', message: 'WebSocket connection error'}) + reject(error) + } + + this.ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data) + this.handleMessage(data) + } catch (e) { + console.error('[GrammarStream] Parse error:', e) + } + } + } catch (error) { + reject(error) + } + }) + } + + /** + * 메시지 핸들링 + */ + handleMessage(data) { + switch (data.type) { + case 'start': + this.callbacks.onStart?.(data) + break + case 'token': + this.callbacks.onToken?.(data) + break + case 'complete': + this.callbacks.onComplete?.(data) + break + case 'error': + this.callbacks.onError?.(data) + break + default: + console.log('[GrammarStream] Unknown message type:', data.type) + } + } + + /** + * 스트리밍 요청 전송 + */ + send(message, options = {}) { + const {level = 'BEGINNER', sessionId = null} = options + + if (USE_MOCK) { + return createMockStream(message, this.callbacks) + } + + if (!this.isConnected || !this.ws) { + this.callbacks.onError?.({type: 'error', message: 'Not connected'}) + return + } + + const request = { + action: 'grammarStreaming', + message, + level, + ...(sessionId && {sessionId}), + } + + this.ws.send(JSON.stringify(request)) + } + + /** + * 콜백 설정 + */ + setCallbacks(callbacks) { + this.callbacks = callbacks + } + + /** + * 연결 종료 + */ + disconnect() { + if (this.ws) { + this.ws.close(1000, 'Client disconnect') + this.ws = null + } + this.isConnected = false + } + + /** + * 연결 상태 확인 + */ + getConnectionState() { + if (USE_MOCK) return 'connected' + if (!this.ws) return 'disconnected' + + switch (this.ws.readyState) { + case WebSocket.CONNECTING: + return 'connecting' + case WebSocket.OPEN: + return 'connected' + case WebSocket.CLOSING: + return 'closing' + case WebSocket.CLOSED: + return 'disconnected' + default: + return 'unknown' + } + } +} + +// 싱글톤 인스턴스 +let streamInstance = null + +/** + * Grammar Stream 서비스 + */ +export const grammarStreamService = { + /** + * 인스턴스 가져오기 (싱글톤) + */ + getInstance() { + if (!streamInstance) { + streamInstance = new GrammarStreamConnection() + } + return streamInstance + }, + + /** + * 연결 + */ + async connect(token) { + const instance = this.getInstance() + return instance.connect(token) + }, + + /** + * 스트리밍 요청 + */ + send(message, options = {}) { + const instance = this.getInstance() + return instance.send(message, options) + }, + + /** + * 콜백 설정 + */ + setCallbacks(callbacks) { + const instance = this.getInstance() + instance.setCallbacks(callbacks) + }, + + /** + * 연결 종료 + */ + disconnect() { + const instance = this.getInstance() + instance.disconnect() + }, + + /** + * 연결 상태 + */ + getConnectionState() { + const instance = this.getInstance() + return instance.getConnectionState() + }, +} + +export default grammarStreamService diff --git a/src/domains/vocab/components/FlashCard.jsx b/src/domains/vocab/components/FlashCard.jsx new file mode 100644 index 0000000..c671e1b --- /dev/null +++ b/src/domains/vocab/components/FlashCard.jsx @@ -0,0 +1,284 @@ +import {Box, Chip, IconButton, Typography} from '@mui/material' +import {TouchApp as TapIcon, VolumeUp as VolumeIcon} from '@mui/icons-material' +import {CATEGORY_LABELS, LEVEL_LABELS} from '../constants/vocabConstants' + +export default function FlashCard({word, isFlipped, onFlip, onPlayTTS, isPlayingTTS}) { + if (!word) return null + + const getLevelStyle = (level) => { + switch (level) { + case 'BEGINNER': + return {bg: '#ecfdf5', color: '#059669'} + case 'INTERMEDIATE': + return {bg: '#fff7ed', color: '#f97316'} + case 'ADVANCED': + return {bg: '#fef2f2', color: '#ef4444'} + default: + return {bg: '#f5f5f4', color: '#57534e'} + } + } + + const levelStyle = getLevelStyle(word.level) + + return ( + + + {/* Front - English */} + + {/* Decorative corner */} + + + {/* Level badge */} + + + {LEVEL_LABELS[word.level]} + + + + + {word.english} + + + { + e.stopPropagation() + onPlayTTS?.() + }} + disabled={isPlayingTTS} + sx={{ + width: 56, + height: 56, + backgroundColor: isPlayingTTS ? '#059669' : '#f5f5f4', + mb: 3, + transition: 'all 0.2s ease', + '&:hover': { + backgroundColor: isPlayingTTS ? '#047857' : '#e7e5e4', + transform: 'scale(1.05)', + }, + }} + > + + + + {word.example && ( + + "{word.example}" + + )} + + + + + Tap to reveal meaning + + + + + {/* Back - Korean */} + + {/* Decorative elements */} + + + + + MEANING + + + + {word.korean} + + + + + {word.category && ( + + )} + + + + + + Tap to see word + + + + + + ) +} diff --git a/src/domains/vocab/components/TestQuestion.jsx b/src/domains/vocab/components/TestQuestion.jsx new file mode 100644 index 0000000..e30095b --- /dev/null +++ b/src/domains/vocab/components/TestQuestion.jsx @@ -0,0 +1,250 @@ +import {Box, Paper, RadioGroup, Typography} from '@mui/material' + +export default function TestQuestion({ + question, + selectedAnswer, + onSelect, + showResult = false, + disabled = false, + }) { + if (!question) return null + + const getOptionStyle = (option, index) => { + const isSelected = selectedAnswer === option + const isCorrect = option === question.correctAnswer + + if (!showResult) { + return { + border: isSelected ? '2px solid #059669' : '2px solid #e7e5e4', + backgroundColor: isSelected ? '#ecfdf5' : '#ffffff', + transform: isSelected ? 'scale(1.02)' : 'scale(1)', + } + } + + // Result mode + if (isCorrect) { + return { + border: '2px solid #10b981', + backgroundColor: '#ecfdf5', + } + } + if (isSelected && !isCorrect) { + return { + border: '2px solid #ef4444', + backgroundColor: '#fef2f2', + } + } + return { + border: '2px solid #e7e5e4', + backgroundColor: '#fafaf9', + opacity: 0.5, + } + } + + const optionLabels = ['A', 'B', 'C', 'D'] + + return ( + + {/* Question Card */} + + {/* Decorative circles */} + + + + + WHAT DOES THIS MEAN? + + + + {question.english || question.question} + + + {question.example && ( + + "{question.example}" + + )} + + + {/* Options */} + !disabled && onSelect(e.target.value)} + > + + {question.options.map((option, index) => { + const isSelected = selectedAnswer === option + const isCorrect = showResult && option === question.correctAnswer + const isWrong = showResult && isSelected && !isCorrect + + return ( + !disabled && onSelect(option)} + sx={{ + p: 0, + cursor: disabled ? 'default' : 'pointer', + transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)', + borderRadius: '16px', + ...getOptionStyle(option, index), + '&:hover': disabled + ? {} + : { + borderColor: '#059669', + transform: 'translateX(4px)', + boxShadow: '0 4px 12px -4px rgba(5, 150, 105, 0.2)', + }, + }} + > + + {/* Option Label */} + + + {optionLabels[index]} + + + + {/* Option Text */} + + {option} + + + {/* Result Icon */} + {showResult && isCorrect && ( + + + + )} + {showResult && isWrong && ( + + + + )} + + + ) + })} + + + + ) +} diff --git a/src/domains/vocab/components/WordDetailModal.jsx b/src/domains/vocab/components/WordDetailModal.jsx new file mode 100644 index 0000000..4941aaa --- /dev/null +++ b/src/domains/vocab/components/WordDetailModal.jsx @@ -0,0 +1,462 @@ +import {useState} from 'react' +import { + Box, + Button, + Chip, + Dialog, + DialogContent, + Divider, + IconButton, + Paper, + ToggleButton, + ToggleButtonGroup, + Typography, +} from '@mui/material' +import { + Cancel as CancelIcon, + CheckCircle as CheckIcon, + Close as CloseIcon, + Favorite as FavoriteIcon, + FavoriteBorder as FavoriteBorderIcon, + Star as StarIcon, + StarBorder as StarBorderIcon, + VolumeUp as VolumeIcon, +} from '@mui/icons-material' +import { + CATEGORY_LABELS, + DIFFICULTY, + DIFFICULTY_LABELS, + LEVEL_LABELS, + VOICE_TYPES, + WORD_STATUS_LABELS, +} from '../constants/vocabConstants' +import {useTranslation} from '../../../contexts/SettingsContext' + +export default function WordDetailModal({ + open, + onClose, + word, + userWord, + onPlayTTS, + onToggleBookmark, + onToggleFavorite, + onSetDifficulty, + isPlayingTTS, + }) { + const {t} = useTranslation() + const [selectedVoice, setSelectedVoice] = useState(VOICE_TYPES.FEMALE) + + if (!word) return null + + const wordData = word + const userData = userWord || word + + const handlePlayTTS = () => { + onPlayTTS?.(selectedVoice) + } + + const getLevelStyle = (level) => { + switch (level) { + case 'BEGINNER': + return {bg: '#ecfdf5', color: '#059669'} + case 'INTERMEDIATE': + return {bg: '#fff7ed', color: '#f97316'} + case 'ADVANCED': + return {bg: '#fef2f2', color: '#ef4444'} + default: + return {bg: '#f5f5f4', color: '#57534e'} + } + } + + const getStatusStyle = (status) => { + switch (status) { + case 'MASTERED': + return {bg: '#ecfdf5', color: '#059669'} + case 'REVIEWING': + return {bg: '#eff6ff', color: '#3b82f6'} + case 'LEARNING': + return {bg: '#fff7ed', color: '#f97316'} + default: + return {bg: '#f5f5f4', color: '#57534e'} + } + } + + const levelStyle = getLevelStyle(wordData.level) + const statusStyle = getStatusStyle(userData?.status) + + const totalAttempts = (userData?.correctCount || 0) + (userData?.incorrectCount || 0) + const accuracy = totalAttempts > 0 + ? ((userData?.correctCount || 0) / totalAttempts * 100).toFixed(0) + : 0 + + return ( + + {/* 헤더 */} + + {/* 장식 요소 */} + + + + + + + {wordData.english} + + + {wordData.level && ( + + )} + {wordData.category && ( + + )} + + + + + + + + + + {/* 뜻 */} + + + {wordData.korean} + + {userData?.status && ( + + )} + + + {/* 예문 */} + {wordData.example && ( + + + {t('wordDetail.example')} + + + "{wordData.example}" + + + )} + + {/* TTS */} + + + {t('wordDetail.pronunciation')} + + + val && setSelectedVoice(val)} + size="small" + sx={{ + '& .MuiToggleButton-root': { + px: 2, + '&.Mui-selected': { + backgroundColor: '#059669', + color: 'white', + '&:hover': {backgroundColor: '#047857'}, + }, + }, + }} + > + {t('wordDetail.voiceFemale')} + {t('wordDetail.voiceMale')} + + + + + + {/* 학습 현황 */} + {userData && (userData.correctCount > 0 || userData.incorrectCount > 0) && ( + + + {t('wordDetail.learningStatus')} + + + + + + + + + {userData.correctCount || 0} + + {t('wordDetail.correctCount')} + + + + + + + {userData.incorrectCount || 0} + + {t('wordDetail.incorrectCount')} + + + = 80 ? '#ecfdf5' : accuracy >= 50 ? '#fff7ed' : '#fef2f2', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + mx: 'auto', + mb: 1, + }} + > + = 80 ? '#10b981' : accuracy >= 50 ? '#f97316' : '#ef4444', + }} + > + % + + + = 80 ? '#10b981' : accuracy >= 50 ? '#f97316' : '#ef4444', + }} + > + {accuracy} + + {t('wordDetail.accuracyLabel')} + + + + {userData.lastReviewedAt && ( + + {t('wordDetail.lastReviewed')} + + {new Date(userData.lastReviewedAt).toLocaleDateString('ko-KR')} + + + )} + {userData.nextReviewAt && ( + + {t('wordDetail.nextReview')} + + {new Date(userData.nextReviewAt).toLocaleDateString('ko-KR')} + + + )} + + + )} + + + + {/* 액션 */} + + + + {userData?.bookmarked ? ( + + ) : ( + + )} + + + {userData?.favorite ? ( + + ) : ( + + )} + + + + + + {t('wordDetail.difficulty')} + + val && onSetDifficulty?.(val)} + size="small" + sx={{ + '& .MuiToggleButton-root': { + px: 1.5, + fontSize: 12, + '&.Mui-selected': { + backgroundColor: '#059669', + color: 'white', + '&:hover': {backgroundColor: '#047857'}, + }, + }, + }} + > + {Object.entries(DIFFICULTY_LABELS).map(([key, label]) => ( + + {label} + + ))} + + + + + + ) +} diff --git a/src/domains/vocab/components/WordListItem.jsx b/src/domains/vocab/components/WordListItem.jsx new file mode 100644 index 0000000..ed44cc7 --- /dev/null +++ b/src/domains/vocab/components/WordListItem.jsx @@ -0,0 +1,106 @@ +import {Box, Chip, IconButton, ListItem, ListItemText, Tooltip, Typography,} from '@mui/material' +import {Star as StarIcon, StarBorder as StarBorderIcon, VolumeUp as VolumeIcon,} from '@mui/icons-material' +import { + CATEGORY_LABELS, + LEVEL_COLORS, + LEVEL_LABELS, + WORD_STATUS_COLORS, + WORD_STATUS_LABELS, +} from '../constants/vocabConstants' + +export default function WordListItem({ + word, + userWord, + onPlayTTS, + onToggleBookmark, + onClick, + isPlayingTTS, + }) { + const status = userWord?.status + const bookmarked = userWord?.bookmarked + + return ( + + + + {word.english} + + + + + } + secondary={ + + + {word.korean} + + {status && ( + + )} + + } + /> + + + + { + e.stopPropagation() + onPlayTTS?.() + }} + disabled={isPlayingTTS} + > + + + + + { + e.stopPropagation() + onToggleBookmark?.() + }} + > + {bookmarked ? ( + + ) : ( + + )} + + + + + ) +} diff --git a/src/domains/vocab/constants/vocabConstants.js b/src/domains/vocab/constants/vocabConstants.js new file mode 100644 index 0000000..8f52a5c --- /dev/null +++ b/src/domains/vocab/constants/vocabConstants.js @@ -0,0 +1,95 @@ +// 학습 레벨 +export const LEVELS = { + BEGINNER: 'BEGINNER', + INTERMEDIATE: 'INTERMEDIATE', + ADVANCED: 'ADVANCED', +} + +export const LEVEL_LABELS = { + [LEVELS.BEGINNER]: '초급', + [LEVELS.INTERMEDIATE]: '중급', + [LEVELS.ADVANCED]: '고급', +} + +export const LEVEL_COLORS = { + [LEVELS.BEGINNER]: 'success', + [LEVELS.INTERMEDIATE]: 'warning', + [LEVELS.ADVANCED]: 'error', +} + +// 카테고리 +export const CATEGORIES = { + DAILY: 'DAILY', + BUSINESS: 'BUSINESS', + ACADEMIC: 'ACADEMIC', +} + +export const CATEGORY_LABELS = { + [CATEGORIES.DAILY]: '일상', + [CATEGORIES.BUSINESS]: '비즈니스', + [CATEGORIES.ACADEMIC]: '학술', +} + +// 학습 상태 +export const WORD_STATUS = { + NEW: 'NEW', + LEARNING: 'LEARNING', + REVIEWING: 'REVIEWING', + MASTERED: 'MASTERED', +} + +export const WORD_STATUS_LABELS = { + [WORD_STATUS.NEW]: '새 단어', + [WORD_STATUS.LEARNING]: '학습 중', + [WORD_STATUS.REVIEWING]: '복습 중', + [WORD_STATUS.MASTERED]: '암기 완료', +} + +export const WORD_STATUS_COLORS = { + [WORD_STATUS.NEW]: 'default', + [WORD_STATUS.LEARNING]: 'warning', + [WORD_STATUS.REVIEWING]: 'info', + [WORD_STATUS.MASTERED]: 'success', +} + +// 난이도 +export const DIFFICULTY = { + EASY: 'EASY', + NORMAL: 'NORMAL', + HARD: 'HARD', +} + +export const DIFFICULTY_LABELS = { + [DIFFICULTY.EASY]: '쉬움', + [DIFFICULTY.NORMAL]: '보통', + [DIFFICULTY.HARD]: '어려움', +} + +// 시험 유형 +export const TEST_TYPES = { + KOREAN_TO_ENGLISH: 'KOREAN_TO_ENGLISH', + ENGLISH_TO_KOREAN: 'ENGLISH_TO_KOREAN', +} + +export const TEST_TYPE_LABELS = { + [TEST_TYPES.KOREAN_TO_ENGLISH]: '한국어 → 영어', + [TEST_TYPES.ENGLISH_TO_KOREAN]: '영어 → 한국어', +} + +// TTS 음성 +export const VOICE_TYPES = { + MALE: 'MALE', + FEMALE: 'FEMALE', +} + +export const VOICE_LABELS = { + [VOICE_TYPES.MALE]: '남성', + [VOICE_TYPES.FEMALE]: '여성', +} + +// 일일 학습 목표 +export const DAILY_GOAL = { + NEW_WORDS: 50, + REVIEW_WORDS: 5, + TOTAL: 55, +} diff --git a/src/domains/vocab/index.js b/src/domains/vocab/index.js new file mode 100644 index 0000000..05c63ec --- /dev/null +++ b/src/domains/vocab/index.js @@ -0,0 +1,5 @@ +// Services +export * from './services/vocabService' + +// Constants +export * from './constants/vocabConstants' diff --git a/src/domains/vocab/pages/DailyLearning.jsx b/src/domains/vocab/pages/DailyLearning.jsx new file mode 100644 index 0000000..dd2b4d9 --- /dev/null +++ b/src/domains/vocab/pages/DailyLearning.jsx @@ -0,0 +1,630 @@ +import {useCallback, useEffect, useState} from 'react' +import {useNavigate} from 'react-router-dom' +import { + Alert, + Box, + Button, + Card, + CardActionArea, + CardContent, + CircularProgress, + Container, + FormControlLabel, + IconButton, + LinearProgress, + Paper, + Switch, + Tooltip, + Typography, +} from '@mui/material' +import { + ArrowBack as BackIcon, + AutoAwesome as SparkleIcon, + Celebration as CelebrationIcon, + Check as CheckIcon, + Close as CloseIcon, + School as SchoolIcon, + SkipNext as SkipIcon, + Star as StarIcon, + StarBorder as StarBorderIcon, + VolumeUp as VolumeIcon, +} from '@mui/icons-material' +import FlashCard from '../components/FlashCard' +import {dailyService, userWordService, voiceService} from '../services/vocabService' +import {LEVEL_LABELS, LEVELS} from '../constants/vocabConstants' +import {useTranslation} from '../../../contexts/SettingsContext' + +const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1' + +// Level Selection Screen +function LevelSelect({onSelect, loading, t, isKorean}) { + const levelConfig = { + [LEVELS.BEGINNER]: { + description: isKorean ? '기초 어휘 학습' : 'Basic vocabulary', + color: '#059669', + bgColor: '#ecfdf5', + icon: '🌱', + }, + [LEVELS.INTERMEDIATE]: { + description: isKorean ? '실용 어휘 확장' : 'Intermediate vocabulary', + color: '#f97316', + bgColor: '#fff7ed', + icon: '🌿', + }, + [LEVELS.ADVANCED]: { + description: isKorean ? '전문 어휘 마스터' : 'Advanced vocabulary', + color: '#ef4444', + bgColor: '#fef2f2', + icon: '🌳', + }, + } + + return ( + + + + + + + {t('dailyLearning.selectLevel')} + + + {isKorean ? '난이도를 선택하여 학습을 시작하세요' : 'Select a difficulty to start learning'} + + + + + {Object.entries(LEVEL_LABELS).map(([level, label]) => { + const config = levelConfig[level] + return ( + + !loading && onSelect(level)} disabled={loading}> + + + + {config.icon} + + + + {label} + + + {config.description} + + + {loading && } + + + + + ) + })} + + + ) +} + +export default function DailyLearning() { + const navigate = useNavigate() + const {t, isKorean} = useTranslation() + const [phase, setPhase] = useState('loading') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [words, setWords] = useState([]) + const [currentIndex, setCurrentIndex] = useState(0) + const [isFlipped, setIsFlipped] = useState(false) + const [learnedIds, setLearnedIds] = useState(new Set()) + const [autoPlayTTS, setAutoPlayTTS] = useState(false) + const [isPlayingTTS, setIsPlayingTTS] = useState(false) + const [results, setResults] = useState({correct: 0, incorrect: 0}) + const [swipeDirection, setSwipeDirection] = useState(null) + const [isEntering, setIsEntering] = useState(false) + + useEffect(() => { + fetchDailyWords() + }, []) + + const fetchDailyWords = async (level = null) => { + try { + setLoading(true) + setError(null) + + const response = await dailyService.getWords(TEMP_USER_ID, level) + const dailyData = response?.data || response + + const allWords = [ + ...(dailyData?.newWords || []), + ...(dailyData?.reviewWords || []), + ] + + if (allWords.length === 0) { + setError('No words to learn.') + setPhase('select') + return + } + + setWords(allWords) + + const learnedCount = dailyData?.learnedCount || 0 + if (learnedCount > 0 && learnedCount < allWords.length) { + const learned = new Set(allWords.slice(0, learnedCount).map(w => w.wordId)) + setLearnedIds(learned) + setCurrentIndex(learnedCount) + } + + if (dailyData?.isCompleted) { + setPhase('complete') + } else { + setPhase('learning') + } + } catch (err) { + console.error('Fetch daily words error:', err) + const errorMsg = err.response?.data?.message || err.message || '' + + if (errorMsg.includes('level') || err.response?.status === 400) { + setPhase('select') + } else { + setError('Failed to load words.') + setPhase('select') + } + } finally { + setLoading(false) + } + } + + const handleLevelSelect = (level) => { + fetchDailyWords(level) + } + + const currentWord = words[currentIndex] + const progress = words.length > 0 ? (learnedIds.size / words.length) * 100 : 0 + + const playTTS = useCallback(async (word) => { + if (!word || isPlayingTTS) return + try { + setIsPlayingTTS(true) + const response = await voiceService.synthesize(word.wordId, word.english) + if (response?.audioUrl) { + const audio = new Audio(response.audioUrl) + audio.onended = () => setIsPlayingTTS(false) + audio.onerror = () => setIsPlayingTTS(false) + await audio.play() + } else { + setIsPlayingTTS(false) + } + } catch (err) { + console.error('TTS error:', err) + setIsPlayingTTS(false) + } + }, [isPlayingTTS]) + + useEffect(() => { + if (autoPlayTTS && currentWord && !isFlipped && phase === 'learning') { + playTTS(currentWord) + } + }, [currentIndex, autoPlayTTS, phase]) + + const handleFlip = () => { + setIsFlipped(!isFlipped) + } + + const handleAnswer = async (isCorrect) => { + if (!currentWord || swipeDirection) return + + 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) + } + + setTimeout(() => { + setSwipeDirection(null) + setIsEntering(true) + moveToNext() + setTimeout(() => setIsEntering(false), 200) + }, 250) + } + + const moveToNext = () => { + setIsFlipped(false) + if (currentIndex < words.length - 1) { + setCurrentIndex(prev => prev + 1) + } else { + setPhase('complete') + } + } + + const handleToggleBookmark = async () => { + if (!currentWord) return + try { + const newBookmarked = !currentWord.bookmarked + await userWordService.updateTag(TEMP_USER_ID, currentWord.wordId, { + bookmarked: newBookmarked, + }) + setWords(prev => + prev.map(w => + w.wordId === currentWord.wordId ? {...w, bookmarked: newBookmarked} : w + ) + ) + } catch (err) { + console.error('Bookmark error:', err) + } + } + + const handleRestart = () => { + setCurrentIndex(0) + setLearnedIds(new Set()) + setIsFlipped(false) + setPhase('learning') + setResults({correct: 0, incorrect: 0}) + } + + // Loading Screen + if (phase === 'loading') { + return ( + + + + + + ) + } + + // Level Selection Screen + if (phase === 'select') { + return ( + + + navigate('/vocab')}> + + + + {t('dailyLearning.title')} + + + + {error && ( + setError(null)}> + {error} + + )} + + + + ) + } + + // Completion Screen + if (phase === 'complete') { + const totalAnswered = results.correct + results.incorrect + const accuracy = totalAnswered > 0 ? (results.correct / totalAnswered) * 100 : 0 + + return ( + + + + + + + + {t('dailyLearning.greatJob')} + + + {t('dailyLearning.completedSession')} + + + + + + + {results.correct} + + + {t('dailyLearning.correct')} + + + + + {results.incorrect} + + + {t('dailyLearning.incorrect')} + + + + = 80 ? '#ecfdf5' : accuracy >= 50 ? '#fff7ed' : '#fef2f2', + }} + > + = 80 ? '#059669' : accuracy >= 50 ? '#f97316' : '#ef4444'}}/> + = 80 ? '#059669' : accuracy >= 50 ? '#f97316' : '#ef4444'}} + > + {accuracy.toFixed(0)}% {t('dailyLearning.accuracy')} + + + + + + + + + + + ) + } + + // Learning Screen + return ( + + {/* 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={} + /> + + + {/* Progress Bar */} + + + + {t('dailyLearning.progress')} + + + {Math.round(progress)}% + + + + + + {/* FlashCard */} + + playTTS(currentWord)} + isPlayingTTS={isPlayingTTS} + /> + + + {/* Answer Buttons */} + + + + + + {/* Navigation */} + + + + + + {currentWord?.bookmarked ? ( + + ) : ( + + )} + + + + + + + ) +} diff --git a/src/domains/vocab/pages/StatsPage.jsx b/src/domains/vocab/pages/StatsPage.jsx new file mode 100644 index 0000000..0c6ed67 --- /dev/null +++ b/src/domains/vocab/pages/StatsPage.jsx @@ -0,0 +1,518 @@ +import {useEffect, useState} from 'react' +import {useNavigate} from 'react-router-dom' +import { + Alert, + Box, + Chip, + CircularProgress, + Container, + Grid, + IconButton, + LinearProgress, + List, + ListItem, + ListItemText, + Paper, + Tab, + Tabs, + Tooltip, + Typography, +} from '@mui/material' +import { + ArrowBack as BackIcon, + CalendarMonth as CalendarIcon, + TrendingUp as TrendingUpIcon, + 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 {useTranslation} from '../../../contexts/SettingsContext' +import {BadgeSection} from '../../badge' + +const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1' + +// 학습 캘린더 히트맵 컴포넌트 +function LearningCalendar({data}) { + const today = new Date() + const startDate = new Date(today) + startDate.setDate(startDate.getDate() - 83) // 12주 전 + + const weeks = [] + let currentDate = new Date(startDate) + + // 12주 데이터 생성 + for (let w = 0; w < 12; w++) { + const week = [] + for (let d = 0; d < 7; d++) { + const dateStr = currentDate.toISOString().split('T')[0] + const dayData = data?.find(d => d.date === dateStr) + week.push({ + date: dateStr, + count: dayData?.learnedCount || 0, + isToday: dateStr === today.toISOString().split('T')[0], + }) + currentDate.setDate(currentDate.getDate() + 1) + } + weeks.push(week) + } + + const getColor = (count) => { + if (count === 0) return '#ebedf0' + if (count < 20) return '#9be9a8' + if (count < 40) return '#40c463' + if (count < 55) return '#30a14e' + return '#216e39' + } + + const dayLabels = ['일', '월', '화', '수', '목', '금', '토'] + + return ( + + + {/* 요일 라벨 */} + + {dayLabels.map((label, idx) => ( + + {idx % 2 === 1 ? label : ''} + + ))} + + + {/* 히트맵 그리드 */} + {weeks.map((week, wIdx) => ( + + {week.map((day, dIdx) => ( + + + + ))} + + ))} + + + {/* 범례 */} + + 적음 + {[0, 10, 30, 45, 55].map((count, idx) => ( + + ))} + 많음 + + + ) +} + +// 취약 단어 목록 컴포넌트 +function WeakWordsList({words, onPlayTTS, playingWordId}) { + if (!words || words.length === 0) { + return ( + + 취약 단어가 없습니다 + + ) + } + + return ( + + {words.map((item, index) => ( + + + + {item.english} + + + + } + secondary={item.korean} + /> + onPlayTTS?.(item)} + disabled={playingWordId === item.wordId} + > + + + + ))} + + ) +} + +// 레벨별 진행률 차트 +function LevelProgressChart({data}) { + if (!data) return null + + 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 + + return ( + + + + {label} + + + {levelData.learned}/{levelData.total} + + + + + ) + })} + + ) +} + +// 난이도 분포 차트 +function DifficultyChart({data}) { + if (!data) return null + + const total = Object.values(data).reduce((sum, val) => sum + val, 0) + + const colors = { + EASY: '#4caf50', + NORMAL: '#2196f3', + HARD: '#ff9800', + } + + return ( + + {/* 막대 그래프 */} + + {Object.entries(DIFFICULTY_LABELS).map(([key, label]) => { + const count = data[key] || 0 + const height = total > 0 ? (count / total) * 100 : 0 + + return ( + + + {count} + + + + {label} + + + ) + })} + + + ) +} + +// 통계 요약 카드 +function StatCard({title, value, subtitle, icon: Icon, color}) { + return ( + + + + + {title} + + + {value} + + {subtitle && ( + + {subtitle} + + )} + + {Icon && ( + + + + )} + + + ) +} + +export default function StatsPage() { + const navigate = useNavigate() + const {t} = useTranslation() + const [tab, setTab] = useState(0) // 0: 일간, 1: 주간, 2: 월간 + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // 통계 데이터 + const [overviewStats, setOverviewStats] = useState(null) + const [calendarData, setCalendarData] = useState([]) + const [weakWords, setWeakWords] = useState([]) + const [levelProgress, setLevelProgress] = useState(null) + const [difficultyDist, setDifficultyDist] = useState(null) + + // TTS + const [playingWordId, setPlayingWordId] = useState(null) + + useEffect(() => { + 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), + ]) + + setOverviewStats(overviewRes?.data) + setCalendarData(dailyRes?.data?.dailyStats || []) + setWeakWords(weakRes?.data?.weakWords || []) + setLevelProgress(overviewRes?.data?.levelProgress) + setDifficultyDist(overviewRes?.data?.difficultyDistribution) + } catch (err) { + console.error('Fetch stats error:', err) + setError('통계를 불러오는데 실패했습니다.') + } finally { + setLoading(false) + } + } + + 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 + + try { + setPlayingWordId(word.wordId) + const response = await voiceService.synthesize({ + text: word.english, + voiceType: VOICE_TYPES.FEMALE, + }) + + if (response?.audioUrl) { + const audio = new Audio(response.audioUrl) + audio.onended = () => setPlayingWordId(null) + audio.onerror = () => setPlayingWordId(null) + await audio.play() + } else { + setPlayingWordId(null) + } + } catch (err) { + console.error('TTS error:', err) + setPlayingWordId(null) + } + } + + if (loading) { + return ( + + + + + + ) + } + + return ( + + {/* 헤더 */} + + navigate('/vocab')}> + + + + {t('stats.title')} + + + + {error && ( + setError(null)}> + {error} + + )} + + {/* 기간 탭 */} + setTab(v)} + sx={{mb: 3}} + variant="fullWidth" + > + + + + + + {/* 요약 카드 */} + + + + + + + + + + + + + + + + {/* 학습 캘린더 */} + + + {t('stats.learningHistory')} + + + + + {/* 레벨별 진행률 */} + + + {t('stats.levelProgress')} + + + + + {/* 난이도 분포 */} + + + {t('stats.difficultyDist')} + + + + + {/* 취약 단어 */} + + + + {t('stats.weakWordsTop10')} + + navigate('/vocab/daily?mode=weak')} + /> + + + + + {/* 배지 섹션 */} + + + ) +} diff --git a/src/domains/vocab/pages/TestPage.jsx b/src/domains/vocab/pages/TestPage.jsx new file mode 100644 index 0000000..329a9d6 --- /dev/null +++ b/src/domains/vocab/pages/TestPage.jsx @@ -0,0 +1,621 @@ +import {useEffect, useState} from 'react' +import {useNavigate} from 'react-router-dom' +import { + Alert, + Box, + Button, + Card, + CardContent, + CircularProgress, + Container, + IconButton, + LinearProgress, + Paper, + Typography, +} from '@mui/material' +import { + ArrowBack as BackIcon, + AutoAwesome as SparkleIcon, + EmojiEvents as TrophyIcon, + NavigateBefore as PrevIcon, + NavigateNext as NextIcon, + PlayArrow as PlayIcon, + Quiz as QuizIcon, + Timer as TimerIcon, +} from '@mui/icons-material' +import TestQuestion from '../components/TestQuestion' +import {testService} from '../services/vocabService' +import {useTranslation} from '../../../contexts/SettingsContext' + +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}) { + return ( + + + {/* Decorative elements */} + + + + + + + + + {t('test.title')} + + + {t('test.subtitle')} + + + + + + {/* Recent Results */} + {recentResults.length > 0 && ( + + + {t('test.recentResults')} + + + {recentResults.map((result, index) => ( + + + + + + {result.completedAt ? new Date(result.completedAt).toLocaleDateString() : '-'} + + + {result.totalQuestions || 0} {t('test.question')} + + + = 80 + ? '#ecfdf5' + : result.successRate >= 60 + ? '#fff7ed' + : '#fef2f2', + }} + > + = 80 + ? '#059669' + : result.successRate >= 60 + ? '#f97316' + : '#ef4444', + }} + > + {result.successRate?.toFixed(0) || 0}% + + + + + + ))} + + + )} + + ) +} + +// Test In Progress Screen +function TestInProgress({ + questions, + currentIndex, + answers, + timeRemaining, + onAnswer, + onNext, + onPrev, + onSubmit, + t, + }) { + const currentQuestion = questions[currentIndex] + const progress = ((currentIndex + 1) / questions.length) * 100 + const timerProgress = (timeRemaining / QUESTION_TIME_LIMIT) * 100 + + if (!currentQuestion) return null + + return ( + + {/* Header */} + + + {currentIndex + 1} / {questions.length} + + + + + {timeRemaining} + + + + + {/* Timer Progress */} + + + {/* Overall Progress */} + + + {/* Question */} + onAnswer(currentQuestion.wordId, answer)} + /> + + {/* Navigation */} + + + + {currentIndex === questions.length - 1 ? ( + + ) : ( + + )} + + + {/* Question Indicators */} + + {questions.map((q, idx) => ( + + {answers[q.wordId] ? '✓' : idx + 1} + + ))} + + + ) +} + +// Result Screen +function TestResult({result, onRetry, onHome, t}) { + const score = result.successRate || 0 + const isGreat = score >= 80 + const isGood = score >= 60 + + return ( + + + + + + + {score.toFixed(0)}{t('test.score')} + + + {result.totalQuestions || 0} {t('test.question')} / {result.correctCount || 0} {t('test.correct')} + + + + + + + {result.correctCount || 0} + + + {t('test.correct')} + + + + + {result.incorrectCount || 0} + + + {t('test.incorrect')} + + + + + + + + {isGreat ? t('test.excellent') : isGood ? t('test.good') : t('test.needPractice')} + + + + + {/* Wrong Answers */} + {result.results?.filter((r) => !r.isCorrect).length > 0 && ( + + + {t('test.reviewWrong')} + + + {result.results + .filter((r) => !r.isCorrect) + .map((r, idx) => ( + + + {r.english} + + + + + {t('test.myAnswer')} + + + {r.userAnswer || t('test.noAnswer')} + + + + + {t('test.correctAnswer')} + + + {r.correctAnswer} + + + + + ))} + + + )} + + + + + + + ) +} + +export default function TestPage() { + const navigate = useNavigate() + const {t} = useTranslation() + const [phase, setPhase] = useState('setup') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [recentResults, setRecentResults] = useState([]) + + const [testId, setTestId] = useState(null) + const [questions, setQuestions] = useState([]) + const [currentIndex, setCurrentIndex] = useState(0) + const [answers, setAnswers] = useState({}) + const [timeRemaining, setTimeRemaining] = useState(0) + const [result, setResult] = useState(null) + + useEffect(() => { + fetchRecentResults() + }, []) + + useEffect(() => { + if (phase !== 'testing' || questions.length === 0) return + + setTimeRemaining(QUESTION_TIME_LIMIT) + + const timer = setInterval(() => { + setTimeRemaining((prev) => { + if (prev <= 1) { + if (currentIndex < questions.length - 1) { + setCurrentIndex((idx) => idx + 1) + } else { + handleSubmit() + } + return QUESTION_TIME_LIMIT + } + return prev - 1 + }) + }, 1000) + + return () => clearInterval(timer) + }, [phase, currentIndex, questions.length]) + + const fetchRecentResults = async () => { + try { + const response = await testService.getResults(TEMP_USER_ID, {limit: 5}) + setRecentResults(response?.testResults || []) + } catch (err) { + console.error('Fetch results error:', err) + } + } + + const handleStart = async () => { + try { + setLoading(true) + setError(null) + const response = await testService.start(TEMP_USER_ID, 'DAILY') + + const testData = response?.data || response + if (testData?.testId) { + setTestId(testData.testId) + setQuestions(testData.questions || []) + setTimeRemaining(QUESTION_TIME_LIMIT) + setAnswers({}) + setCurrentIndex(0) + setPhase('testing') + } else { + setError('시험 데이터를 불러오지 못했습니다.') + } + } catch (err) { + console.error('Start test error:', err) + const errorMsg = err.response?.data?.message || '시험을 시작할 수 없습니다.' + setError(errorMsg) + } finally { + setLoading(false) + } + } + + const handleAnswer = (wordId, answer) => { + setAnswers((prev) => ({...prev, [wordId]: answer})) + } + + const handleNext = () => { + if (currentIndex < questions.length - 1) { + setCurrentIndex((prev) => prev + 1) + } + } + + const handlePrev = () => { + if (currentIndex > 0) { + setCurrentIndex((prev) => prev - 1) + } + } + + const handleSubmit = async () => { + try { + setLoading(true) + const answersArray = questions.map((q) => ({ + wordId: q.wordId, + answer: answers[q.wordId] || '', + })) + + const response = await testService.submit(TEMP_USER_ID, testId, answersArray) + + const resultData = response?.data || response + if (resultData) { + setResult(resultData) + setPhase('result') + } + } catch (err) { + console.error('Submit error:', err) + setError('제출에 실패했습니다.') + } finally { + setLoading(false) + } + } + + const handleRetry = () => { + setPhase('setup') + setTestId(null) + setQuestions([]) + setAnswers({}) + setResult(null) + fetchRecentResults() + } + + return ( + + {/* Header */} + + navigate('/vocab')}> + + + + {t('test.title')} + + + + {error && ( + setError(null)}> + {error} + + )} + + {phase === 'setup' && + } + + {phase === 'testing' && questions.length > 0 && ( + + )} + + {phase === 'result' && result && ( + navigate('/vocab')} t={t}/> + )} + + ) +} diff --git a/src/domains/vocab/pages/VocabDashboard.jsx b/src/domains/vocab/pages/VocabDashboard.jsx new file mode 100644 index 0000000..7667110 --- /dev/null +++ b/src/domains/vocab/pages/VocabDashboard.jsx @@ -0,0 +1,659 @@ +import {useEffect, useState} from 'react' +import {useNavigate} from 'react-router-dom' +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Container, + Grid, + IconButton, + LinearProgress, + Tooltip, + Typography, +} from '@mui/material' +import { + CheckCircle as CheckIcon, + EmojiEvents as TrophyIcon, + LocalFireDepartment as FireIcon, + MenuBook as VocabIcon, + PlayArrow as PlayIcon, + Quiz as TestIcon, + Star as StarIcon, + StarBorder as StarBorderIcon, + TrendingUp as TrendingIcon, + VolumeUp as VolumeIcon, +} from '@mui/icons-material' +import {dailyService, statsService, userWordService, voiceService} from '../services/vocabService' +import {DAILY_GOAL, LEVEL_LABELS,} from '../constants/vocabConstants' +import {useTranslation} from '../../../contexts/SettingsContext' + +const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1' + +export default function VocabDashboard() { + const navigate = useNavigate() + const {t, isKorean} = useTranslation() + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [dailyData, setDailyData] = useState(null) + const [statsData, setStatsData] = useState(null) + const [weeklyStats, setWeeklyStats] = useState([]) + const [weakWords, setWeakWords] = useState([]) + const [playingTTS, setPlayingTTS] = useState(null) + + useEffect(() => { + fetchDashboardData() + }, []) + + const fetchDashboardData = async () => { + try { + setLoading(true) + 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), + ]) + + setDailyData(daily) + setStatsData(stats) + setWeeklyStats(weekly?.dailyStats || []) + setWeakWords(weakness?.weakestWords?.slice(0, 5) || []) + } catch (err) { + console.error('Dashboard fetch error:', err) + setError('Failed to load data.') + } finally { + setLoading(false) + } + } + + const handlePlayTTS = async (word) => { + try { + setPlayingTTS(word.wordId) + const response = await voiceService.synthesize(word.wordId, word.english) + if (response?.audioUrl) { + const audio = new Audio(response.audioUrl) + audio.onended = () => setPlayingTTS(null) + audio.onerror = () => setPlayingTTS(null) + await audio.play() + } + } catch (err) { + console.error('TTS error:', err) + setPlayingTTS(null) + } + } + + const handleToggleBookmark = async (word) => { + try { + await userWordService.updateTag(TEMP_USER_ID, word.wordId, { + bookmarked: !word.bookmarked, + }) + setWeakWords((prev) => + prev.map((w) => + w.wordId === word.wordId ? {...w, bookmarked: !w.bookmarked} : w + ) + ) + } catch (err) { + console.error('Bookmark error:', err) + } + } + + if (loading) { + return ( + + + + + + ) + } + + 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 + + // Calculate streak from weekly stats + const streak = weeklyStats.filter(s => s?.isCompleted).length + + return ( + + {/* Header */} + + + + + + + + {t('vocabDash.title')} + + + {t('vocabDash.subtitle')} + + + + + + {error && ( + + {error} + + )} + + {/* Hero Progress Card */} + + {/* Decorative Elements */} + + + + + + + + {t('vocabDash.todayProgress')} + + + {Math.round(progress)}% + + + + {streak > 0 && ( + + + + + {streak} + + + {t('vocabDash.days')} + + + + )} + + + + + + {learnedCount} / {totalWords} {t('vocabDash.wordsLearned')} + + + + + + + + + {isKorean ? '새 단어' : 'New Words'} + + + {newWordsCount} / {DAILY_GOAL.NEW_WORDS} + + + + + {isKorean ? '복습' : 'Review'} + + + {reviewWordsCount} / {DAILY_GOAL.REVIEW_WORDS} + + + + + + + + + {/* Quick Actions */} + + + navigate('/vocab/stats')} + > + + + + + + {t('vocabDash.viewStats')} + + + {statsData?.totalWords || 0} + + + {t('vocabDash.wordsLearned')} + + + + + + + + navigate('/vocab/test')} + > + + + + + + {t('vocabDash.takeQuiz')} + + + {statsData?.avgSuccessRate?.toFixed(0) || 0}% + + + {isKorean ? '평균 점수' : 'average score'} + + + + + + + + navigate('/vocab/words')} + > + + + + + + {t('vocabDash.viewWordList')} + + + {statsData?.wordStatusCounts?.MASTERED || 0} + + + {isKorean ? '마스터' : 'mastered'} + + + + + + + + {/* 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) + + return ( + + + {day} + + + {isCompleted ? ( + + + + ) : hasProgress ? ( + + + {stat?.learnedCount} + + + ) : ( + + )} + + + ) + })} + + + + + {/* Weak Words */} + {weakWords.length > 0 && ( + + + + + {t('vocabDash.focusWords')} + + + + + {isKorean ? '추가 연습이 필요한 단어입니다' : 'These words need extra attention'} + + + {weakWords.map((word, index) => ( + + + + + {word.english} + + + + + {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 new file mode 100644 index 0000000..e9c82c8 --- /dev/null +++ b/src/domains/vocab/pages/WordListPage.jsx @@ -0,0 +1,504 @@ +import {useCallback, useEffect, useRef, useState} from 'react' +import {useNavigate, useSearchParams} from 'react-router-dom' +import { + Alert, + Box, + Chip, + CircularProgress, + Container, + IconButton, + InputAdornment, + Paper, + TextField, + ToggleButton, + ToggleButtonGroup, + Typography, +} from '@mui/material' +import { + ArrowBack as BackIcon, + Clear as ClearIcon, + ErrorOutline as ErrorIcon, + LibraryBooks as WordListIcon, + Search as SearchIcon, + Star as StarIcon, + StarBorder as StarBorderIcon, + VolumeUp as VolumeIcon, +} from '@mui/icons-material' +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' + +const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1' +const PAGE_SIZE = 20 + +// 디바운스 훅 +function useDebounce(value, delay) { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay) + return () => clearTimeout(timer) + }, [value, delay]) + + return debouncedValue +} + +export default function WordListPage() { + const navigate = useNavigate() + const {t} = useTranslation() + const [searchParams] = useSearchParams() + const observerRef = useRef(null) + const loadMoreRef = useRef(null) + + const initialFilter = searchParams.get('filter') + + const [filterMode, setFilterMode] = useState(initialFilter || 'all') + const [searchText, setSearchText] = useState('') + + const [userWords, setUserWords] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [hasMore, setHasMore] = useState(true) + const [cursor, setCursor] = useState(null) + + const [selectedWord, setSelectedWord] = useState(null) + const [modalOpen, setModalOpen] = useState(false) + + const [playingWordId, setPlayingWordId] = useState(null) + + const debouncedSearch = useDebounce(searchText, 300) + + const fetchUserWords = useCallback(async (reset = false) => { + if (loading) return + + try { + setLoading(true) + setError(null) + + const params = { + limit: PAGE_SIZE, + cursor: reset ? undefined : cursor, + } + + if (filterMode === 'bookmarked') { + params.bookmarked = true + } else if (filterMode === 'incorrect') { + params.incorrectOnly = true + } + + const response = await myWordService.getList(TEMP_USER_ID, params) + const data = response?.data || response + const newWords = data?.userWords || [] + + setUserWords(prev => reset ? newWords : [...prev, ...newWords]) + setHasMore(data?.hasMore || false) + setCursor(data?.nextCursor || null) + } catch (err) { + console.error('Fetch user words error:', err) + setError('단어 목록을 불러오는데 실패했습니다.') + } finally { + setLoading(false) + } + }, [loading, cursor, filterMode]) + + useEffect(() => { + setUserWords([]) + setCursor(null) + setHasMore(true) + fetchUserWords(true) + }, [filterMode]) + + useEffect(() => { + if (loading || !hasMore) return + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasMore && !loading) { + fetchUserWords(false) + } + }, + {threshold: 0.1} + ) + + if (loadMoreRef.current) { + observer.observe(loadMoreRef.current) + } + + observerRef.current = observer + + return () => { + if (observerRef.current) { + observerRef.current.disconnect() + } + } + }, [hasMore, loading, fetchUserWords]) + + const filteredWords = userWords.filter(word => { + if (!debouncedSearch) return true + const search = debouncedSearch.toLowerCase() + return ( + word.english?.toLowerCase().includes(search) || + word.korean?.toLowerCase().includes(search) + ) + }) + + const handlePlayTTS = async (word) => { + if (playingWordId) return + + try { + setPlayingWordId(word.wordId) + const response = await voiceService.synthesize(word.wordId, word.english) + + if (response?.audioUrl) { + const audio = new Audio(response.audioUrl) + audio.onended = () => setPlayingWordId(null) + audio.onerror = () => setPlayingWordId(null) + await audio.play() + } else { + setPlayingWordId(null) + } + } catch (err) { + console.error('TTS error:', err) + setPlayingWordId(null) + } + } + + const handleToggleBookmark = async (word) => { + const newBookmarked = !word.bookmarked + + try { + await myWordService.toggleBookmark(TEMP_USER_ID, word.wordId, newBookmarked) + + setUserWords(prev => + prev.map(w => + w.wordId === word.wordId ? {...w, bookmarked: newBookmarked} : w + ) + ) + + if (filterMode === 'bookmarked' && !newBookmarked) { + setUserWords(prev => prev.filter(w => w.wordId !== word.wordId)) + } + } catch (err) { + console.error('Bookmark toggle error:', err) + } + } + + const handleWordClick = (word) => { + setSelectedWord(word) + setModalOpen(true) + } + + const handleClearSearch = () => { + setSearchText('') + } + + const getLevelStyle = (level) => { + switch (level) { + case 'BEGINNER': + return {bg: '#ecfdf5', color: '#059669'} + case 'INTERMEDIATE': + return {bg: '#fff7ed', color: '#f97316'} + case 'ADVANCED': + return {bg: '#fef2f2', color: '#ef4444'} + default: + return {bg: '#f5f5f4', color: '#57534e'} + } + } + + const getStatusStyle = (status) => { + switch (status) { + case 'MASTERED': + return {bg: '#ecfdf5', color: '#059669'} + case 'REVIEWING': + return {bg: '#eff6ff', color: '#3b82f6'} + case 'LEARNING': + return {bg: '#fff7ed', color: '#f97316'} + default: + return {bg: '#f5f5f4', color: '#57534e'} + } + } + + return ( + + {/* 헤더 */} + + + navigate('/vocab')} sx={{ml: -1}}> + + + + + + + + {t('wordList.title')} + + + {filteredWords.length} {t('wordList.wordsCount')} + + + + + + {/* 검색 */} + setSearchText(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: searchText && ( + + + + + + ), + }} + sx={{ + mb: 3, + '& .MuiOutlinedInput-root': { + backgroundColor: 'white', + }, + }} + /> + + {/* 필터 탭 */} + + val && setFilterMode(val)} + size="small" + fullWidth + sx={{ + '& .MuiToggleButton-root': { + flex: 1, + py: 1.5, + fontWeight: 600, + '&.Mui-selected': { + backgroundColor: '#059669', + color: 'white', + '&:hover': { + backgroundColor: '#047857', + }, + }, + }, + }} + > + + {t('wordList.filterAll')} + + + + {t('wordList.filterBookmarked')} + + + + {t('wordList.filterIncorrect')} + + + + + {error && ( + setError(null)}> + {error} + + )} + + {/* 단어 목록 */} + + {filteredWords.map((word) => { + const levelStyle = getLevelStyle(word.level) + const statusStyle = getStatusStyle(word.status) + + return ( + handleWordClick(word)} + sx={{ + p: 2.5, + cursor: 'pointer', + border: '2px solid transparent', + borderRadius: '16px', + transition: 'all 0.2s ease', + '&:hover': { + borderColor: '#059669', + boxShadow: '0 4px 12px -4px rgba(5, 150, 105, 0.2)', + }, + }} + > + + + + + {word.english} + + {word.level && ( + + )} + {word.status && ( + + )} + + + + {word.korean} + + + {(word.correctCount > 0 || word.incorrectCount > 0) && ( + + + {t('wordList.correct')} {word.correctCount || 0} + + + {t('wordList.incorrect')} {word.incorrectCount || 0} + + + )} + + + + { + e.stopPropagation() + handlePlayTTS(word) + }} + disabled={playingWordId === word.wordId} + sx={{ + width: 40, + height: 40, + backgroundColor: playingWordId === word.wordId ? '#059669' : '#f5f5f4', + '&:hover': {backgroundColor: playingWordId === word.wordId ? '#047857' : '#e7e5e4'}, + }} + > + + + { + e.stopPropagation() + handleToggleBookmark(word) + }} + sx={{ + width: 40, + height: 40, + backgroundColor: word.bookmarked ? '#fef3c7' : '#f5f5f4', + '&:hover': {backgroundColor: word.bookmarked ? '#fde68a' : '#e7e5e4'}, + }} + > + {word.bookmarked ? ( + + ) : ( + + )} + + + + + ) + })} + + + {/* 로딩 & 더보기 트리거 */} + + {loading && } + {!loading && !hasMore && filteredWords.length > 0 && ( + + {t('wordList.loadedAll')} + + )} + {!loading && filteredWords.length === 0 && !error && ( + + + + + + {filterMode === 'bookmarked' + ? t('wordList.noBookmarks') + : filterMode === 'incorrect' + ? t('wordList.noIncorrect') + : t('wordList.noWords')} + + navigate('/vocab/daily')} + sx={{ + mt: 1, + backgroundColor: '#059669', + color: 'white', + fontWeight: 600, + '&:hover': {backgroundColor: '#047857'}, + }} + /> + + )} + + + {/* 상세 모달 */} + setModalOpen(false)} + word={selectedWord} + userWord={selectedWord} + onPlayTTS={() => selectedWord && handlePlayTTS(selectedWord)} + onToggleBookmark={() => selectedWord && handleToggleBookmark(selectedWord)} + isPlayingTTS={playingWordId === selectedWord?.wordId} + /> + + ) +} diff --git a/src/domains/vocab/services/vocabService.js b/src/domains/vocab/services/vocabService.js new file mode 100644 index 0000000..613b1d7 --- /dev/null +++ b/src/domains/vocab/services/vocabService.js @@ -0,0 +1,540 @@ +import vocabApi from '../../../api/vocabApi' + +// Mock 데이터 사용 여부 (true: 목 데이터 사용, false: 실제 API 호출) +const USE_MOCK = true + +// ============================================ +// Mock 데이터 +// ============================================ + +const mockWords = [ + { + wordId: 'w1', + english: 'apple', + korean: '사과', + level: 'BEGINNER', + category: 'DAILY', + example: 'I eat an apple every day.' + }, + { + wordId: 'w2', + english: 'beautiful', + korean: '아름다운', + level: 'BEGINNER', + category: 'DAILY', + example: 'The sunset is beautiful.' + }, + { + wordId: 'w3', + english: 'computer', + korean: '컴퓨터', + level: 'BEGINNER', + category: 'DAILY', + example: 'I use a computer for work.' + }, + { + wordId: 'w4', + english: 'delicious', + korean: '맛있는', + level: 'BEGINNER', + category: 'DAILY', + example: 'This pizza is delicious.' + }, + { + wordId: 'w5', + english: 'environment', + korean: '환경', + level: 'INTERMEDIATE', + category: 'ACADEMIC', + example: 'We must protect the environment.' + }, + { + wordId: 'w6', + english: 'fundamental', + korean: '기본적인', + level: 'INTERMEDIATE', + category: 'ACADEMIC', + example: 'This is a fundamental concept.' + }, + { + wordId: 'w7', + english: 'generate', + korean: '생성하다', + level: 'INTERMEDIATE', + category: 'BUSINESS', + example: 'The company generates revenue.' + }, + { + wordId: 'w8', + english: 'hypothesis', + korean: '가설', + level: 'ADVANCED', + category: 'ACADEMIC', + example: 'We need to test this hypothesis.' + }, + { + wordId: 'w9', + english: 'implement', + korean: '구현하다', + level: 'INTERMEDIATE', + category: 'BUSINESS', + example: 'We will implement the new system.' + }, + { + wordId: 'w10', + english: 'jurisdiction', + korean: '관할권', + level: 'ADVANCED', + category: 'BUSINESS', + example: 'This falls under federal jurisdiction.' + }, + { + wordId: 'w11', + english: 'knowledge', + korean: '지식', + level: 'BEGINNER', + category: 'DAILY', + example: 'Knowledge is power.' + }, + { + wordId: 'w12', + english: 'legitimate', + korean: '합법적인', + level: 'ADVANCED', + category: 'BUSINESS', + example: 'Is this a legitimate business?' + }, + { + wordId: 'w13', + english: 'magnificent', + korean: '웅장한', + level: 'INTERMEDIATE', + category: 'DAILY', + example: 'The castle is magnificent.' + }, + { + wordId: 'w14', + english: 'negotiate', + korean: '협상하다', + level: 'INTERMEDIATE', + category: 'BUSINESS', + example: 'They will negotiate the contract.' + }, + { + wordId: 'w15', + english: 'opportunity', + korean: '기회', + level: 'BEGINNER', + category: 'DAILY', + example: 'This is a great opportunity.' + }, + { + wordId: 'w16', + english: 'perseverance', + korean: '인내', + level: 'ADVANCED', + category: 'DAILY', + example: 'Success requires perseverance.' + }, + { + wordId: 'w17', + english: 'question', + korean: '질문', + level: 'BEGINNER', + category: 'DAILY', + example: 'Do you have any questions?' + }, + { + wordId: 'w18', + english: 'responsibility', + korean: '책임', + level: 'INTERMEDIATE', + category: 'BUSINESS', + example: 'Take responsibility for your actions.' + }, + { + wordId: 'w19', + english: 'sophisticated', + korean: '정교한', + level: 'ADVANCED', + category: 'ACADEMIC', + example: 'This is a sophisticated algorithm.' + }, + { + wordId: 'w20', + english: 'technology', + korean: '기술', + level: 'BEGINNER', + category: 'DAILY', + example: 'Technology is advancing rapidly.' + }, +] + +const mockUserWords = mockWords.map((word, idx) => ({ + ...word, + status: idx < 5 ? 'MASTERED' : idx < 12 ? 'REVIEWING' : idx < 17 ? 'LEARNING' : 'NEW', + correctCount: Math.floor(Math.random() * 10) + 1, + incorrectCount: Math.floor(Math.random() * 5), + bookmarked: idx % 4 === 0, + favorite: idx % 5 === 0, + difficulty: ['EASY', 'NORMAL', 'HARD'][idx % 3], + lastReviewedAt: new Date(Date.now() - idx * 86400000).toISOString(), + nextReviewAt: new Date(Date.now() + (idx + 1) * 86400000).toISOString(), +})) + +const generateDailyStats = () => { + const stats = [] + for (let i = 0; i < 84; i++) { + const date = new Date() + date.setDate(date.getDate() - i) + stats.push({ + date: date.toISOString().split('T')[0], + learnedCount: Math.random() > 0.3 ? Math.floor(Math.random() * 55) + 5 : 0, + wordsStudied: Math.floor(Math.random() * 30) + 5, + successRate: Math.floor(Math.random() * 40) + 60, + correctCount: Math.floor(Math.random() * 40) + 10, + incorrectCount: Math.floor(Math.random() * 15), + }) + } + return stats +} + +const mockTestResults = [ + { + testId: 't1', + testType: 'DAILY', + totalQuestions: 20, + correctAnswers: 18, + successRate: 90, + completedAt: new Date(Date.now() - 86400000).toISOString() + }, + { + testId: 't2', + testType: 'DAILY', + totalQuestions: 20, + correctAnswers: 15, + successRate: 75, + completedAt: new Date(Date.now() - 172800000).toISOString() + }, + { + testId: 't3', + testType: 'DAILY', + totalQuestions: 20, + correctAnswers: 12, + successRate: 60, + completedAt: new Date(Date.now() - 259200000).toISOString() + }, +] + +// ============================================ +// API with Mock fallback +// ============================================ + +const withMock = (apiCall, mockData) => { + if (USE_MOCK) { + // interceptor가 response.data를 반환하므로 mockData를 직접 반환 + return Promise.resolve(mockData) + } + return apiCall().catch(() => mockData) +} + +/** + * 단어 관리 API - Backend: GET /words, GET /words/search + */ +export const wordService = { + // GET /words - 단어 목록 조회 + getList: ({level, category, limit = 20, cursor} = {}) => + withMock( + () => vocabApi.get('/words', {params: {level, category, limit, cursor}}), + { + words: mockWords.filter(w => (!level || w.level === level) && (!category || w.category === category)).slice(0, limit), + hasMore: false, + nextCursor: null, + } + ), + + // GET /words - 단어 목록 조회 (별칭) + getWords: (params) => + withMock( + () => vocabApi.get('/words', {params}), + {words: mockWords, hasMore: false} + ), + + // GET /words/search - 단어 검색 + search: ({q, limit = 20, cursor} = {}) => + withMock( + () => vocabApi.get('/words/search', {params: {q, limit, cursor}}), + { + words: mockWords.filter(w => + w.english.toLowerCase().includes(q?.toLowerCase() || '') || + w.korean.includes(q || '') + ).slice(0, limit), + query: q, + hasMore: false, + } + ), + + // GET /words/{wordId} - 단어 상세 조회 (백엔드 문서에 없지만 필요시) + getDetail: (wordId) => + withMock( + () => vocabApi.get(`/words/${wordId}`), + mockWords.find(w => w.wordId === wordId) || mockWords[0] + ), + + // POST /words/batch - 배치 단어 생성 + createBatch: (words) => + withMock( + () => vocabApi.post('/words/batch', {words}), + {successCount: words.length, failCount: 0, totalRequested: words.length} + ), + + // POST /words/batch/get - 배치 단어 조회 + getBatch: (wordIds) => + withMock( + () => vocabApi.post('/words/batch/get', {wordIds}), + { + words: mockWords.filter(w => wordIds.includes(w.wordId)), + requestedCount: wordIds.length, + retrievedCount: wordIds.length, + } + ), +} + +/** + * 일일 학습 API - Backend: POST /daily-study/record, GET /user-words/review + */ +export const dailyService = { + // 일일 학습용 단어 조회 (새 단어 + 복습 단어) + getWords: (userId, level) => + withMock( + () => vocabApi.get('/user-words/review', {params: {userId, ...(level ? {level} : {})}}), + { + newWords: mockWords.filter(w => !level || w.level === level).slice(0, 10), + reviewWords: mockUserWords.filter(w => w.status === 'REVIEWING').slice(0, 5), + learnedCount: 0, + isCompleted: false, + } + ), + + // POST /daily-study/record - 일일 학습 기록 + markLearned: (userId, wordId, isCorrect, studyType = 'REVIEW') => + withMock( + () => vocabApi.post('/daily-study/record', {userId, wordId, isCorrect, studyType}), + { + userId, + date: new Date().toISOString().split('T')[0], + wordsStudied: 1, + correctCount: isCorrect ? 1 : 0, + incorrectCount: isCorrect ? 0 : 1, + } + ), +} + +/** + * 사용자 단어 학습 상태 API - Backend: POST /user-words/{wordId}/review, PATCH /user-words/{wordId}/tag + */ +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}}), + { + userWords: mockUserWords.filter(w => !status || w.status === status).slice(0, limit), + hasMore: false, + nextCursor: null, + } + ), + + // GET /user-words/review - 사용자 단어 조회 (별칭) + getUserWords: (userId, params) => + withMock( + () => vocabApi.get('/user-words/review', {params: {userId, ...params}}), + {words: mockUserWords, hasMore: false} + ), + + // POST /user-words/{wordId}/review - 사용자 단어 학습 업데이트 + update: (userId, wordId, isCorrect) => + withMock( + () => vocabApi.post(`/user-words/${wordId}/review`, {userId, isCorrect}), + { + userId, + wordId, + status: isCorrect ? 'REVIEWING' : 'LEARNING', + interval: isCorrect ? 6 : 1, + easeFactor: isCorrect ? 2.5 : 2.3, + repetitions: isCorrect ? 2 : 0, + nextReviewAt: new Date(Date.now() + (isCorrect ? 6 : 1) * 86400000).toISOString().split('T')[0], + lastReviewedAt: new Date().toISOString(), + correctCount: isCorrect ? 5 : 4, + incorrectCount: isCorrect ? 1 : 2, + } + ), + + // PATCH /user-words/{wordId}/tag - 사용자 단어 태그 업데이트 + updateTag: (userId, wordId, {bookmarked, favorite, difficulty}) => + withMock( + () => vocabApi.patch(`/user-words/${wordId}/tag`, {userId, bookmarked, favorite, difficulty}), + {success: true, userId, wordId, bookmarked, favorite, difficulty} + ), + + // PATCH /user-words/{wordId}/tag - 사용자 단어 업데이트 (별칭) + updateUserWord: (userId, wordId, data) => + withMock( + () => vocabApi.patch(`/user-words/${wordId}/tag`, {userId, ...data}), + {success: true, ...data} + ), +} + +/** + * 나의 단어장 API - 북마크/오답 필터링 + */ +export const myWordService = { + // GET /user-words/review - 나의 단어 목록 (필터링) + getList: (userId, {bookmarked, incorrectOnly, limit = 20, cursor} = {}) => + withMock( + () => vocabApi.get('/user-words/review', { + params: {userId, bookmarked, incorrectOnly, limit, cursor} + }), + { + userWords: mockUserWords + .filter(w => (!bookmarked || w.bookmarked) && (!incorrectOnly || w.incorrectCount > 0)) + .slice(0, limit), + hasMore: false, + } + ), + + // 북마크된 단어 조회 + getBookmarked: (userId, {limit = 20, cursor} = {}) => + withMock( + () => vocabApi.get('/user-words/review', {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}}), + {userWords: mockUserWords.filter(w => w.incorrectCount > 0).slice(0, limit), hasMore: false} + ), + + // PATCH /user-words/{wordId}/tag - 북마크 토글 + toggleBookmark: (userId, wordId, bookmarked) => + withMock( + () => vocabApi.patch(`/user-words/${wordId}/tag`, {userId, bookmarked}), + {success: true, wordId, bookmarked} + ), +} + +/** + * 시험 API - Backend: POST /tests/start, POST /tests/{testId}/submit + */ +export const testService = { + // POST /tests/start - 시험 시작 + start: (userId, testType = 'DAILY', wordCount = 20, level) => + withMock( + () => vocabApi.post('/tests/start', {userId, testType, wordCount, level}), + { + testId: `test-${Date.now()}`, + testType, + words: mockWords.slice(0, wordCount || 10).map(w => ({ + wordId: w.wordId, + english: w.english, + options: [w.korean, '다른뜻1', '다른뜻2', '다른뜻3'].sort(() => Math.random() - 0.5), + })), + startedAt: new Date().toISOString(), + } + ), + + // POST /tests/{testId}/submit - 시험 제출 + submit: (userId, testId, answers) => + withMock( + () => vocabApi.post(`/tests/${testId}/submit`, {userId, answers}), + { + testId, + totalQuestions: answers.length, + correctAnswers: Math.floor(answers.length * 0.8), + incorrectAnswers: Math.ceil(answers.length * 0.2), + successRate: 80, + incorrectWordIds: answers.slice(Math.floor(answers.length * 0.8)).map(a => a.wordId), + completedAt: new Date().toISOString(), + } + ), + + // 시험 결과 조회 (프론트엔드 전용 - 백엔드에서 미구현) + getResults: (userId, {limit = 20, cursor} = {}) => + withMock( + () => vocabApi.get('/tests/results', {params: {userId, limit, cursor}}), + {testResults: mockTestResults.slice(0, limit), hasMore: false} + ), +} + +/** + * 통계 API - Backend: GET /statistics + */ +export const statsService = { + // GET /statistics - 학습 통계 조회 + getOverall: (userId, period = 'ALL') => + withMock( + () => vocabApi.get('/statistics', {params: {userId, period}}), + { + totalWords: mockWords.length, + totalLearned: 15, + masteredWords: 5, + learningWords: 8, + newWords: 7, + averageSuccessRate: 78.5, + averageAccuracy: 78.5, + studyStreak: 7, + 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'} = {}) => + withMock( + () => vocabApi.get('/statistics', {params: {userId, period}}), + {dailyStats: generateDailyStats().slice(0, limit)} + ), + + // 취약 단어 조회 (프론트엔드 전용 - 백엔드에서 미구현) + getWeakness: (userId) => + withMock( + () => vocabApi.get('/statistics', {params: {userId, includeWeak: true}}), + { + 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), + })), + } + ), +} + +/** + * 음성 API (TTS) - Backend: POST /voice/synthesize + */ +export const voiceService = { + // POST /voice/synthesize - 음성 합성 + synthesize: (wordId, text, voice = 'female', type = 'word') => + withMock( + () => vocabApi.post('/voice/synthesize', {wordId, text, voice, type}), + { + audioUrl: null, // Mock에서는 실제 오디오 없음 + cached: false, + } + ), +} diff --git a/src/i18n/translations.js b/src/i18n/translations.js new file mode 100644 index 0000000..a7cd430 --- /dev/null +++ b/src/i18n/translations.js @@ -0,0 +1,762 @@ +/** + * Multi-language support for the application + * Supports: Korean (ko), English (en) + */ + +export const translations = { + ko: { + // Common + common: { + hello: '안녕하세요', + loading: '로딩 중...', + error: '오류가 발생했습니다', + retry: '다시 시도', + cancel: '취소', + confirm: '확인', + save: '저장', + delete: '삭제', + edit: '수정', + close: '닫기', + back: '뒤로', + next: '다음', + previous: '이전', + start: '시작', + finish: '완료', + submit: '제출', + search: '검색', + all: '전체', + none: '없음', + }, + + // Navigation + nav: { + dashboard: '대시보드', + learningMode: '학습 모드', + reports: '리포트', + settings: '설정', + profile: '내 프로필', + logout: '로그아웃', + notifications: '알림', + }, + + // Sidebar categories + sidebar: { + learningMode: '학습 모드', + other: '기타', + speaking: '말하기 연습', + writing: '쓰기 연습', + vocab: '단어 학습', + opicPractice: '오픽 연습', + opicDesc: '레벨별 맞춤 연습', + aiTalk: 'AI와 대화하기', + aiTalkDesc: 'AI와 자유로운 대화', + chatPeople: '사람들과 채팅', + chatPeopleDesc: '다른 학습자와 대화', + writingPractice: '작문 연습', + writingPracticeDesc: '문법 교정 & 피드백', + vocabLearn: '단어 외우기', + vocabLearnDesc: '매일 55개 단어 학습', + vocabTest: '시험 보기', + vocabTestDesc: '4지선다 퀴즈', + vocabWords: '단어장', + vocabWordsDesc: '전체 단어 목록', + dashboardDesc: '학습 현황 요약', + reportsDesc: '학습 결과 분석', + settingsDesc: '계정 및 앱 설정', + todayStudyTime: '오늘의 학습 시간', + }, + + // Header + header: { + appName: 'AI 언어 학습', + darkMode: '다크 모드로 전환', + lightMode: '라이트 모드로 전환', + notifications: '알림', + user: '사용자님', + }, + + // Dashboard + dashboard: { + greeting: '안녕하세요!', + subtitle: '오늘은 무엇을 배워볼까요?', + recentActivity: '최근 활동', + noHistory: '아직 학습 기록이 없습니다', + startLearning: '학습을 시작해서 진도를 확인하세요', + startButton: '학습 시작하기', + speakingTitle: '말하기 연습', + speakingDesc: 'OPIC과 AI 대화로 스피킹 실력 향상', + writingTitle: '쓰기 연습', + writingDesc: '채팅과 작문으로 쓰기 실력 향상', + vocabTitle: '단어 학습', + vocabDesc: '매일 55개 단어로 어휘력 마스터', + opicTitle: 'OPIC 연습', + opicDesc: '레벨별 맞춤 훈련', + aiTalkTitle: 'AI와 대화', + aiTalkDesc: '자유로운 회화', + chatTitle: '사람들과 채팅', + chatDesc: '학습자와 함께', + compositionTitle: '작문', + compositionDesc: '문법 & 피드백', + dailyWordsTitle: '오늘의 단어', + dailyWordsDesc: '매일 55단어', + quizTitle: '퀴즈 풀기', + quizDesc: '4지선다 테스트', + wordListTitle: '단어장', + wordListDesc: '내 모든 단어', + }, + + // Vocab Dashboard + vocabDash: { + title: '단어 학습', + subtitle: '오늘의 학습 현황', + todayProgress: '오늘의 학습', + wordsLearned: '학습한 단어', + accuracy: '정확도', + streak: '연속 학습', + days: '일', + weeklyProgress: '주간 학습 현황', + quickActions: '빠른 시작', + startDailyLearning: '오늘의 학습 시작', + takeQuiz: '퀴즈 풀기', + viewWordList: '단어장 보기', + viewStats: '통계 보기', + focusWords: '집중 학습 단어', + weakWords: '약점 단어', + noWeakWords: '약점 단어가 없습니다', + }, + + // Daily Learning + dailyLearning: { + title: '오늘의 단어', + selectLevel: '레벨 선택', + beginnerTitle: '초급', + beginnerDesc: '기초 어휘 학습', + intermediateTitle: '중급', + intermediateDesc: '실용 어휘 확장', + advancedTitle: '고급', + advancedDesc: '전문 어휘 마스터', + wordsCount: '단어', + progress: '진행률', + tapToFlip: '카드를 탭하여 뒤집기', + knew: '알아요', + didntKnow: '몰라요', + complete: '학습 완료!', + totalWords: '학습한 단어', + correctCount: '정답', + accuracy: '정확도', + goBack: '돌아가기', + continueStudy: '계속 학습', + takeQuiz: '퀴즈 풀기', + greatJob: '잘했어요!', + completedSession: '오늘의 학습을 완료했습니다', + correct: '정답', + incorrect: '오답', + practiceAgain: '다시 연습', + backToDashboard: '대시보드로', + dontKnow: '몰라요', + knowIt: '알아요', + previous: '이전', + skip: '건너뛰기', + finish: '완료', + bookmark: '북마크', + removeBookmark: '북마크 해제', + }, + + // Test Page + test: { + title: '단어 시험', + subtitle: '4지선다 문제로 실력을 테스트해보세요', + startButton: '시험 시작', + recentResults: '최근 결과', + noResults: '아직 시험 기록이 없습니다', + startFirst: '첫 시험을 시작해보세요!', + question: '문제', + of: '/', + timeLeft: '남은 시간', + seconds: '초', + selectAnswer: '정답을 선택하세요', + results: '시험 결과', + score: '점', + correct: '정답', + incorrect: '오답', + time: '소요 시간', + retake: '다시 풀기', + reviewWrong: '오답 확인', + finish: '완료', + excellent: '훌륭해요!', + good: '잘했어요!', + needPractice: '더 연습해보세요!', + myAnswer: '내 답', + correctAnswer: '정답', + noAnswer: '미응답', + toDashboard: '대시보드로', + }, + + // Word List + wordList: { + title: '나의 단어장', + wordsCount: '개의 단어', + searchPlaceholder: '단어 검색...', + filterAll: '전체', + filterBookmarked: '북마크', + filterIncorrect: '틀린 단어', + noBookmarks: '북마크한 단어가 없습니다', + noIncorrect: '틀린 단어가 없습니다', + noWords: '학습한 단어가 없습니다', + startLearning: '학습 시작하기', + loadedAll: '모든 단어를 불러왔습니다', + correct: '정답', + incorrect: '오답', + }, + + // Word Detail Modal + wordDetail: { + example: '예문', + pronunciation: '발음 듣기', + voiceFemale: '여성', + voiceMale: '남성', + listen: '듣기', + playing: '재생 중...', + learningStatus: '학습 현황', + correctCount: '정답', + incorrectCount: '오답', + accuracyLabel: '정확도', + lastReviewed: '마지막 학습', + nextReview: '다음 복습', + difficulty: '난이도', + }, + + // Settings + settings: { + title: '설정', + subtitle: '앱 환경을 설정하세요', + language: '언어', + languageDesc: '앱에서 사용할 언어를 선택하세요', + korean: '한국어', + english: 'English', + ttsVoice: 'TTS 음성', + ttsVoiceDesc: '텍스트 음성 변환에 사용할 음성을 선택하세요', + femaleVoice: '여성 음성', + maleVoice: '남성 음성', + theme: '테마', + themeDesc: '앱 테마를 선택하세요', + lightTheme: '라이트 모드', + darkTheme: '다크 모드', + systemTheme: '시스템 설정', + }, + + // Levels + levels: { + BEGINNER: '초급', + INTERMEDIATE: '중급', + ADVANCED: '고급', + }, + + // Word Status + wordStatus: { + NEW: '새 단어', + LEARNING: '학습 중', + REVIEWING: '복습 중', + MASTERED: '완료', + }, + + // Statistics Page + stats: { + title: '학습 통계', + daily: '일간', + weekly: '주간', + monthly: '월간', + totalLearned: '총 학습 단어', + outOf: '전체', + avgAccuracy: '평균 정답률', + streak: '연속 학습', + days: '일', + weakWords: '취약 단어', + needReview: '복습이 필요해요', + learningHistory: '학습 기록', + levelProgress: '레벨별 진행률', + difficultyDist: '난이도 분포', + weakWordsTop10: '취약 단어 TOP 10', + review: '복습하기', + noWeakWords: '취약 단어가 없습니다', + less: '적음', + more: '많음', + }, + + // Footer + footer: { + copyright: '© 2026 AI 언어 학습. All rights reserved.', + terms: '이용약관', + privacy: '개인정보처리방침', + contact: '고객센터', + }, + + // 404 Page + notFound: { + title: '페이지를 찾을 수 없습니다', + message: '요청하신 페이지가 존재하지 않거나 이동되었습니다.', + backHome: '홈으로 돌아가기', + }, + + // Grammar Check + grammar: { + title: '문법 교정', + subtitle: 'AI가 문법을 검사하고 피드백을 제공합니다', + inputPlaceholder: '검사할 영어 문장을 입력하세요...', + checkButton: '문법 검사', + checking: '검사 중...', + selectLevel: '학습 레벨', + beginner: '초급', + beginnerDesc: '한국어 번역 포함, 쉬운 설명', + intermediate: '중급', + intermediateDesc: '영어 설명, 일반적인 패턴 학습', + advanced: '고급', + advancedDesc: '상세한 문법 규칙, 뉘앙스 학습', + result: '검사 결과', + original: '원문', + corrected: '교정문', + score: '점수', + perfect: '완벽해요!', + noProblem: '문법 오류가 없습니다', + errors: '발견된 오류', + errorCount: '개 오류 발견', + feedback: '피드백', + suggestions: '개선 제안', + tryAgain: '다시 검사', + newSentence: '새 문장', + history: '검사 기록', + noHistory: '검사 기록이 없습니다', + minLength: '최소 3자 이상 입력하세요', + maxLength: '최대 500자까지 입력 가능합니다', + errorTypes: { + VERB_TENSE: '동사 시제', + SUBJECT_VERB_AGREEMENT: '주어-동사 일치', + ARTICLE: '관사', + PREPOSITION: '전치사', + WORD_ORDER: '어순', + PLURAL_SINGULAR: '단수/복수', + PRONOUN: '대명사', + SPELLING: '철자', + PUNCTUATION: '구두점', + WORD_CHOICE: '단어 선택', + SENTENCE_STRUCTURE: '문장 구조', + OTHER: '기타', + }, + scoreGrade: { + excellent: '훌륭해요!', + good: '잘했어요!', + fair: '괜찮아요', + poor: '더 연습해요', + }, + }, + + // Badge + badge: { + title: '획득한 배지', + subtitle: '학습 목표를 달성하고 배지를 모아보세요!', + progress: '전체 진행률', + earned: '획득', + notEarned: '미획득', + earnedAt: '획득일', + progressLabel: '진행도', + allCollected: '축하합니다! 모든 배지를 획득했습니다!', + noBadges: '배지가 없습니다', + categories: { + FIRST_STUDY: '첫 시작', + STREAK: '연속 학습', + WORDS_LEARNED: '단어 학습', + PERFECT_TEST: '완벽한 테스트', + TESTS_COMPLETED: '테스트 완료', + ACCURACY: '정확도', + GAMES_PLAYED: '게임 참여', + GAMES_WON: '게임 승리', + QUICK_GUESSES: '빠른 정답', + PERFECT_DRAWS: '완벽한 출제', + ALL_BADGES: '마스터', + }, + }, + }, + + en: { + // Common + common: { + hello: 'Hello', + loading: 'Loading...', + error: 'An error occurred', + retry: 'Retry', + cancel: 'Cancel', + confirm: 'Confirm', + save: 'Save', + delete: 'Delete', + edit: 'Edit', + close: 'Close', + back: 'Back', + next: 'Next', + previous: 'Previous', + start: 'Start', + finish: 'Finish', + submit: 'Submit', + search: 'Search', + all: 'All', + none: 'None', + }, + + // Navigation + nav: { + dashboard: 'Dashboard', + learningMode: 'Learning Mode', + reports: 'Reports', + settings: 'Settings', + profile: 'My Profile', + logout: 'Logout', + notifications: 'Notifications', + }, + + // Sidebar categories + sidebar: { + learningMode: 'Learning Mode', + other: 'Other', + speaking: 'Speaking Practice', + writing: 'Writing Practice', + vocab: 'Vocabulary', + opicPractice: 'OPIC Practice', + opicDesc: 'Level-based training', + aiTalk: 'Talk with AI', + aiTalkDesc: 'Free conversation with AI', + chatPeople: 'Chat with People', + chatPeopleDesc: 'Practice with learners', + writingPractice: 'Composition', + writingPracticeDesc: 'Grammar & feedback', + vocabLearn: 'Learn Words', + vocabLearnDesc: '55 words per day', + vocabTest: 'Take Quiz', + vocabTestDesc: 'Multiple choice test', + vocabWords: 'Word List', + vocabWordsDesc: 'All your words', + dashboardDesc: 'Learning overview', + reportsDesc: 'Learning analytics', + settingsDesc: 'Account & app settings', + todayStudyTime: "Today's Study Time", + }, + + // Header + header: { + appName: 'AI Language Learning', + darkMode: 'Switch to dark mode', + lightMode: 'Switch to light mode', + notifications: 'Notifications', + user: 'User', + }, + + // Dashboard + dashboard: { + greeting: 'Hello!', + subtitle: 'What would you like to learn today?', + recentActivity: 'Recent Activity', + noHistory: 'No learning history yet', + startLearning: 'Start learning to see your progress here', + startButton: 'Start Learning', + speakingTitle: 'Speaking Practice', + speakingDesc: 'Improve speaking with OPIC and AI conversation', + writingTitle: 'Writing Practice', + writingDesc: 'Enhance writing through chat and composition', + vocabTitle: 'Vocabulary', + vocabDesc: 'Master 55 words daily for fluency', + opicTitle: 'OPIC Practice', + opicDesc: 'Level-based training', + aiTalkTitle: 'Talk with AI', + aiTalkDesc: 'Free conversation', + chatTitle: 'Chat with People', + chatDesc: 'Practice with learners', + compositionTitle: 'Composition', + compositionDesc: 'Grammar & feedback', + dailyWordsTitle: 'Daily Words', + dailyWordsDesc: '55 words per day', + quizTitle: 'Take Quiz', + quizDesc: 'Multiple choice test', + wordListTitle: 'Word List', + wordListDesc: 'All your words', + }, + + // Vocab Dashboard + vocabDash: { + title: 'Vocabulary', + subtitle: "Today's learning progress", + todayProgress: "Today's Progress", + wordsLearned: 'Words Learned', + accuracy: 'Accuracy', + streak: 'Streak', + days: 'days', + weeklyProgress: 'Weekly Progress', + quickActions: 'Quick Start', + startDailyLearning: 'Start Daily Learning', + takeQuiz: 'Take Quiz', + viewWordList: 'View Word List', + viewStats: 'View Statistics', + focusWords: 'Focus Words', + weakWords: 'Weak Words', + noWeakWords: 'No weak words', + }, + + // Daily Learning + dailyLearning: { + title: "Today's Words", + selectLevel: 'Select Level', + beginnerTitle: 'Beginner', + beginnerDesc: 'Basic vocabulary', + intermediateTitle: 'Intermediate', + intermediateDesc: 'Practical vocabulary', + advancedTitle: 'Advanced', + advancedDesc: 'Professional vocabulary', + wordsCount: 'words', + progress: 'Progress', + tapToFlip: 'Tap card to flip', + knew: 'Knew it', + didntKnow: "Didn't know", + complete: 'Learning Complete!', + totalWords: 'Words Learned', + correctCount: 'Correct', + accuracy: 'Accuracy', + goBack: 'Go Back', + continueStudy: 'Continue', + takeQuiz: 'Take Quiz', + greatJob: 'Great Job!', + completedSession: "You've completed today's learning", + correct: 'Correct', + incorrect: 'Incorrect', + practiceAgain: 'Practice Again', + backToDashboard: 'Dashboard', + dontKnow: "Don't Know", + knowIt: 'Know It', + previous: 'Previous', + skip: 'Skip', + finish: 'Finish', + bookmark: 'Bookmark', + removeBookmark: 'Remove Bookmark', + }, + + // Test Page + test: { + title: 'Vocabulary Quiz', + subtitle: 'Test your vocabulary with multiple choice', + startButton: 'Start Quiz', + recentResults: 'Recent Results', + noResults: 'No quiz history yet', + startFirst: 'Take your first quiz!', + question: 'questions', + of: '/', + timeLeft: 'Time Left', + seconds: 'sec', + selectAnswer: 'Select your answer', + results: 'Quiz Results', + score: ' pts', + correct: 'Correct', + incorrect: 'Incorrect', + time: 'Time', + retake: 'Retake', + reviewWrong: 'Review Incorrect', + finish: 'Finish', + excellent: 'Excellent!', + good: 'Good job!', + needPractice: 'Keep practicing!', + myAnswer: 'Your Answer', + correctAnswer: 'Correct Answer', + noAnswer: 'No answer', + toDashboard: 'Dashboard', + }, + + // Word List + wordList: { + title: 'My Word List', + wordsCount: 'words', + searchPlaceholder: 'Search words...', + filterAll: 'All', + filterBookmarked: 'Bookmarked', + filterIncorrect: 'Incorrect', + noBookmarks: 'No bookmarked words', + noIncorrect: 'No incorrect words', + noWords: 'No learned words yet', + startLearning: 'Start Learning', + loadedAll: 'All words loaded', + correct: 'Correct', + incorrect: 'Incorrect', + }, + + // Word Detail Modal + wordDetail: { + example: 'Example', + pronunciation: 'Pronunciation', + voiceFemale: 'Female', + voiceMale: 'Male', + listen: 'Listen', + playing: 'Playing...', + learningStatus: 'Learning Status', + correctCount: 'Correct', + incorrectCount: 'Incorrect', + accuracyLabel: 'Accuracy', + lastReviewed: 'Last Reviewed', + nextReview: 'Next Review', + difficulty: 'Difficulty', + }, + + // Settings + settings: { + title: 'Settings', + subtitle: 'Customize your app preferences', + language: 'Language', + languageDesc: 'Select your preferred language', + korean: '한국어', + english: 'English', + ttsVoice: 'TTS Voice', + ttsVoiceDesc: 'Select the voice for text-to-speech', + femaleVoice: 'Female Voice', + maleVoice: 'Male Voice', + theme: 'Theme', + themeDesc: 'Select app theme', + lightTheme: 'Light Mode', + darkTheme: 'Dark Mode', + systemTheme: 'System Default', + }, + + // Levels + levels: { + BEGINNER: 'Beginner', + INTERMEDIATE: 'Intermediate', + ADVANCED: 'Advanced', + }, + + // Word Status + wordStatus: { + NEW: 'New', + LEARNING: 'Learning', + REVIEWING: 'Reviewing', + MASTERED: 'Mastered', + }, + + // Statistics Page + stats: { + title: 'Learning Statistics', + daily: 'Daily', + weekly: 'Weekly', + monthly: 'Monthly', + totalLearned: 'Total Learned', + outOf: 'out of', + avgAccuracy: 'Average Accuracy', + streak: 'Streak', + days: ' days', + weakWords: 'Weak Words', + needReview: 'Need review', + learningHistory: 'Learning History', + levelProgress: 'Level Progress', + difficultyDist: 'Difficulty Distribution', + weakWordsTop10: 'Weak Words TOP 10', + review: 'Review', + noWeakWords: 'No weak words', + less: 'Less', + more: 'More', + }, + + // Footer + footer: { + copyright: '© 2026 AI Language Learning. All rights reserved.', + terms: 'Terms of Service', + privacy: 'Privacy Policy', + contact: 'Contact Us', + }, + + // 404 Page + notFound: { + title: 'Page Not Found', + message: "The page you're looking for doesn't exist or has been moved.", + backHome: 'Back to Home', + }, + + // Grammar Check + grammar: { + title: 'Grammar Check', + subtitle: 'AI checks your grammar and provides feedback', + inputPlaceholder: 'Enter an English sentence to check...', + checkButton: 'Check Grammar', + checking: 'Checking...', + selectLevel: 'Learning Level', + beginner: 'Beginner', + beginnerDesc: 'Korean translations, simple explanations', + intermediate: 'Intermediate', + intermediateDesc: 'English explanations, common patterns', + advanced: 'Advanced', + advancedDesc: 'Detailed rules, nuanced usage', + result: 'Results', + original: 'Original', + corrected: 'Corrected', + score: 'Score', + perfect: 'Perfect!', + noProblem: 'No grammar errors found', + errors: 'Errors Found', + errorCount: 'errors found', + feedback: 'Feedback', + suggestions: 'Suggestions', + tryAgain: 'Check Again', + newSentence: 'New Sentence', + history: 'History', + noHistory: 'No check history', + minLength: 'Enter at least 3 characters', + maxLength: 'Maximum 500 characters allowed', + errorTypes: { + VERB_TENSE: 'Verb Tense', + SUBJECT_VERB_AGREEMENT: 'Subject-Verb Agreement', + ARTICLE: 'Article', + PREPOSITION: 'Preposition', + WORD_ORDER: 'Word Order', + PLURAL_SINGULAR: 'Singular/Plural', + PRONOUN: 'Pronoun', + SPELLING: 'Spelling', + PUNCTUATION: 'Punctuation', + WORD_CHOICE: 'Word Choice', + SENTENCE_STRUCTURE: 'Sentence Structure', + OTHER: 'Other', + }, + scoreGrade: { + excellent: 'Excellent!', + good: 'Good job!', + fair: 'Not bad', + poor: 'Keep practicing', + }, + }, + + // Badge + badge: { + title: 'Earned Badges', + subtitle: 'Achieve learning goals and collect badges!', + progress: 'Overall Progress', + earned: 'Earned', + notEarned: 'Not earned', + earnedAt: 'Earned on', + progressLabel: 'Progress', + allCollected: 'Congratulations! You collected all badges!', + noBadges: 'No badges available', + categories: { + FIRST_STUDY: 'First Steps', + STREAK: 'Streak', + WORDS_LEARNED: 'Words Learned', + PERFECT_TEST: 'Perfect Test', + TESTS_COMPLETED: 'Tests Completed', + ACCURACY: 'Accuracy', + GAMES_PLAYED: 'Games Played', + GAMES_WON: 'Games Won', + QUICK_GUESSES: 'Quick Guesser', + PERFECT_DRAWS: 'Perfect Drawer', + ALL_BADGES: 'Master', + }, + }, + }, +} + +export const LANGUAGES = { + KO: 'ko', + EN: 'en', +} + +export const LANGUAGE_LABELS = { + ko: '한국어', + en: 'English', +} diff --git a/src/index.css b/src/index.css index cc31903..82ee46c 100644 --- a/src/index.css +++ b/src/index.css @@ -1,20 +1,269 @@ +/* Fresh Editorial Design System */ +@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap'); + +/* CSS Custom Properties - Design Tokens */ +:root { + /* Primary - Fresh Emerald */ + --color-primary-50: #ecfdf5; + --color-primary-100: #d1fae5; + --color-primary-200: #a7f3d0; + --color-primary-300: #6ee7b7; + --color-primary-400: #34d399; + --color-primary-500: #10b981; + --color-primary-600: #059669; + --color-primary-700: #047857; + --color-primary-800: #065f46; + --color-primary-900: #064e3b; + + /* Accent - Warm Coral */ + --color-accent-50: #fff7ed; + --color-accent-100: #ffedd5; + --color-accent-200: #fed7aa; + --color-accent-300: #fdba74; + --color-accent-400: #fb923c; + --color-accent-500: #f97316; + --color-accent-600: #ea580c; + --color-accent-700: #c2410c; + + /* Semantic Colors */ + --color-success: #10b981; + --color-warning: #f59e0b; + --color-error: #ef4444; + --color-info: #3b82f6; + + /* Neutrals - Warm Gray */ + --color-gray-50: #fafaf9; + --color-gray-100: #f5f5f4; + --color-gray-200: #e7e5e4; + --color-gray-300: #d6d3d1; + --color-gray-400: #a8a29e; + --color-gray-500: #78716c; + --color-gray-600: #57534e; + --color-gray-700: #44403c; + --color-gray-800: #292524; + --color-gray-900: #1c1917; + + /* Typography Scale */ + --font-display: 'Outfit', system-ui, sans-serif; + --font-body: 'DM Sans', system-ui, sans-serif; + + /* Spacing */ + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + --space-12: 3rem; + --space-16: 4rem; + + /* Shadows */ + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); + --shadow-glow: 0 0 40px -10px var(--color-primary-400); + + /* Border Radius */ + --radius-sm: 0.375rem; + --radius-md: 0.5rem; + --radius-lg: 0.75rem; + --radius-xl: 1rem; + --radius-2xl: 1.5rem; + --radius-full: 9999px; + + /* Transitions */ + --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-base: 200ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-bounce: 500ms cubic-bezier(0.34, 1.56, 0.64, 1); +} + * { - margin: 0; - padding: 0; - box-sizing: border-box; + margin: 0; + padding: 0; + box-sizing: border-box; } body { - font-family: 'Roboto', 'Noto Sans KR', sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; + 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; } #root { - min-height: 100vh; + min-height: 100vh; } a { - text-decoration: none; - color: inherit; + text-decoration: none; + color: inherit; +} + +/* Headings use display font */ +h1, h2, h3, h4, h5, h6 { + font-family: var(--font-display); + line-height: 1.2; +} + +/* Custom Scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--color-gray-100); + border-radius: var(--radius-full); +} + +::-webkit-scrollbar-thumb { + background: var(--color-gray-300); + border-radius: var(--radius-full); +} + +::-webkit-scrollbar-thumb:hover { + background: var(--color-gray-400); +} + +/* Selection */ +::selection { + background-color: var(--color-primary-200); + color: var(--color-primary-900); +} + +/* Focus Styles */ +:focus-visible { + outline: 2px solid var(--color-primary-500); + outline-offset: 2px; +} + +/* Animations */ +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes scaleIn { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +@keyframes float { + 0%, 100% { + transform: translateY(0px); + } + 50% { + transform: translateY(-6px); + } +} + +@keyframes celebrate { + 0% { + transform: scale(1) rotate(0deg); + } + 25% { + transform: scale(1.1) rotate(-5deg); + } + 50% { + transform: scale(1.2) rotate(5deg); + } + 75% { + transform: scale(1.1) rotate(-3deg); + } + 100% { + transform: scale(1) rotate(0deg); + } +} + +/* Utility Classes */ +.animate-fadeIn { + animation: fadeIn var(--transition-base) ease-out; +} + +.animate-slideUp { + animation: slideUp var(--transition-slow) ease-out; +} + +.animate-scaleIn { + animation: scaleIn var(--transition-base) ease-out; +} + +.animate-pulse { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +.animate-float { + animation: float 3s ease-in-out infinite; +} + +.animate-celebrate { + animation: celebrate 0.6s ease-in-out; +} + +/* Gradient Text */ +.gradient-text { + background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-accent-500) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* Glass Effect */ +.glass { + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +/* Card Hover Effect */ +.card-hover { + transition: transform var(--transition-base), box-shadow var(--transition-base); +} + +.card-hover:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-xl); } diff --git a/src/layouts/MainLayout/Footer/index.jsx b/src/layouts/MainLayout/Footer/index.jsx new file mode 100644 index 0000000..27093f4 --- /dev/null +++ b/src/layouts/MainLayout/Footer/index.jsx @@ -0,0 +1,74 @@ +import {Box, Container, Link, Typography} from '@mui/material' +import {useTranslation} from '../../../contexts/SettingsContext' + +const Footer = () => { + const {t} = useTranslation() + + return ( + + + + {/* Copyright */} + + {t('footer.copyright')} + + + {/* Links */} + + + {t('footer.terms')} + + + {t('footer.privacy')} + + + {t('footer.contact')} + + + + + + ) +} + +export default Footer diff --git a/src/layouts/MainLayout/Header/index.jsx b/src/layouts/MainLayout/Header/index.jsx new file mode 100644 index 0000000..e468f15 --- /dev/null +++ b/src/layouts/MainLayout/Header/index.jsx @@ -0,0 +1,427 @@ +import {useState} from 'react' +import {useNavigate} from 'react-router-dom' +import { + AppBar, + Avatar, + Badge, + Box, + Chip, + Divider, + IconButton, + Menu, + MenuItem, + Toolbar, + Typography, + useMediaQuery, + useTheme, +} from '@mui/material' +import { + DarkMode as DarkModeIcon, + LightMode as LightModeIcon, + Logout as LogoutIcon, + Menu as MenuIcon, + Notifications as NotificationsIcon, + Person as PersonIcon, + Settings as SettingsIcon, + Translate as TranslateIcon, +} from '@mui/icons-material' +import {useThemeMode} from '../../../contexts/ThemeContext' +import {useSettings, useTranslation} from '../../../contexts/SettingsContext' +import {LANGUAGE_LABELS, LANGUAGES} from '../../../i18n/translations' + +const Header = ({onMenuClick, sidebarOpen}) => { + const theme = useTheme() + const navigate = useNavigate() + const isMobile = useMediaQuery(theme.breakpoints.down('md')) + const {mode, toggleTheme} = useThemeMode() + const {setLanguage, language} = useSettings() + const {t} = useTranslation() + + const [anchorEl, setAnchorEl] = useState(null) + const [notificationAnchor, setNotificationAnchor] = useState(null) + const [langAnchor, setLangAnchor] = useState(null) + + const handleProfileMenuOpen = (event) => { + setAnchorEl(event.currentTarget) + } + + const handleProfileMenuClose = () => { + setAnchorEl(null) + } + + const handleNotificationOpen = (event) => { + setNotificationAnchor(event.currentTarget) + } + + const handleNotificationClose = () => { + setNotificationAnchor(null) + } + + const handleLangOpen = (event) => { + setLangAnchor(event.currentTarget) + } + + const handleLangClose = () => { + setLangAnchor(null) + } + + const handleLanguageChange = (lang) => { + setLanguage(lang) + handleLangClose() + } + + const handleLogout = () => { + handleProfileMenuClose() + navigate('/login') + } + + return ( + + + {/* Hamburger menu (mobile) */} + {isMobile && ( + + + + )} + + {/* Logo */} + navigate('/')} + > + + + L + + + + + {t('header.appName')} + + + + + + + {/* Right side icons */} + + {/* Language selector */} + + + + + {/* Dark mode toggle */} + + {mode === 'dark' ? : } + + + {/* Notifications */} + + + + + + + {/* Profile */} + + + U + + + + + {/* Language Menu */} + + + + {t('settings.language')} + + + + {Object.entries(LANGUAGES).map(([key, value]) => ( + handleLanguageChange(value)} + selected={language === value} + sx={{ + py: 1.5, + px: 2, + '&.Mui-selected': { + backgroundColor: 'rgba(5, 150, 105, 0.08)', + '&:hover': { + backgroundColor: 'rgba(5, 150, 105, 0.12)', + }, + }, + }} + > + + + {LANGUAGE_LABELS[value]} + + {language === value && ( + + )} + + + ))} + + + {/* Notification Menu */} + + + + {t('nav.notifications')} + + + + + {[ + { + text: language === 'ko' ? '면접 연습 세션이 완료되었습니다.' : 'Interview session completed.', + time: language === 'ko' ? '10분 전' : '10 min ago' + }, + { + text: language === 'ko' ? 'OPIC 모의고사 결과가 도착했습니다.' : 'OPIC test results arrived.', + time: language === 'ko' ? '1시간 전' : '1 hour ago' + }, + { + text: language === 'ko' ? '새로운 학습 리포트가 생성되었습니다.' : 'New learning report generated.', + time: language === 'ko' ? '어제' : 'Yesterday' + }, + ].map((item, index) => ( + + + + {item.text} + + + {item.time} + + + + ))} + + + {/* Profile Menu */} + + + + {t('header.user')} + + + user@example.com + + + + { + handleProfileMenuClose(); + navigate('/profile'); + }} + sx={{py: 1.5, px: 2.5}} + > + + {t('nav.profile')} + + { + handleProfileMenuClose(); + navigate('/settings'); + }} + sx={{py: 1.5, px: 2.5}} + > + + {t('nav.settings')} + + + + + {t('nav.logout')} + + + + + ) +} + +export default Header diff --git a/src/layouts/MainLayout/HorizontalNav/index.jsx b/src/layouts/MainLayout/HorizontalNav/index.jsx new file mode 100644 index 0000000..c4ea8f4 --- /dev/null +++ b/src/layouts/MainLayout/HorizontalNav/index.jsx @@ -0,0 +1,394 @@ +import {useRef, useState} from 'react' +import {useLocation, useNavigate} from 'react-router-dom' +import {Box, Fade, Paper, Typography, useMediaQuery, useTheme,} from '@mui/material' +import { + Assessment as ReportIcon, + Create as WritingIcon, + Dashboard as DashboardIcon, + Edit as WriteIcon, + Headphones as OpicIcon, + LibraryBooks as WordListIcon, + MenuBook as VocabIcon, + Mic as SpeakingIcon, + People as PeopleIcon, + Quiz as QuizIcon, + School as LearnIcon, + Settings as SettingsIcon, + SmartToy as AiIcon, + TrendingUp as TrendingIcon, +} from '@mui/icons-material' +import {useThemeMode} from '../../../contexts/ThemeContext' +import {useTranslation} from '../../../contexts/SettingsContext' + +// 고정 크기로 일정한 드롭다운 보장 +const DROPDOWN_ITEM_WIDTH = 200 +const DROPDOWN_ITEM_HEIGHT = 72 // 각 아이템의 고정 높이 +const DROPDOWN_PADDING = 12 + +const HorizontalNav = () => { + const theme = useTheme() + const {mode} = useThemeMode() + const location = useLocation() + const navigate = useNavigate() + const {t} = useTranslation() + const isMobile = useMediaQuery(theme.breakpoints.down('md')) + + const [activeMenu, setActiveMenu] = useState(null) + const navRef = useRef(null) + const timeoutRef = useRef(null) + + // 메뉴 정의 + const menuItems = [ + { + id: 'speaking', + label: t('sidebar.speaking'), + icon: SpeakingIcon, + color: '#3b82f6', + children: [ + { + id: 'opic', + label: t('sidebar.opicPractice'), + icon: OpicIcon, + path: '/opic', + desc: t('sidebar.opicDesc') + }, + { + id: 'ai-talk', + label: t('sidebar.aiTalk'), + icon: AiIcon, + path: '/freetalk/ai', + desc: t('sidebar.aiTalkDesc') + }, + ], + }, + { + id: 'writing', + label: t('sidebar.writing'), + icon: WritingIcon, + color: '#8b5cf6', + children: [ + { + id: 'chat-people', + label: t('sidebar.chatPeople'), + icon: PeopleIcon, + path: '/freetalk/people', + desc: t('sidebar.chatPeopleDesc') + }, + { + id: 'writing-practice', + label: t('sidebar.writingPractice'), + icon: WriteIcon, + path: '/writing', + desc: t('sidebar.writingPracticeDesc') + }, + ], + }, + { + id: 'vocab', + label: t('sidebar.vocab'), + icon: VocabIcon, + color: '#059669', + children: [ + { + id: 'vocab-daily', + label: t('sidebar.vocabLearn'), + icon: LearnIcon, + path: '/vocab', + desc: t('sidebar.vocabLearnDesc') + }, + { + id: 'vocab-test', + label: t('sidebar.vocabTest'), + icon: QuizIcon, + path: '/vocab/test', + desc: t('sidebar.vocabTestDesc') + }, + { + id: 'vocab-words', + label: t('sidebar.vocabWords'), + icon: WordListIcon, + path: '/vocab/words', + desc: t('sidebar.vocabWordsDesc') + }, + { + id: 'vocab-stats', + label: t('vocabDash.viewStats'), + icon: TrendingIcon, + path: '/vocab/stats', + desc: t('sidebar.reportsDesc') + }, + ], + }, + { + id: 'dashboard', + label: t('nav.dashboard'), + icon: DashboardIcon, + color: '#f97316', + path: '/dashboard', + }, + { + id: 'reports', + label: t('nav.reports'), + icon: ReportIcon, + color: '#ec4899', + path: '/reports', + }, + { + id: 'settings', + label: t('nav.settings'), + icon: SettingsIcon, + color: '#6b7280', + path: '/settings', + }, + ] + + // 가장 많은 하위 메뉴 개수 찾기 (일정한 드롭다운 높이를 위해) + const maxChildren = Math.max(...menuItems.filter(m => m.children).map(m => m.children.length), 0) + + const handleMouseEnter = (menuId) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + setActiveMenu(menuId) + } + + const handleMouseLeave = () => { + timeoutRef.current = setTimeout(() => { + setActiveMenu(null) + }, 150) + } + + const handleNavigation = (path) => { + navigate(path) + setActiveMenu(null) + } + + const isActive = (path) => location.pathname === path + const isParentActive = (children) => children?.some((child) => location.pathname.startsWith(child.path)) + + // 모바일에서는 숨기기 + if (isMobile) return null + + return ( + + {/* 메인 네비게이션 바 */} + + {menuItems.map((item) => { + const Icon = item.icon + const hasChildren = item.children && item.children.length > 0 + const active = item.path ? isActive(item.path) : isParentActive(item.children) + const isOpen = activeMenu === item.id && hasChildren + + return ( + handleMouseEnter(item.id)} + onMouseLeave={handleMouseLeave} + sx={{position: 'relative'}} + > + {/* 메인 메뉴 버튼 */} + !hasChildren && handleNavigation(item.path)} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 1, + px: 2, + py: 1.5, + borderRadius: '12px', + cursor: 'pointer', + backgroundColor: active + ? `${item.color}15` + : isOpen + ? (mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)') + : 'transparent', + border: '2px solid', + borderColor: active ? item.color : 'transparent', + transition: 'all 0.2s ease', + '&:hover': { + backgroundColor: active + ? `${item.color}20` + : mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)', + }, + }} + > + + + + + {item.label} + + + + {/* 드롭다운 메뉴 */} + {hasChildren && ( + + handleMouseEnter(item.id)} + onMouseLeave={handleMouseLeave} + sx={{ + position: 'absolute', + top: '100%', + left: '50%', + transform: 'translateX(-50%)', + mt: 0.5, + borderRadius: '16px', + overflow: 'hidden', + // 고정 너비로 모든 드롭다운 크기 통일 + width: DROPDOWN_ITEM_WIDTH + DROPDOWN_PADDING * 2, + // 최대 하위 메뉴 개수 기준으로 고정 높이 설정 + minHeight: maxChildren * DROPDOWN_ITEM_HEIGHT + DROPDOWN_PADDING * 2, + backgroundColor: mode === 'dark' ? '#1e1e1e' : 'white', + boxShadow: mode === 'dark' + ? '0 10px 40px -10px rgba(0,0,0,0.5)' + : '0 10px 40px -10px rgba(0,0,0,0.15)', + display: isOpen ? 'block' : 'none', + }} + > + + {item.children.map((child) => { + const ChildIcon = child.icon + const childActive = isActive(child.path) + + return ( + handleNavigation(child.path)} + sx={{ + // 고정 높이로 아이템 크기 통일 + height: DROPDOWN_ITEM_HEIGHT - 8, + p: 1.5, + mb: 1, + borderRadius: '12px', + cursor: 'pointer', + backgroundColor: childActive + ? `${item.color}10` + : 'transparent', + transition: 'all 0.2s ease', + '&:hover': { + backgroundColor: childActive + ? `${item.color}15` + : mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)', + }, + '&:last-child': { + mb: 0, + }, + }} + > + + + + + + + {child.label} + + {child.desc && ( + + {child.desc} + + )} + + + + ) + })} + + + + )} + + ) + })} + + + ) +} + +export default HorizontalNav diff --git a/src/layouts/MainLayout/Sidebar/index.jsx b/src/layouts/MainLayout/Sidebar/index.jsx new file mode 100644 index 0000000..8ee4644 --- /dev/null +++ b/src/layouts/MainLayout/Sidebar/index.jsx @@ -0,0 +1,492 @@ +import {useEffect, useState} from 'react' +import {useLocation, useNavigate} from 'react-router-dom' +import { + Box, + Collapse, + Divider, + Drawer, + IconButton, + LinearProgress, + List, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, + Typography, + useMediaQuery, + useTheme, +} from '@mui/material' +import { + Assessment as ReportIcon, + ChevronLeft as ChevronLeftIcon, + ChevronRight as ChevronRightIcon, + Create as WritingCategoryIcon, + Dashboard as DashboardIcon, + Edit as WritingIcon, + ExpandLess as ExpandLessIcon, + ExpandMore as ExpandMoreIcon, + Headphones as OpicIcon, + LibraryBooks as WordListIcon, + MenuBook as VocabIcon, + Mic as SpeakingIcon, + People as PeopleIcon, + Quiz as QuizIcon, + School as LearnIcon, + Settings as SettingsIcon, + SmartToy as AiIcon, + TrendingUp as TrendingIcon, +} from '@mui/icons-material' +import {useThemeMode} from '../../../contexts/ThemeContext' +import {useTranslation} from '../../../contexts/SettingsContext' + +const DRAWER_WIDTH = 280 +const DRAWER_WIDTH_COLLAPSED = 76 + +const Sidebar = ({open, collapsed, onToggleCollapse, onClose}) => { + const theme = useTheme() + const {mode} = useThemeMode() + const location = useLocation() + const navigate = useNavigate() + const isMobile = useMediaQuery(theme.breakpoints.down('md')) + const {t} = useTranslation() + + const [expandedMenus, setExpandedMenus] = useState(() => { + const saved = localStorage.getItem('expandedMenus') + return saved ? JSON.parse(saved) : {speaking: true, writing: true, vocab: true} + }) + + useEffect(() => { + localStorage.setItem('expandedMenus', JSON.stringify(expandedMenus)) + }, [expandedMenus]) + + const menuItems = [ + { + category: t('sidebar.learningMode'), + items: [ + { + id: 'speaking', + label: t('sidebar.speaking'), + icon: SpeakingIcon, + color: '#3b82f6', + bgColor: '#eff6ff', + children: [ + { + id: 'opic', + label: t('sidebar.opicPractice'), + icon: OpicIcon, + path: '/opic', + description: t('sidebar.opicDesc'), + }, + { + id: 'ai-talk', + label: t('sidebar.aiTalk'), + icon: AiIcon, + path: '/freetalk/ai', + description: t('sidebar.aiTalkDesc'), + }, + ], + }, + { + id: 'writing', + label: t('sidebar.writing'), + icon: WritingCategoryIcon, + color: '#8b5cf6', + bgColor: '#f5f3ff', + children: [ + { + id: 'chat-people', + label: t('sidebar.chatPeople'), + icon: PeopleIcon, + path: '/freetalk/people', + description: t('sidebar.chatPeopleDesc'), + }, + { + id: 'writing-practice', + label: t('sidebar.writingPractice'), + icon: WritingIcon, + path: '/writing', + description: t('sidebar.writingPracticeDesc'), + }, + ], + }, + { + id: 'vocab', + label: t('sidebar.vocab'), + icon: VocabIcon, + color: '#059669', + bgColor: '#ecfdf5', + children: [ + { + id: 'vocab-daily', + label: t('sidebar.vocabLearn'), + icon: LearnIcon, + path: '/vocab', + description: t('sidebar.vocabLearnDesc'), + }, + { + id: 'vocab-test', + label: t('sidebar.vocabTest'), + icon: QuizIcon, + path: '/vocab/test', + description: t('sidebar.vocabTestDesc'), + }, + { + id: 'vocab-words', + label: t('sidebar.vocabWords'), + icon: WordListIcon, + path: '/vocab/words', + description: t('sidebar.vocabWordsDesc'), + }, + { + id: 'vocab-stats', + label: t('vocabDash.viewStats'), + icon: TrendingIcon, + path: '/vocab/stats', + description: t('sidebar.reportsDesc'), + }, + ], + }, + ], + }, + { + category: t('sidebar.other'), + items: [ + { + id: 'dashboard', + label: t('nav.dashboard'), + icon: DashboardIcon, + path: '/dashboard', + description: t('sidebar.dashboardDesc'), + color: '#f97316', + bgColor: '#fff7ed', + }, + { + id: 'reports', + label: t('nav.reports'), + icon: ReportIcon, + path: '/reports', + description: t('sidebar.reportsDesc'), + color: '#ec4899', + bgColor: '#fdf2f8', + }, + { + id: 'settings', + label: t('nav.settings'), + icon: SettingsIcon, + path: '/settings', + description: t('sidebar.settingsDesc'), + color: '#6b7280', + bgColor: '#f3f4f6', + }, + ], + }, + ] + + const drawerWidth = collapsed ? DRAWER_WIDTH_COLLAPSED : DRAWER_WIDTH + + const handleNavigation = (path) => { + navigate(path) + if (isMobile) { + onClose() + } + } + + const handleToggleExpand = (menuId) => { + setExpandedMenus((prev) => ({ + ...prev, + [menuId]: !prev[menuId], + })) + } + + const isActive = (path) => location.pathname === path + const isParentActive = (children) => children?.some((child) => location.pathname === child.path) + + const renderMenuItem = (item, isChild = false) => { + const Icon = item.icon + const hasChildren = item.children && item.children.length > 0 + const active = item.path ? isActive(item.path) : isParentActive(item.children) + const expanded = expandedMenus[item.id] + const itemColor = item.color || '#059669' + const itemBgColor = item.bgColor || '#ecfdf5' + + return ( + + + { + if (hasChildren) { + handleToggleExpand(item.id) + } else if (item.path) { + handleNavigation(item.path) + } + }} + sx={{ + borderRadius: '14px', + minHeight: collapsed ? 48 : 52, + justifyContent: collapsed ? 'center' : 'flex-start', + px: collapsed ? 1.5 : 2, + pl: isChild && !collapsed ? 3.5 : (collapsed ? 1.5 : 2), + mx: 1, + backgroundColor: active && !hasChildren + ? itemBgColor + : 'transparent', + border: '2px solid', + borderColor: active && !hasChildren ? itemColor : 'transparent', + transition: 'all 0.2s ease', + '&:hover': { + backgroundColor: active && !hasChildren + ? itemBgColor + : mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)', + borderColor: active && !hasChildren ? itemColor : 'transparent', + }, + }} + > + + + + + + + {!collapsed && ( + <> + + {hasChildren && ( + + {expanded ? ( + + ) : ( + + )} + + )} + + )} + + + + {hasChildren && !collapsed && ( + + + {item.children.map((child) => renderMenuItem({ + ...child, + color: itemColor, + bgColor: itemBgColor + }, true))} + + + )} + + ) + } + + const drawerContent = ( + + {/* Header spacing */} + + + {/* Collapse toggle */} + {!isMobile && ( + + + {collapsed ? ( + + ) : ( + + )} + + + )} + + {/* Menu list */} + + {menuItems.map((category, categoryIndex) => ( + + {!collapsed && ( + + {category.category} + + )} + + + {category.items.map((item) => renderMenuItem(item))} + + + {categoryIndex < menuItems.length - 1 && !collapsed && ( + + )} + + ))} + + + {/* Bottom stats */} + {!collapsed && ( + + + {t('sidebar.todayStudyTime')} + + + 1h 23m + + + + + {t('vocabDash.todayProgress')} + + + 68% + + + + + + )} + + ) + + // Mobile: Temporary Drawer + if (isMobile) { + return ( + + {drawerContent} + + ) + } + + // Desktop: Permanent Drawer + return ( + + {drawerContent} + + ) +} + +export default Sidebar diff --git a/src/layouts/MainLayout/index.jsx b/src/layouts/MainLayout/index.jsx new file mode 100644 index 0000000..971b7e1 --- /dev/null +++ b/src/layouts/MainLayout/index.jsx @@ -0,0 +1,110 @@ +import {useEffect, useState} from 'react' +import {Outlet} from 'react-router-dom' +import {Box, useMediaQuery, useTheme} from '@mui/material' +import Header from './Header' +import Sidebar from './Sidebar' +import HorizontalNav from './HorizontalNav' +import Footer from './Footer' + +const DRAWER_WIDTH = 280 +const DRAWER_WIDTH_COLLAPSED = 76 + +// 가로 네비게이션 사용 여부 (true: 가로 네비게이션, false: 사이드바) +const USE_HORIZONTAL_NAV = true + +const MainLayout = () => { + const theme = useTheme() + const isMobile = useMediaQuery(theme.breakpoints.down('md')) + + // 모바일 사이드바 열림 상태 + const [mobileOpen, setMobileOpen] = useState(false) + + // 데스크톱 사이드바 접힘 상태 (localStorage 저장) + const [collapsed, setCollapsed] = useState(() => { + const saved = localStorage.getItem('sidebarCollapsed') + return saved ? JSON.parse(saved) : false + }) + + // collapsed 상태 localStorage 저장 + useEffect(() => { + localStorage.setItem('sidebarCollapsed', JSON.stringify(collapsed)) + }, [collapsed]) + + const handleMobileToggle = () => { + setMobileOpen(!mobileOpen) + } + + const handleMobileClose = () => { + setMobileOpen(false) + } + + const handleCollapseToggle = () => { + setCollapsed(!collapsed) + } + + // 가로 네비게이션 사용 시 사이드바 너비는 0 + const drawerWidth = USE_HORIZONTAL_NAV + ? 0 + : (isMobile ? 0 : (collapsed ? DRAWER_WIDTH_COLLAPSED : DRAWER_WIDTH)) + + // 헤더 + 가로 네비게이션 높이 (64 + 56) + const topOffset = USE_HORIZONTAL_NAV && !isMobile ? 120 : 64 + + return ( + + {/* Header */} +
+ + {/* 가로 네비게이션 (데스크톱) */} + {USE_HORIZONTAL_NAV && } + + {/* 사이드바 (모바일에서만 사용하거나 USE_HORIZONTAL_NAV가 false일 때) */} + {(!USE_HORIZONTAL_NAV || isMobile) && ( + + )} + + {/* Main Content */} + + {/* 상단 여백 (헤더 + 가로 네비게이션) */} + + + {/* 콘텐츠 영역 */} + + + + + {/* Footer */} +