diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..b6076ffd --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,18 @@ +language: 'ko-KR' +reviews: + request_changes_workflow: false + high_level_summary: true + poem: false + review_status: true + collapse_walkthrough: false + auto_review: + enabled: true + ignore_title_keywords: + - 'WIP' + - 'DO NOT MERGE' + base_branches: + - 'main' + - 'develop' + - 'release/*' +chat: + auto_reply: true diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..9dae753b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +amplify/ +.vscode/ +.github/ +docs/ +.env +.github/ + diff --git a/.firebaserc b/.firebaserc new file mode 100644 index 00000000..4c472300 --- /dev/null +++ b/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "aipark-four-t" + } +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..a89b3ab1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +* text=auto eol=lf +casesensitive=true + +*.{cmd,[cC][mM][dD]} text eol=crlf +*.{bat,[bB][aA][tT]} text eol=crlf +*.{exe,dll} binary +*.yml text eol=lf +*.yaml text eol=lf diff --git a/.github/.gitmessage.txt b/.github/.gitmessage.txt new file mode 100644 index 00000000..f2083f7e --- /dev/null +++ b/.github/.gitmessage.txt @@ -0,0 +1,36 @@ +# [타입] <제목> #이슈 번호 - 제목을 "<타입> <제목> (#이슈 번호)" 형식으로 작성 + +# 변경된 "내용"을 명확히 설명 / 마침표로 끝내지 마세요 +# 예시 - [settings] eslint, prettier, lefthook 설정 #1 + + +# 본문을 아래에 작성하세요 + +# 여러 줄을 구분할 때는 "-"를 사용하세요 +# - login.tsx 파일이 수정되었습니다. + +# --- 커밋 끝 --- +# <타입> 목록 +# feat : 기능 (새로운 기능) +# fix : 버그 수정 (버그 수정) +# refactor : 리팩토링 +# style : 코드 스타일 (코드 서식, 공백, 주석, 세미콜론: 비즈니스 로직 변경 없음) +# docs : 문서 (문서 추가, 수정, 삭제, README) +# test : 테스트 (테스트 코드 추가, 수정, 삭제: 비즈니스 로직 변경 없음) +# settings : 프로젝트 설정 변경(예: 패키지 매니저 수정, .gitignore 등) +# init : 초기 생성 +# rename : 파일/폴더명 변경 또는 이동만 +# remove : 파일 삭제만 +# design : UI/UX 디자인 변경 (예: CSS) +# release : 배포 또는 릴리스 (예: release/login-123) +# chore : 기타 변경사항 +# ------------------ +# 제목은 소문자로 시작 +# 제목은 명령형으로 작성 +# 제목 끝에 마침표를 넣지 마세요 +# 제목과 본문 사이에 빈 줄을 남기세요 +# 본문은 "어떻게"보다는 "무엇을", "왜"를 설명해야 합니다 +# 본문의 여러 줄을 구분할 때는 "-"를 사용하세요 +# 설정 명령어: +# git config --local commit.template ./.github/.gitmessage.txt +# ------------------ diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 00000000..a91111b8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,27 @@ +name: 버그 리포트 +description: 버그 보고 또는 UX/UI 개선 요청을 위한 템플릿입니다. +title: "[bug] " +labels: ["bug"] +projects: ["FC-DEV-FinalProject/2"] +body: + - type: textarea + id: bug-report + attributes: + label: 버그 리포트 + description: ⚠️제목으로 유추할 수도 있겠지만, 아래 항목은 가능한 적어주세요⚠️ + placeholder: | + - 버그에 대한 간단한 설명 또는 UX/UI 개선 요청 + - 해당 버그가 발생한 상황 + - 스크린샷 (선택) + validations: + required: true + - type: markdown + attributes: + value: | + - 버그에 대한 간단한 설명 + - UX/UI 개선 요청 (와이어프레임과 상이, 레이아웃 변경, 폰트 조절 등) + - 해당 버그가 발생한 상황 (아이디 28자 이상 입력, 닫기 빠른 5연속 클릭 등) + - 가능하다면 스크린샷 + - 해결책의 상세한 설명 + - 더 많은 상세 내용과 관련된 정보를 담은 기사나 URL의 참조 + - 해결책의 일부나, 상세 내용을 깊게 이해하는데 도움이 될 만한 도표, 설계, 다이어그램 스냅샷 diff --git a/.github/ISSUE_TEMPLATE/docs.yml b/.github/ISSUE_TEMPLATE/docs.yml new file mode 100644 index 00000000..f6b80615 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/docs.yml @@ -0,0 +1,15 @@ +name: 문서 변경 +description: 문서 업데이트가 있나요? +title: "[docs] " +labels: ["docs"] +projects: ["FC-DEV-FinalProject/2"] +body: + - type: textarea + id: docs-update + attributes: + label: 📄 문서 업데이트 상세 내용 + description: 어떤 문서가 업데이트되어야 하는지, 왜 업데이트가 필요한지 명확히 기술해 주세요. + placeholder: | + 어떤 문서가 업데이트되어야 하는지, 왜 업데이트가 필요한지 명확히 기술해 주세요. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/feat.yml b/.github/ISSUE_TEMPLATE/feat.yml new file mode 100644 index 00000000..64ce66fd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feat.yml @@ -0,0 +1,15 @@ +name: 기능 추가 +description: 새로운 기능이나 사양이 있나요? +title: "[feat] " +labels: ["feat"] +projects: ["FC-DEV-FinalProject/2"] +body: + - type: textarea + id: feat-description + attributes: + label: 📄 설명 + description: 새로운 기능을 설명해주세요. + placeholder: 자세한 설명을 제공해주세요! + validations: + required: true + diff --git a/.github/ISSUE_TEMPLATE/refactor.yml b/.github/ISSUE_TEMPLATE/refactor.yml new file mode 100644 index 00000000..38aa9cf2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/refactor.yml @@ -0,0 +1,32 @@ +name: 코드 리펙토링 +description: 코드 리팩토링 +title: "[refactor] " +labels: ["refactor"] +projects: ["FC-DEV-FinalProject/2"] +body: + - type: textarea + id: refactor-description + attributes: + label: 📄 설명 + description: 리팩터링 변경 사항을 설명해주세요. + placeholder: 자세한 설명을 제공해주세요! + validations: + required: true + - type: markdown + attributes: + value: | + ## 리팩토링 예시: + ### 1. 리팩토링: + - 긴 함수를 여러 개의 작은 함수로 나누기 + - 복잡한 if-else 문을 전략 패턴으로 변경하기 + - 하드코딩된 문자열을 상수로 추출하기 + + ### 2. 버그 고치기 + - **⚠️버그를 고치는 것은 리팩토링이 아닙니다, feat 라벨로 변경 해주세요!!⚠️** + - refactor태그가 아닌 feat테그를 넣고 작성해주세요. + + ### 2. 기능 추가: + - **⚠️기능을 추가하는 것은 리팩토링이 아닙니다, feat 라벨로 변경 해주세요!!⚠️ ** + - 새로운 사용자 인증 방식 추가하기 + - 데이터베이스에 새 필드 추가하고 관련 로직 구현하기 + - 새로운 API 엔드포인트 만들기 diff --git a/.github/ISSUE_TEMPLATE/setting.yml b/.github/ISSUE_TEMPLATE/setting.yml new file mode 100644 index 00000000..fc56e5b0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/setting.yml @@ -0,0 +1,36 @@ +name: 설정 변경 +description: 프로젝트 구성 변경(npm, env, db, 등) +title: "[setting] " +labels: ["setting"] +projects: ["FC-DEV-FinalProject/2"] +body: + - type: textarea + id: setting-details + attributes: + label: 📄 구성 변경 상세 내용 + description: 프로젝트 구성 변경에 대해 설명해주세요. + placeholder: | + - 어떤 구성을 변경하나요? + - 예시: + - npm 종속성 + - package-lock.json 변경 + - .env 환경 변수 관련 세팅 변경 + - 그외 + validations: + required: true + - type: markdown + attributes: + value: | + > 다음 내용을 포함하여 작성해주세요: + + ## 어떤 구성을 변경하나요? + ### 예시 + - npm 종속성 추가 + - package-lock.json 변경 + - .env 환경 변수 관련 세팅 변경 + - 그외(디비 세팅, 배포 환경 관련 세팅 변경) + + ### 주의사항 + - npm 종속성 추가 시 정확한 버전을 명시해주세요. + - package-lock.json 변경 시 전체 팀에 공유해주세요. + - 환경 변수 변경 시 .env.example 파일도 업데이트해주세요. diff --git a/.github/ISSUE_TEMPLATE/test.yml b/.github/ISSUE_TEMPLATE/test.yml new file mode 100644 index 00000000..02c99f80 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/test.yml @@ -0,0 +1,33 @@ +name: 테스트 +description: 테스트(E2E테스트, 통합테스트, 테스트 관련 세팅) +title: "[test] " +labels: ["test"] +projects: ["FC-DEV-FinalProject/2"] +body: + - type: textarea + id: test-details + attributes: + label: 📄 테스트 상세 내용 + description: 추가하거나 수정하는 테스트에 대해 설명해주세요. + placeholder: | + 시간이 없다면 `테스트 대상`과 `테스트 유형`만이라도 적어주세요 + validations: + required: true + - type: markdown + attributes: + value: | + **⚠️시간이 없다면 `테스트 대상`과 `테스트 유형`만이라도 적어주세요⚠️** + + ### 1. 테스트 대상: + - 어떤 기능 또는 컴포넌트를 테스트하나요? + + ### 2. 테스트 유형: + - 단위 테스트, 통합 테스트, E2E 테스트 중 어떤 유형인가요? + + ### 3. 테스트 시나리오: + - 어떤 상황을 테스트하나요? (정상 케이스, 경계값, 예외 상황 등) + + ### 주의사항: + - 테스트는 독립적이고 반복 가능해야 합니다. + - 모든 중요한 로직에 대해 테스트를 작성해주세요. + - 테스트 실행 시간을 고려하세요. 너무 긴 테스트는 CI/CD 파이프라인에 부담을 줄 수 있습니다. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..3d38999a --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,3 @@ + + + diff --git a/.github/auto_assign.yml b/.github/auto_assign.yml new file mode 100644 index 00000000..0ecad1c0 --- /dev/null +++ b/.github/auto_assign.yml @@ -0,0 +1,17 @@ +addReviewers: true + +reviewers: + - nakyeonko3 + - sjgaru-dev + - miniseung + - dyeongg + +addAssignees: author +numberOfReviewers: 0 + +skipKeywords: + - wip + - WIP + - draft + - "do not review" + - "not ready" diff --git a/.github/hooks/commitlint.sh b/.github/hooks/commitlint.sh new file mode 100644 index 00000000..3241b50b --- /dev/null +++ b/.github/hooks/commitlint.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# prepare-commit-msg + +if [ -z "$1" ]; then + echo "Error: No commit message file provided" + exit 1 +fi + +COMMIT_MSG_FILE="$1" +COMMIT_SOURCE="$2" + +# Validate that commit message file exists +if [ ! -f "$COMMIT_MSG_FILE" ]; then + echo "Error: Commit message file does not exist: $COMMIT_MSG_FILE" + exit 1 +fi + +# Check if .env file exists and byulBash is set to true +if [ -f .env ]; then + byul_bash=$(grep '^byulBash=' .env | cut -d '=' -f2) + if [ "$byul_bash" != "true" ]; then + exit 0 + fi +else + exit 0 +fi + +# ANSI color codes +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +print_color() { + printf "${1}${2}${NC}\n" +} + +read_json_value() { + json_file="$1" + key="$2" + sed -n "s/^.*\"$key\": *\"\\(.*\\)\".*$/\\1/p" "$json_file" | sed 's/,$//' +} + +extract_type_and_number() { + branch_name="$1" + + # Extract type (bug, feat, fix, etc.) - take everything before the first slash + branch_type=$(echo "$branch_name" | cut -d'/' -f1) + + # Extract issue number - take the last number sequence in the branch name + issue_number=$(echo "$branch_name" | grep -Eo '[0-9]+$') + + echo "$branch_type|$issue_number" +} + +format_commit_message() { + commit_msg_file="$1" + commit_source="$2" + branch_name=$(git symbolic-ref --short HEAD) + + json_file=$(git rev-parse --show-toplevel)/byul.config.json + if [ ! -f "$json_file" ]; then + print_color "$YELLOW" "Warning: byul.config.json not found. Using default format." + byul_format="[{type}] {commitMessage} #{issueNumber}" + else + byul_format=$(read_json_value "$json_file" "byulFormat") + if [ -z "$byul_format" ]; then + print_color "$YELLOW" "Warning: byulFormat not found in config. Using default format." + byul_format="[{type}] {commitMessage} #{issueNumber}" + fi + fi + + # Extract type and issue number + extracted=$(extract_type_and_number "$branch_name") + branch_type=$(echo "$extracted" | cut -d'|' -f1) + issue_number=$(echo "$extracted" | cut -d'|' -f2) + echo "Branch type: $branch_type" + echo "Issue number: $issue_number" + echo "Commit source: $commit_source" + + if [ -z "$branch_type" ]; then + print_color "$YELLOW" "Could not extract branch type. Skipping formatting." + return 1 + fi + + if [ "$commit_source" = "message" ]; then + # For -m flag commits + first_line=$(head -n 1 "$commit_msg_file") + + formatted_msg=$(echo "$byul_format" | + sed "s/{type}/$branch_type/g" | + sed "s/{commitMessage}/$first_line/g" | + sed "s/{issueNumber}/$issue_number/g") + + echo "$formatted_msg" > "$commit_msg_file.tmp" + tail -n +2 "$commit_msg_file" >> "$commit_msg_file.tmp" + mv "$commit_msg_file.tmp" "$commit_msg_file" + else + # For editor commits + template_start=$(grep -n "^#" "$commit_msg_file" | head -n 1 | cut -d: -f1) + + formatted_msg=$(echo "$byul_format" | + sed "s/{type}/$branch_type/g" | + sed "s/{commitMessage}//g" | + sed "s/{issueNumber}/$issue_number/g") + + formatted_msg=$(echo "$formatted_msg" | sed 's/: */: /g') + + tmp_file="${commit_msg_file}.tmp" + echo "$formatted_msg" > "$tmp_file" + echo "" >> "$tmp_file" + + if [ -n "$template_start" ]; then + tail -n +"$template_start" "$commit_msg_file" >> "$tmp_file" + fi + + mv "$tmp_file" "$commit_msg_file" + fi + + print_color "$GREEN" "✔ Commit message formatted successfully!" + print_color "$BLUE" "New commit message: $formatted_msg" + return 0 +} + +# Check if the commit is a merge, squash, or amend +if [ "$COMMIT_SOURCE" = "merge" ] || [ "$COMMIT_SOURCE" = "squash" ] || [ "$COMMIT_SOURCE" = "commit" ]; then + print_color "$BLUE" "Merge, squash, or amend commit detected. Skipping formatting." + exit 0 +fi + +if format_commit_message "$COMMIT_MSG_FILE" "$COMMIT_SOURCE"; then + exit 0 +else + print_color "$RED" "❌ Failed to format commit message." + exit 0 +fi diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 00000000..5d684c6c --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,41 @@ +feat: + - head-branch: ['^feat/', '^feat-'] + +settings: + - head-branch: ['^settings/', '^settings-'] + +design: + - head-branch: ['^design/', '^design-'] + +fix: + - head-branch: ['^fix/', '^fix-'] + +chore: + - head-branch: ['^chore/', '^chore-'] + +hotfix: + - head-branch: ['^hotfix/', '^hotfix-'] + +style: + - head-branch: ['^style/', '^style-'] + +refactor: + - head-branch: ['^refactor/', '^refactor-'] + +test: + - head-branch: ['^test/', '^test-'] + +docs: + - head-branch: ['^docs/', '^docs-'] + +rename: + - head-branch: ['^rename/', '^rename-'] + +remove: + - head-branch: ['^remove/', '^remove-'] + +release: + - head-branch: ['^release/', '^release-'] + +init: + - head-branch: ['^init/', '^init-'] diff --git a/.github/sonar.yml b/.github/sonar.yml new file mode 100644 index 00000000..1e61edef --- /dev/null +++ b/.github/sonar.yml @@ -0,0 +1,77 @@ +name: SonarQube Analysis Comment + +on: + pull_request: + types: opened + workflow_dispatch: + inputs: + branch: + description: 'Branch to analyze' + required: true + default: 'develop' + +jobs: + sonarqube: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: SonarQube Scan + uses: sonarsource/sonarqube-scan-action@v3 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} + + - name: SonarQube Quality Gate + id: sonarqube-quality-gate + continue-on-error: true + uses: sonarsource/sonarqube-quality-gate-action@master + timeout-minutes: 5 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} + + - name: Get SonarQube Analysis Results + id: sonar-results + run: | + QUALITY_GATE=$(curl --silent -H "Authorization: Bearer ${{ secrets.SONAR_TOKEN }}" "${{ secrets.SONAR_HOST_URL }}api/qualitygates/project_status?projectKey=team4_fe" | jq -r '.projectStatus.status') + echo "QUALITY_GATE=$QUALITY_GATE" >> $GITHUB_OUTPUT + + ISSUES=$(curl --silent -H "Authorization: Bearer ${{ secrets.SONAR_TOKEN }}" "${{ secrets.SONAR_HOST_URL }}api/issues/search?componentKeys=team4_fe&resolved=false" | jq -r '.total') + echo "NEW_ISSUES=$ISSUES" >> $GITHUB_OUTPUT + + # - name: Comment PR + # if: always() + # uses: actions/github-script@v6 + # with: + # script: | + # const qualityGateStatus = "${{ steps.sonar-results.outputs.QUALITY_GATE }}"; + # const newIssues = "${{ steps.sonar-results.outputs.NEW_ISSUES }}"; + + # let statusEmoji; + # if (qualityGateStatus === 'OK') { + # statusEmoji = '✅'; + # } else { + # statusEmoji = '❌'; + # } + + # const comment = `## SonarQube 분석 결과 [ 바로가기 ](${{ secrets.SONAR_HOST_URL }}dashboard?id=team4_fe) + + # ${statusEmoji} Quality Gate : **${qualityGateStatus}** + + # 🛑 코드 품질 이슈 : **${newIssues}** 개 + + + # > 코드 품질향상을 위한 권장사항이며, 수정하지 않아도 무방합니다. + # > 하지만 더 멋진 코드가 될 수 있습니다. 😉 + + # `; + + # await github.rest.issues.createComment({ + # issue_number: context.issue.number, + # owner: context.repo.owner, + # repo: context.repo.repo, + # body: comment + # }); diff --git a/.github/workflows/awsdeploy.yml b/.github/workflows/awsdeploy.yml new file mode 100644 index 00000000..dba19cb7 --- /dev/null +++ b/.github/workflows/awsdeploy.yml @@ -0,0 +1,73 @@ +name: Docker EC2 Deploy + +on: + workflow_dispatch: + # push: + # branches: main + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - name: Checkout Source Code + uses: actions/checkout@v3 + - name: Setup node.js 20.x + uses: actions/setup-node@v3 + with: + node-version: 20.x + cache: "npm" + - name: Install dependencies + run: npm install + + - name: Docker Login + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build and Push Docker Image + run: | + docker build -t frontend:latest . + docker tag frontend:latest popomance/frontend:latest + docker push popomance/frontend:latest + + deploy: + needs: docker + runs-on: ubuntu-latest + steps: + - name: Deploy to EC2 + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_SSH_KEY }} + port: 22 + script: | + sudo docker pull popomance/frontend:latest + sudo docker stop frontend || true + sudo docker rm frontend || true + sudo docker run -d -p 80:80 --name frontend popomance/frontend:latest + sudo docker system prune -f -a + + - name: Notify Slack - Success + if: success() + run: | + curl -X POST -H 'Content-type: application/json' --data '{ + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":ballot_box_with_check: 프론트엔드 배포가 성공적으로 완료되었습니다. " + } + } + ] + }' ${{ secrets.SLACK_WEBHOOK_URL }} + + - name: Notify Slack - Failure + if: failure() + run: | + # curl -X POST -H 'Content-type: application/json' --data '{ + # "text": ":x: 프론트엔드 배포 중 오류가 발생했습니다." + # }' ${{ secrets.SLACK_WEBHOOK_URL }} + \ No newline at end of file diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml new file mode 100644 index 00000000..553546cb --- /dev/null +++ b/.github/workflows/chromatic.yml @@ -0,0 +1,48 @@ +name: 'Chromatic' + +on: + pull_request: + types: [opened] + paths: + - 'src/stories/**' + +permissions: + contents: read + pull-requests: write + +jobs: + chromatic: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Install dependencies + run: npm install + + - name: Publish to Chromatic + id: chromatic + uses: chromaui/action@v1 + with: + projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} + exitOnceUploaded: true + autoAcceptChanges: 'develop' + onlyChanged: true + + - name: Find Comment + uses: peter-evans/find-comment@v2 + id: fc + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: 'github-actions[bot]' + body-includes: 'Storybook' + + - name: Create or Update Comment + uses: peter-evans/create-or-update-comment@v3 + with: + comment-id: ${{ steps.fc.outputs.comment-id }} + issue-number: ${{ github.event.pull_request.number }} + body: | + ## Storybook 확인 [ 바로가기 ](${{ steps.chromatic.outputs.storybookUrl }}) + edit-mode: replace diff --git a/.github/workflows/firebase-hosting-merge.yml b/.github/workflows/firebase-hosting-merge.yml new file mode 100644 index 00000000..cd732680 --- /dev/null +++ b/.github/workflows/firebase-hosting-merge.yml @@ -0,0 +1,27 @@ +# This file was auto-generated by the Firebase CLI +# https://github.com/firebase/firebase-tools + +name: Deploy to Firebase Hosting on merge to develop +on: + push: + branches: + - main +jobs: + build_and_deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.1.10 + - run: bun install + - name: Create .env file + run: echo "${{ secrets.ENV_FILE }}" > .env + - name: Build and Deploy + run: bun run build + - uses: FirebaseExtended/action-hosting-deploy@v0 + with: + repoToken: ${{ secrets.GITHUB_TOKEN }} + firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_POPOMANCE_STUDIO }} + channelId: live + projectId: popomance-studio diff --git a/.github/workflows/firebase-hosting-pull-request.yml b/.github/workflows/firebase-hosting-pull-request.yml new file mode 100644 index 00000000..a86b0d0c --- /dev/null +++ b/.github/workflows/firebase-hosting-pull-request.yml @@ -0,0 +1,30 @@ +# This file was auto-generated by the Firebase CLI +# https://github.com/firebase/firebase-tools + +name: Deploy to Firebase Hosting on PR Preview +on: pull_request +permissions: + checks: write + contents: read + pull-requests: write +jobs: + build_and_preview: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.1.10 + - name: Create .env file + run: echo "${{ secrets.ENV_FILE }}" > .env + - run: bun install + - name: Build and Deploy + run: bun run build + - uses: FirebaseExtended/action-hosting-deploy@v0 + with: + repoToken: ${{ secrets.GITHUB_TOKEN }} + firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_POPOMANCE_STUDIO }} + projectId: popomance-studio + expires: 7d diff --git a/.github/workflows/pr-auto.yml b/.github/workflows/pr-auto.yml new file mode 100644 index 00000000..8b2ec995 --- /dev/null +++ b/.github/workflows/pr-auto.yml @@ -0,0 +1,33 @@ +name: PR Auto Assign and Labeler + +on: + pull_request: + types: [opened, reopened, synchronize] + +permissions: + contents: read + pull-requests: write + +jobs: + pr-auto: + name: PR Automation + runs-on: ubuntu-latest + steps: + - uses: actions/add-to-project@v1.0.2 + continue-on-error: true + with: + project-url: https://github.com/orgs/FC-DEV-FinalProject/projects/2 + github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} + labeled: feat, setting, fix, docs, refactor, test + label-operator: OR + + - uses: kentaro-m/auto-assign-action@v2.0.0 + continue-on-error: true + with: + configuration-path: '.github/auto_assign.yml' + + - uses: actions/labeler@v5 + continue-on-error: true + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + configuration-path: .github/labeler.yml diff --git a/.gitignore b/.gitignore index c6bba591..3db40145 100644 --- a/.gitignore +++ b/.gitignore @@ -4,127 +4,45 @@ logs npm-debug.log* yarn-debug.log* yarn-error.log* +pnpm-debug.log* lerna-debug.log* -.pnpm-debug.log* +*storybook.log -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# Snowpack dependency directory (https://snowpack.dev/) -web_modules/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional stylelint cache -.stylelintcache - -# Microbundle cache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variable files +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# tmp +.tmp +tmp +temp + +# env .env -.env.development.local -.env.test.local -.env.production.local .env.local +.env.development.local -# parcel-bundler cache (https://parceljs.org/) -.cache -.parcel-cache - -# Next.js build output -.next -out - -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and not Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# vuepress v2.x temp and cache directory -.temp -.cache - -# Docusaurus cache and generated files -.docusaurus - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# TernJS port file -.tern-port +# vscode +.vscode/* +!.vscode/settings.json +!.vscode/extensions.json +!typescriptreact.code-snippets -# Stores VSCode versions used for testing VSCode extensions -.vscode-test +# typescript +*.tsbuildinfo -# yarn v2 -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* +# Storybook +storybook-static +build-storybook.log diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 00000000..fb85fde3 --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,35 @@ +import type { StorybookConfig } from '@storybook/react-vite'; +import path from 'path'; + +const config: StorybookConfig = { + stories: ['../src/stories/**/*.stories.@(js|jsx|ts|tsx)'], + + addons: ['@storybook/addon-essentials', '@storybook/addon-themes', '@chromatic-com/storybook'], + + framework: { + name: '@storybook/react-vite', + options: {}, + }, + + docs: { + autodocs: 'tag', + }, + + viteFinal: async (config) => { + if (!config.resolve) { + config.resolve = {}; + } + if (!config.resolve.alias) { + config.resolve.alias = {}; + } + + config.resolve.alias = { + ...config.resolve.alias, + '@': path.resolve(__dirname, '../src'), + }; + + return config; + }, +}; + +export default config; diff --git a/.storybook/preview.ts b/.storybook/preview.ts new file mode 100644 index 00000000..fa090f11 --- /dev/null +++ b/.storybook/preview.ts @@ -0,0 +1,16 @@ +import '@/index.css'; + +import type { Preview } from '@storybook/react'; + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + }, +}; + +export default preview; diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..77b70dce --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint", + "bradlc.vscode-tailwindcss", + "csstools.postcss", + "GitHub.vscode-pull-request-github", + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..a78bdbb6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,21 @@ +{ + "editor.defaultFormatter": "dbaeumer.vscode-eslint", + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "editor.formatOnSave": true, + "prettier.requireConfig": true, + "files.eol": "\n", + "files.insertFinalNewline": true, + "typescript.preferences.importModuleSpecifier": "non-relative", + "javascript.preferences.importModuleSpecifier": "non-relative", + "typescript.preferences.importModuleSpecifierEnding": "minimal", + "javascript.preferences.importModuleSpecifierEnding": "minimal", + "tailwindCSS.emmetCompletions": true, + "files.associations": { + "*.css": "tailwindcss" + }, + "editor.quickSuggestions": { + "strings": "on" + } +} diff --git a/.vscode/typescriptreact.code-snippets b/.vscode/typescriptreact.code-snippets new file mode 100644 index 00000000..098251e2 --- /dev/null +++ b/.vscode/typescriptreact.code-snippets @@ -0,0 +1,50 @@ +{ + "React Functional Component": { + "prefix": "rfc", + "scope": "typescriptreact", + "body": [ + "interface ${1:${TM_FILENAME_BASE}}Props {", + " $2", + "}", + "", + "const ${1:${TM_FILENAME_BASE}}: React.FC<${1:${TM_FILENAME_BASE}}Props> = ({$3}) => {", + " return (", + "
$4
", + " )", + "}", + "", + "export default ${1:${TM_FILENAME_BASE}}", + ], + "description": "React Functional Component with TypeScript", + }, + "React Functional Component Simple": { + "prefix": "rfcs", + "scope": "typescriptreact", + "body": [ + "const ${1:${TM_FILENAME_BASE}} = () => {", + " return (", + "
$2
", + " )", + "}", + "", + "export default ${1:${TM_FILENAME_BASE}}", + ], + }, + "React Functional Component with Props": { + "prefix": "rfcp", + "scope": "typescriptreact", + "body": [ + "interface ${1:${TM_FILENAME_BASE}}Props {", + " $1", + "}", + "", + "const ${1:${TM_FILENAME_BASE}} = ({$2}: Props) => {", + " return (", + "
$3
", + " )", + "}", + "", + "export default ${1:${TM_FILENAME_BASE}}", + ], + }, +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..919ab47e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM node:20-alpine AS build + +WORKDIR /app +COPY package*.json ./ + +RUN sed -i 's/"prepare": "lefthook install"/"prepare": ""/' package.json +RUN npm ci +COPY . . +RUN npm run docker + +FROM nginx:stable-alpine +COPY --from=build /app/dist /usr/share/nginx/html + +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/README.md b/README.md index c24e1cdd..05e60540 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,113 @@ -# FinalTeam_II_fe-team4 -4조 FE팀 깃허브 +# popomance.studio 오디오 변환 플랫폼 + TTS/VC/CONCAT 오디오 서비스 구현을 위한 리포지토리 + +[배포 주소 링크](https://popomance.kr/) +[스토리북 배포 링크](https://673559d8ad04dea21f3f8013-pjizwnrelv.chromatic.com/) + +## 목차 +- [popomance.studio 오디오 변환 플랫폼](#popomancestudio-오디오-변환-플랫폼) + - [목차](#목차) + - [제작 기간 \& 참여 인원](#제작-기간--참여-인원) + - [폴더 구조도](#폴더-구조도) + - [설치 및 실행 방법](#설치-및-실행-방법) + - [사용한 기술](#사용한-기술) + - [프론트엔드](#프론트엔드) + - [CI/CD](#cicd) + - [핵심 기능](#핵심-기능) + - [TTS 기능](#tts-기능) + - [VC 기능](#vc-기능) + - [CONCAT 기능](#concat-기능) + - [프로젝트 관리 기능](#프로젝트-관리-기능) + - [아키텍처](#아키텍처) + - [시스템 아키텍처](#시스템-아키텍처) + - [특징](#특징) + +## 제작 기간 & 참여 인원 +- 제작 기간: 2024년 11월 01일 ~ 2024년 12월 06일 + +| Name | GitHub | +|------|--------| +| 김도형 | @dhkim511 | +| 김승민 | @miniseung | +| 임효정 | @dyeongg | +| 송진 | @sjgaru-dev | +| 고낙연 | @nakyenoko3 | + + +## 폴더 구조도 + +## 설치 및 실행 방법 +1. 저장소를 클론합니다 + +```bash +git clone https://github.com/your-repo/popomance.studio.git +cd popomance.studio +``` + +2. 필요한 패키지를 설치합니다 +```bash +npm install +``` + +3. 개발 서버를 실행합니다 +```bash +npm run dev +``` + + +## 사용한 기술 +### 프론트엔드 +- React ^18.3.1 +- TypeScript 5.3.2 +- Tailwind CSS ^3.4.14 +- shadcn/ui (Radix UI 기반: ^1.1.0 ~ ^2.1.2) +- Storybook ^8.4.4 +- zustand ^5.0.1 +- axios ^1.7.7 + + +### CI/CD +- GitHub Actions +- AWS Amplify +- Firebase Hosting +- EC2 +- Nginx + +## 핵심 기능 + +### TTS 기능 +- 사용자가 입력한 텍스트를 음성으로 변환 +- 여러 줄의 텍스트를 한 번에 텍스트 파일을 통해 입력 가능 +- 이전에 변환된 음성 파일에 대한 히스토리 기능 + +![](https://i.imgur.com/kH4XRBQ.gif) + + +### VC 기능 +- 사용자가 업로드한 음성 파일을 다른 화자의 음성 스타일로 변환 +- 사용자가 원하는 화자의 음성 스타일을 선택 가능 + +![](https://i.imgur.com/fVuUZ7z.gif) + +### CONCAT 기능 +- 음성 파일을 합치고, 무음을 추가하여 음성 파일을 생성 +- 여러 음성 파일에 한 번에 구간별 무음 추가 기능 + +![](https://i.imgur.com/LSF0Yar.gif) + +### 프로젝트 관리 기능 +- 삭제 및 조회 +- 필터링 +- 검색 + +## 아키텍처 + +## 시스템 아키텍처 + +## 특징 +- CI/CD 파이프라인 구축 +- Storybook을 통한 컴포넌트 개발 +- shadcn/ui 라이브러리 도입 +- 각종 자동화 스크립트 구축 + + diff --git a/bun.lockb b/bun.lockb new file mode 100644 index 00000000..b50a31ed Binary files /dev/null and b/bun.lockb differ diff --git a/byul.config.json b/byul.config.json new file mode 100644 index 00000000..2dd6cd08 --- /dev/null +++ b/byul.config.json @@ -0,0 +1,4 @@ +{ + "byulFormat": "[{type}] {commitMessage} #{issueNumber}" +} + diff --git a/components.json b/components.json new file mode 100644 index 00000000..e2c49eff --- /dev/null +++ b/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/docs/middle.md b/docs/middle.md new file mode 100644 index 00000000..3d89f140 --- /dev/null +++ b/docs/middle.md @@ -0,0 +1,126 @@ +# 4조: 퍼포먼스 +- 프로젝트 중간 발표 +- 2024년 11월 8일 + +--- +# 목차 + +- 프로젝트 소개 +- 주요 기능 +- 기술 스텍 +- 협업 방식 & 그룹원 소개 +- 향후 계획 + +## 오디오 서비스 설명🎯 + +- **TTS: 텍스트를 오디오로 변환** +- **VC: 음성을 다른 목소리, 음향으로 변환** +- **CONCAT: 두개 이상의 음성을 하나로 합치기** + +![Image](https://i.imgur.com/RKd84ij.png) + +## 주요 사용자 +### 콘텐츠 크리에이터 +- 니즈: 다양한 음성으로 콘텐츠를 제작함. +- 뉴스 제작, 오디오북 제작, 영상 콘텐츠에 사용되는 음성 사용 +- 주요 사용 기능: TTS, VC, CONCAT + +### 일반 사용자 +- 니즈: 간단한 음성 변환과 편집 +- 학교 과제 등 음성 변환, 일시적인 사용 +- 주요 사용 기능: TTS, VC + +## 경쟁 서비스 분석 +- Motion Array, resemble.ai, elevenlabs.io +- 다량의 텍스트를 변환하기에는 불편함. +- `한글 콘텐츠`를 만들기에는 어려운 점들 +- 사용법이 다소 어려움. 설명이 부족함. +![bg right:50% h:400](https://i.imgur.com/6iXk63V.png) + +## 프로젝트 목표 +- TTS/VC/CONCAT 서비스를 **더 편하게 사용하도록 만들기** +- `콘텐츠 크리에이터`에게 적합하도록 **UI/UX개선하기** + + +## 2. 기능 정의서 ⚙️ + +#### TTS +- 2가지 뷰모드: 사용자가 편한 UI 모드를 선택 +- 세부 설정: 피치/볼륨/속도를 세부 조정 +- 선택 적용/부분적용: 일부만 TTS 세부 설정을 적용할 수 있도록함. + +![bg right:50% h:300 width:900px fit](https://i.imgur.com/iSUAGVc.png) + +![Image](https://i.imgur.com/9LdjqDe.png) + +#### VC +- 선택 적용/부분적용: 일부만 VC 세부 설정을 적용할 수 있도록함. +- 타겟 음성 추가: 기존에 등록한 음성 또는 업로드한 음성으로 원본 음성을 변환 +![bg right:60% h:300 width:900px fit](https://i.imgur.com/ko97nju.png) + +#### CONCAT +- 각 음원 별 무음 추가 기능 +- 맨 앞 무음 추가 +- 맨 뒤 무음 추가 + +![bg right:60% h:300 width:900px fit](https://i.imgur.com/2S8mAOI.png) + + +### 페이지 공통 기능 +- 텍스트 파일 업로드: 대량의 텍스트를 업로드할 수 있도록함. +- 프로젝트 탭 기능: 여러 프로젝트를 동시에 진행할 수 있도록함. +- 저장: 현재 프로젝트 탭의 상태를 저장 버튼을 눌러서 저장 +- 자동 저장: 현재 프로젝트 탭의 작업 상태를 10분에 한번씩 자동 저장 + +![bg right:50% h:300 width:900px fit](https://i.imgur.com/TrWrAhn.png) + +### 선택 추가 기능 +- TTS/VC/CONCAT 동시 편집 페이지 +- 데스크톱앱 개발 + +![Image](https://i.imgur.com/UmmPpOF.png) + + +### 3. 기술 스택 💻 + +### FE +- React: 18.3.1 +- TypeScript: 5.3.2 +- Tailwindcss: 3.4.14 +- Radix-ui + +### BE +- Java: 17 +- MySql +- JPA + +--- +### FE 작업 방식 + +- 문서화보다는 동작하는 소프트웨어 +- 디자인 시안이 나오면 바로 작업 진행 +- 세부 시안이 나오면 해당 시안에 맞게 코드 수정 +- PR 즉시 배포하고, 배포된 웹사이트를 BE와 디자이너님께 전달 +- 자동화 도구 적극적으로 이용 +- 최대한 작업은 작게 쪼개서 빠르게 PR후 merge하기 + +``` +디자인 시안 -> 페이지 작업 -> 상세 디자인 -> 페이지 수정 -> 스테이징 배포 +``` +--- + +### 자동화 도구 +- AI 코드 리뷰 +- 그외 자동화 도구(자동 라벨링, 커밋 포맷팅, 자동 배포) + +![Image](https://i.imgur.com/RqtxfYm.png) + +--- +### 협업 방식 +- 매주 월/목 팀장 회의 +- 평일 10시 BE/FE 나눠서 회의후, 정리된 회의록을 공유 + +--- + +# 감사합니다 👋 +## Q&A diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 00000000..125f1b85 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,48 @@ +import js from '@eslint/js'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import tseslint from 'typescript-eslint'; +import eslintPluginPrettier from 'eslint-plugin-prettier/recommended'; +import eslintPluginImport from 'eslint-plugin-import'; +import pluginQuery from '@tanstack/eslint-plugin-query'; +import simpleImportSort from 'eslint-plugin-simple-import-sort'; + +export default [ + js.configs.recommended, + ...tseslint.configs.recommended, + ...pluginQuery.configs['flat/recommended'], + { ignores: ['dist', 'temp'] }, + { + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + import: eslintPluginImport, + 'simple-import-sort': simpleImportSort, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': 'off', + 'prettier/prettier': ['error', { endOfLine: 'lf' }], + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + 'import/no-unresolved': 'error', + 'import/named': 'error', + 'simple-import-sort/imports': 'error', + 'simple-import-sort/exports': 'error', + }, + settings: { + 'import/resolver': { + typescript: { + project: './tsconfig.json', + }, + }, + }, + }, + eslintPluginPrettier, +]; diff --git a/firebase.json b/firebase.json new file mode 100644 index 00000000..1195e36f --- /dev/null +++ b/firebase.json @@ -0,0 +1,17 @@ +{ + "hosting": { + "public": "dist", + "ignore": [ + "firebase.json", + "**/.*", + "**/node_modules/**" + ], + "rewrites": [ + { + "source": "**", + "destination": "/index.html" + } + ] + } + +} diff --git a/index.html b/index.html new file mode 100644 index 00000000..54fd6e8f --- /dev/null +++ b/index.html @@ -0,0 +1,14 @@ + + + + + + + popomance.studio + + + +
+ + + diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 00000000..e9cf6a9a --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,57 @@ +# EXAMPLE USAGE: +# +# Refer for explanation to following link: +# https://github.com/evilmartians/lefthook/blob/master/docs/configuration.md +# +# pre-push: +# commands: +# packages-audit: +# tags: frontend security +# run: yarn audit +# gems-audit: +# tags: backend security +# run: bundle audit +# + +pre-push: + commands: + type-check: + run: npm run type-check + +prepare-commit-msg: + commands: + commitlint: + runner: bash + skip: + - "[[ ! -f .env ]] || ! grep -q '^byulBash=true' .env" + run: 'bash "./.github/hooks/commitlint.sh" {1} {2}' + +pre-commit: + parallel: true + commands: + eslint: + glob: '*.{js,ts,jsx,tsx}' + run: npm run lint {staged_files} + # type-check: + # run: npm run type-check +# pre-commit: +# parallel: true +# commands: +# eslint: +# glob: "*.{js,ts,jsx,tsx}" +# run: yarn eslint {staged_files} +# rubocop: +# tags: backend style +# glob: "*.rb" +# exclude: '(^|/)(application|routes)\.rb$' +# run: bundle exec rubocop --force-exclusion {all_files} +# govet: +# tags: backend style +# files: git ls-files -m +# glob: "*.go" +# run: go vet {files} +# scripts: +# "hello.js": +# runner: node +# "any.go": +# runner: go run diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 00000000..b6981136 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,12 @@ +server { + listen 80; + server_name popomance.kr www.popomance.kr; + + location / { + root /usr/share/nginx/html; + try_files $uri $uri/ /index.html; + index index.html; + } + + error_page 404 /index.html; +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..693f8c63 --- /dev/null +++ b/package.json @@ -0,0 +1,112 @@ +{ + "name": "final-fe-team4", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -p tsconfig.app.json && vite build", + "lint": "eslint . --fix", + "type-check": "tsc --noEmit -p tsconfig.app.json --incremental", + "preview": "vite preview", + "prepare": "lefthook install", + "docker": "vite build", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build", + "chromatic": "chromatic", + "orval": "orval --config ./orval.config.cjs", + "api-docs": "widdershins --search false ./openapi.json --language_tabs 'shell:Shell' 'javascript:JavaScript' -o ./docs/api.md " + }, + "dependencies": { + "@dnd-kit/core": "^6.2.0", + "@dnd-kit/sortable": "^9.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@hookform/resolvers": "^3.9.1", + "@radix-ui/react-accordion": "^1.2.1", + "@radix-ui/react-avatar": "^1.1.1", + "@radix-ui/react-checkbox": "^1.1.2", + "@radix-ui/react-collapsible": "^1.1.1", + "@radix-ui/react-context": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-dropdown-menu": "^2.1.2", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-radio-group": "^1.2.1", + "@radix-ui/react-scroll-area": "^1.2.0", + "@radix-ui/react-select": "^2.1.2", + "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-slider": "^1.2.1", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-switch": "^1.1.1", + "@radix-ui/react-tabs": "^1.1.1", + "@radix-ui/react-toast": "^1.2.2", + "@radix-ui/react-tooltip": "^1.1.4", + "@radix-ui/react-use-callback-ref": "^1.1.0", + "@tabler/icons-react": "^3.21.0", + "@tanstack/react-query": "^5.59.16", + "axios": "^1.7.7", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "dayjs": "^1.11.13", + "lodash.throttle": "^4.1.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-hook-form": "^7.53.2", + "react-icons": "^5.3.0", + "react-router-dom": "^6.27.0", + "relative-time": "^1.0.0", + "tailwind-merge": "^2.5.4", + "tailwindcss-animate": "^1.0.7", + "wavesurfer.js": "^7.8.8", + "zod": "^3.23.8", + "zustand": "^5.0.1" + }, + "devDependencies": { + "@chromatic-com/storybook": "^3.2.2", + "@eslint/js": "^9.13.0", + "@storybook/addon-a11y": "^8.4.4", + "@storybook/addon-essentials": "^8.4.4", + "@storybook/addon-interactions": "^8.4.4", + "@storybook/addon-links": "^8.4.4", + "@storybook/addon-onboarding": "^8.4.4", + "@storybook/addon-themes": "^8.4.4", + "@storybook/blocks": "^8.4.4", + "@storybook/react": "^8.4.4", + "@storybook/react-vite": "^8.4.4", + "@storybook/test": "^8.4.4", + "@tanstack/eslint-plugin-query": "^5.59.7", + "@types/lodash": "^4.17.13", + "@types/lodash.throttle": "^4.1.9", + "@types/node": "^22.8.4", + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.1", + "@types/react-router-dom": "^5.3.3", + "@types/wavesurfer.js": "^6.0.12", + "@vitejs/plugin-react-swc": "^3.5.0", + "autoprefixer": "^10.4.20", + "chromatic": "^11.18.1", + "eslint": "^9.13.0", + "eslint-config-prettier": "^9.1.0", + "eslint-import-resolver-typescript": "^3.6.3", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-prettier": "^5.2.1", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.13", + "eslint-plugin-simple-import-sort": "^12.1.1", + "eslint-plugin-storybook": "^0.11.0", + "globals": "^15.11.0", + "lefthook": "^1.8.2", + "orval": "^7.3.0", + "postcss": "^8.4.47", + "prettier": "3.3.3", + "storybook": "^8.4.4", + "tailwindcss": "^3.4.14", + "typescript": "5.3.2", + "typescript-eslint": "^8.10.0", + "vite": "^5.4.9" + }, + "eslintConfig": { + "extends": [ + "plugin:storybook/recommended" + ] + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 00000000..2aa7205d --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/prettier.config.js b/prettier.config.js new file mode 100644 index 00000000..7c2f9ac1 --- /dev/null +++ b/prettier.config.js @@ -0,0 +1,13 @@ +/** @type {import("prettier").Config} */ +export default { + semi: true, + tabWidth: 2, + printWidth: 100, + singleQuote: true, + jsxSingleQuote: false, + trailingComma: 'es5', + bracketSpacing: true, + bracketSameLine: false, + arrowParens: 'always', + endOfLine: 'lf', +}; diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 00000000..d0740a45 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,3 @@ +sonar.projectKey=team4_fe +sonar.sources=. +sonar.exclusions=**/*config*.js,**/*config*.json diff --git a/src/api/aIParkAPI.schemas.ts b/src/api/aIParkAPI.schemas.ts new file mode 100644 index 00000000..1e541ecd --- /dev/null +++ b/src/api/aIParkAPI.schemas.ts @@ -0,0 +1,364 @@ +/** + * Generated by orval v7.3.0 🍺 + * Do not edit manually. + * AIPark API + * 기업연계 파이널 프로젝트 API 문서 백엔드 개발용 + * OpenAPI spec version: 1.0.0 + */ +export type DownloadGeneratedAudio4Params = { + bucketRoute: string; +}; + +export type DownloadGeneratedAudio3Params = { + bucketRoute: string; +}; + +export type DownloadGeneratedAudio2Params = { + bucketRoute: string; +}; + +export type DownloadGeneratedAudio1Params = { + bucketRoute: string; +}; + +export type DownloadGeneratedAudioParams = { + bucketRoute: string; +}; + +export type TestFailParams = { + 'Do Would you like to throw an exception?': string; +}; + +export type ConvertMultipleAudiosBody = { + concatRequestDto: ConcatRequestDto; + /** 업로드할 파일들 */ + files: Blob[]; +}; + +export type UploadConcatBody = { + file: Blob; +}; + +export type UploadConcatParams = { + projectId: number; +}; + +export type UploadFiles1Body = { + files: Blob[]; +}; + +export type UploadFiles1Params = { + memberId: number; + projectId: number; + audioType: string; + voiceId: string; +}; + +export type UploadFilesBody = { + files: Blob[]; +}; + +export type UploadFilesParams = { + memberId: number; + projectId: number; + audioType: string; + voiceId: string; +}; + +export type UploadUnit1Body = { + file: Blob; +}; + +export type UploadUnit1Params = { + detailId: number; + projectId: number; +}; + +export type UploadUnitBody = { + file: Blob; +}; + +export type UploadUnitParams = { + detailId: number; + projectId: number; +}; + +export type ProcessVCProjectBody = { + files: Blob[]; + vcSaveDto: VCSaveDto; +}; + +export type ProcessVCProjectParams = { + memberId: number; +}; + +export type SaveVCProjectBody = { + file?: Blob[]; + metadata: VCSaveDto; +}; + +export type DataResponseDtoData = { [key: string]: unknown }; + +export interface DataResponseDto { + code?: number; + data?: DataResponseDtoData; + message?: string; + success?: boolean; +} + +export interface ConcatRequestDetailDto { + audioSeq?: number; + checked?: boolean; + endSilence?: number; + id?: number; + sourceAudio?: Blob; + unitScript?: string; +} + +/** + * 요청 DTO + */ +export interface ConcatRequestDto { + concatRequestDetails?: ConcatRequestDetailDto[]; + globalFrontSilenceLength?: number; + globalTotalSilenceLength?: number; + memberId?: number; + projectId?: number; + projectName?: string; +} + +export type MemberAudioMetaAudioType = + (typeof MemberAudioMetaAudioType)[keyof typeof MemberAudioMetaAudioType]; + +export const MemberAudioMetaAudioType = { + VC_SRC: 'VC_SRC', + VC_TRG: 'VC_TRG', + CONCAT: 'CONCAT', +} as const; + +export type MemberAudioMetaAudioFormat = + (typeof MemberAudioMetaAudioFormat)[keyof typeof MemberAudioMetaAudioFormat]; + +export const MemberAudioMetaAudioFormat = { + WAV: 'WAV', + MP3: 'MP3', +} as const; + +export interface Member { + birthDate?: string; + createdAt?: string; + createdBy?: string; + createdDate?: string; + deletedAt?: string; + email?: string; + gender?: number; + id?: number; + is_deleted?: boolean; + lastModifiedBy?: string; + lastModifiedDate?: string; + name?: string; + phoneNumber?: string; + pwd?: string; + tou?: string; + updatedAt?: string; +} + +export interface MemberAudioMeta { + audioFormat?: MemberAudioMetaAudioFormat; + audioType?: MemberAudioMetaAudioType; + audioUrl?: string; + bucketRoute?: string; + createdAt?: string; + createdBy?: string; + createdDate?: string; + deletedAt?: string; + id?: number; + isDeleted?: boolean; + lastModifiedBy?: string; + lastModifiedDate?: string; + member?: Member; + script?: string; + trgVoiceId?: string; +} + +export interface ConcatDetailDto { + audioSeq?: number; + checked?: boolean; + endSilence?: number; + id?: number | null; + memberAudioMeta?: MemberAudioMeta; + unitScript?: string; +} + +export interface ConcatSaveDto { + concatDetails?: ConcatDetailDto[]; + globalFrontSilenceLength?: number; + globalTotalSilenceLength?: number; + projectId?: number | null; + projectName?: string; +} + +export interface TTSProject { + id: number; + projectName: string; + apiStatus: string; + fullScript: string; + globalPitch: number; + globalSpeed: number; + globalVolume: number; + voiceStyleId: number | null; +} + +export interface TTSDetailDto { + id: number | null; + isDeleted: boolean; + unitPitch?: number; + unitScript?: string; + unitSequence: number; + unitSpeed?: number; + unitVoiceStyleId: number | null; + unitVolume?: number; + genAudios?: [{ id: number; audioUrl: string }]; +} + +export interface TTSSaveDto { + fullScript?: string; + globalPitch?: number; + globalSpeed?: number; + globalVoiceStyleId?: number; + globalVolume?: number; + memberId?: number; + projectId: number | null; + projectName?: string; + ttsDetails?: TTSDetailDto[]; +} + +export interface TTSSpecificResponse { + ttsProject: TTSProject; + ttsDetails: TTSDetailDto[]; +} + +export interface ResponseDto { + success: boolean; + code: number; + message: string; + data?: T; +} + +export type AudioFileDtoAudioType = + (typeof AudioFileDtoAudioType)[keyof typeof AudioFileDtoAudioType]; + +export const AudioFileDtoAudioType = { + VC_SRC: 'VC_SRC', + VC_TRG: 'VC_TRG', + CONCAT: 'CONCAT', +} as const; + +export interface AudioFileDto { + detailId?: number | null; + localFileName?: string | null; + unitScript?: string; + isChecked?: boolean; + audioType?: 'VC_SRC' | 'VC_TRG' | 'CONCAT'; + s3MemberAudioMetaId?: number | null; +} + +export interface VCSaveDto { + projectId: number | null; + projectName: string; + srcFiles: VCSrcFile[]; + trgFiles: VCTrgFile[]; +} + +export interface Project { + projectId: number; + projectType: string; + projectName: string; + script?: string; + projectStatus?: string; + updatedAt: string; + createdAt?: string; +} + +export interface ProjectsResponse { + content: Project[]; + pageable: { + pageNumber: number; + pageSize: number; + }; + totalPages: number; + totalElements: number; +} + +export interface WorkspaceProject { + id: number; + type: string; + name: string; + script?: string; + status?: string; + updatedAt: string; + createdAt?: string; +} + +export type workspacesResponse = WorkspaceProject[]; + +export interface Export { + projectId?: number; + url?: string; + metaId?: number; + fileName: string; + downloadLink?: string; + unitStatus: string; + projectName: string; + projectType: string; + script: string | null; + createdAt?: string; + updatedAt?: string; +} + +// VC 관련 타입 정의 수정 +export interface VCAudioMeta { + id: number; + audioUrl: string; +} + +export interface VCProjectResponse { + id: number; + projectName: string; + trgAudios: VCAudioMeta[]; +} + +export interface VCDetailResponse { + id: number; + projectId: number; + isChecked: boolean; + unitScript: string; + srcAudio: string | null; + genAudios: VCAudioMeta[]; +} + +export interface VCLoadResponse { + vcProjectRes: VCProjectResponse; + vcDetailsRes: VCDetailResponse[]; +} + +export interface VCSrcFile { + detailId: number | null; + localFileName: string | null; + unitScript: string; + isChecked: boolean; +} + +export interface VCTrgFile { + audioType: string; + localFileName: string | null; + s3MemberAudioMetaId: number | null; +} + +// ResponseDto 수정 (기존 정의 교체) +export interface ResponseDto { + success: boolean; + code: number; + message: string; + data?: T; +} diff --git a/src/api/authAPI.ts b/src/api/authAPI.ts new file mode 100644 index 00000000..06cf021c --- /dev/null +++ b/src/api/authAPI.ts @@ -0,0 +1,54 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { AxiosError } from 'axios'; + +import { customInstance } from './axios-client'; + +interface SignupRequest { + email: string; + name: string; + pwd: string; + pwdConfirm: string; + phoneNumber: string; + terms: string; +} +export const login = async (email: string, pwd: string) => { + try { + const data = await customInstance.post('/member/login', { + email, + pwd, + }); + return data; + } catch (error: unknown) { + if (error instanceof AxiosError) { + const errorMessage = + error.response?.data?.message || '로그인 요청에 실패했습니다. 다시 시도해주세요.'; + throw new Error(errorMessage); + } + if (error instanceof Error) { + throw new Error(error.message || '알 수 없는 오류가 발생했습니다.'); + } + throw new Error('알 수 없는 오류가 발생했습니다.'); + } +}; + +export const logout = async () => { + try { + await customInstance.post('/member/logout'); + } catch { + throw new Error('로그아웃 요청에 실패했습니다. 다시 시도해주세요.'); + } +}; + +export const signup = async (signupRequest: SignupRequest) => { + try { + const response = await customInstance.post('/member/signup', signupRequest); + console.log('회원가입 서버 응답:', response); + return response; + } catch (error) { + console.error('회원가입 요청 실패:', error); + const errorMessage = + (error as any).response?.data?.message || '회원가입에 실패했습니다. 다시 시도해주세요.'; + throw new Error(errorMessage); + } +}; diff --git a/src/api/axios-client.ts b/src/api/axios-client.ts new file mode 100644 index 00000000..592fa326 --- /dev/null +++ b/src/api/axios-client.ts @@ -0,0 +1,38 @@ +import axios, { AxiosInstance } from 'axios'; + +export const BASE_URL = import.meta.env.VITE_API_URL; +console.log('BASE_URL', BASE_URL); +if (!BASE_URL) { + throw new Error('VITE_API_URL is not defined'); +} + +// Axios 인스턴스 생성 +export const customInstance: AxiosInstance = axios.create({ + baseURL: BASE_URL, + withCredentials: true, // 세션 쿠키를 자동으로 포함 + headers: { + 'Content-Type': 'application/json', + }, +}); + +// 요청 인터셉터 +customInstance.interceptors.request.use( + (config) => { + // 세션 기반 인증에서는 Authorization 헤더가 필요 없습니다. + return config; + }, + (error) => { + return Promise.reject(error); + } +); + +// 응답 인터셉터 +customInstance.interceptors.response.use( + (response) => { + // 응답 데이터를 바로 반환 + return response.data; + }, + (error) => { + return Promise.reject(error); + } +); diff --git a/src/api/concatAPI.ts b/src/api/concatAPI.ts new file mode 100644 index 00000000..1b7f6c01 --- /dev/null +++ b/src/api/concatAPI.ts @@ -0,0 +1,211 @@ +import { customInstance } from '@/api/axios-client'; +export interface ConcatSaveDto { + projectId: number | null; + projectName: string; + globalFrontSilenceLength: number; + globalTotalSilenceLength: number; + concatDetails: { + id: number | null; + localFileName: string; + audioSeq: number; + isChecked: boolean; + unitScript: string; + endSilence: number; + }[]; +} +// 프로젝트 기본 정보 타입 +export interface ConcatProjectDto { + id: number; + projectName: string; + globalFrontSilenceLength: number; + globalTotalSilenceLength: number; + concatAudios: Array<{ + id: number; + audioUrl: string; + }>; +} +// 상세 정보 타입 +export interface ConcatDetailDto { + id: number; + audioSeq: number; + srcUrl: string; + unitScript: string; + endSilence: number; + checked: boolean; +} +// API 데이터 구조 +export interface ConcatData { + cnctProjectDto: ConcatProjectDto; + cnctDetailDtos: ConcatDetailDto[]; +} +// Save API 요청 데이터 구조 +interface ConcatSaveRequest { + concatSaveDto: ConcatSaveDto; + file?: File[]; +} +// Concat 삭제 요청 타입 +interface DeleteConcatRequest { + projectId: number; + detailIds?: number[]; + audioIds?: number[]; +} +interface DeleteResponse { + success: boolean; + code: number; + message: string; + data: string; +} +interface ConcatSaveResponse { + success: boolean; + code: number; + message: string; + data: { + cnctProjectDto: ConcatProjectDto; + cnctDetailDtos: ConcatDetailDto[]; + }; +} +/** + * Concat 프로젝트 상태를 가져옵니다. + */ +export const concatLoad = async (projectId: number) => { + try { + const response = await customInstance({ + url: `/concat/${projectId}`, + method: 'GET', + }); + console.log('Load API 응답:', response); + return response; + } catch (error) { + console.error('Concat Load API 에러:', error); + throw error; + } +}; + +/** + * Concat 프로젝트 상태를 저장합니다. + */ +export const concatSave = async (data: ConcatSaveRequest): Promise => { + try { + if (data.concatSaveDto.projectId === null) { + const formData = new FormData(); + // 저장할 데이터 로깅 + console.log('Save API 요청 데이터:', data.concatSaveDto); + formData.append('concatSaveDto', JSON.stringify(data.concatSaveDto)); + + if (data.file && data.file.length > 0) { + data.file.forEach((file) => { + console.log('첨부 파일:', file.name); + formData.append('file', file); + }); + } + const { data: responseData } = await customInstance.post( + '/concat/save', + formData, + { + headers: { + 'Content-Type': 'multipart/form-data', + }, + } + ); + console.log('Save API 응답:', responseData); + return responseData; + } else { + const formData = new FormData(); + // 저장할 데이터 로깅 + console.log('Save API 요청 데이터:', data.concatSaveDto); + + const loadResponse = await concatLoad(data.concatSaveDto.projectId); + const { cnctDetailDtos } = loadResponse.data; + + const newConcatDetails = data.concatSaveDto.concatDetails.filter((newDetail) => { + return !cnctDetailDtos.some((oldDetail) => oldDetail.id === newDetail.id); + }); + + const newConcatSaveDto = { + ...data.concatSaveDto, + concatDetails: newConcatDetails, + }; + console.log('새로운 concatSaveDto:', newConcatSaveDto); + formData.append('concatSaveDto', JSON.stringify(newConcatSaveDto)); + + if (data.file && data.file.length > 0) { + data.file.forEach((file) => { + console.log('첨부 파일:', file.name); + formData.append('file', file); + }); + } + const { data: responseData } = await customInstance.post( + '/concat/save', + formData, + { + headers: { + 'Content-Type': 'multipart/form-data', + }, + } + ); + console.log('Save API 응답:', responseData); + return responseData; + } + } catch (error) { + console.error('Concat Save API 에러:', error); + throw error; + } +}; +/** + * 선택된 Concat 항목들을 삭제합니다. + */ +export const deleteSelectedConcatItems = async (data: DeleteConcatRequest) => { + try { + const response = await customInstance.post('/concat/delete/details', data); + console.log('삭제 응답:', response); + return { + success: response.code === 0, + message: response.message, + data: response.data, + }; + } catch (error) { + console.error('Concat 항목 삭제 실패:', error); + throw error; + } +}; +interface ConcatRequestDetail { + id: number | null; + localFileName: string | null; + audioSeq: number; + checked: boolean; + unitScript: string; + endSilence: number; +} +interface ConcatRequestDto { + projectId: number | null; + projectName: string; + globalFrontSilenceLength: number; + globalTotalSilenceLength: number; + concatRequestDetails: ConcatRequestDetail[]; +} +interface ConvertConcatRequest { + concatRequestDto: ConcatRequestDto; + files: File[]; +} +/** + * 오디오 파일들을 병합합니다. + */ +export const convertMultipleAudios = async (data: ConvertConcatRequest) => { + try { + const formData = new FormData(); + formData.append('concatRequestDto', JSON.stringify(data.concatRequestDto)); + data.files.forEach((file, _index) => { + formData.append('files', file); + }); + const response = await customInstance.post('/concat/convert/batch', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + console.log('병합 응답:', response.data); + return response.data; + } catch (error) { + console.error('Concat 변환 실패:', error); + throw error; + } +}; diff --git a/src/api/new-axios-client.ts b/src/api/new-axios-client.ts new file mode 100644 index 00000000..a9015fa6 --- /dev/null +++ b/src/api/new-axios-client.ts @@ -0,0 +1,51 @@ +// apiClient.ts +import axios, { AxiosInstance } from 'axios'; + +const BASE_URL = import.meta.env.VITE_API_URL; + +const createApiClient = (): AxiosInstance => { + const instance = axios.create({ + baseURL: BASE_URL, + headers: { + 'Content-Type': 'application/json', + }, + withCredentials: true, + }); + + instance.interceptors.request.use( + (config) => { + const token = localStorage.getItem('token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => { + return Promise.reject(error); + } + ); + + instance.interceptors.response.use( + (response) => { + return response; + }, + (error) => { + // 에러 처리 (예: 401 인증 에러, 404 등) + if (error.response) { + switch (error.response.status) { + case 401: + break; + case 404: + break; + default: + break; + } + } + return Promise.reject(error); + } + ); + + return instance; +}; + +export const newcustomInstance = createApiClient(); diff --git a/src/api/profileAPI.ts b/src/api/profileAPI.ts new file mode 100644 index 00000000..bc9ac6eb --- /dev/null +++ b/src/api/profileAPI.ts @@ -0,0 +1,107 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { customInstance } from './axios-client'; + +interface FindPasswordRequest { + email: string; + phoneNumber: string; +} + +interface CheckEmailResponse { + isDuplicate: boolean; +} + +export const checkEmail = async (email: string) => { + try { + if (!email || email.trim() === '') { + throw new Error('이메일을 입력해주세요.'); + } + const response = await customInstance.post('/member/check-id', { email }); + if (response.data.isDuplicate) { + throw new Error('이미 사용 중인 이메일입니다.'); + } + return response; + } catch (error) { + if (error instanceof Error) { + if ((error as any).response?.status === 400) { + throw new Error('잘못된 이메일 주소입니다.'); + } + throw error; + } + throw new Error('이메일 중복 확인에 실패했습니다.'); + } +}; + +export const findID = async (name: string, phoneNumber: string) => { + try { + const response = await customInstance.post('/member/find-id', { + name, + phoneNumber, + }); + return response; + } catch (error) { + const errorMessage = + (error as any).response?.data?.message || 'ID 찾기에 실패했습니다. 다시 시도해주세요.'; + throw new Error(errorMessage); + } +}; + +export const findPassword = async ({ email, phoneNumber }: FindPasswordRequest) => { + try { + const response = await customInstance.post('/member/find-password', { + email, + phoneNumber, + }); + return response; + } catch (error) { + const errorMessage = + (error as any).response?.data?.message || '비밀번호 찾기에 실패했습니다. 다시 시도해주세요.'; + throw new Error(errorMessage); + } +}; + +interface Profile { + email: string; + name: string; + phoneNumber: string; +} + +interface Password { + currentPassword: string; + newPassword: string; + confirmPassword: string; +} + +export const changeProfile = async ({ email, name, phoneNumber }: Profile) => { + try { + const response = await customInstance.put('/member/info/update', { + email, + name, + phoneNumber, + }); + return response; + } catch (error) { + const errorMessage = + (error as any).response?.data?.message || + '프로필 업데이트에 실패했습니다. 다시 시도해주세요.'; + throw new Error(errorMessage); + } +}; + +export const changePassword = async ({ + currentPassword, + newPassword, + confirmPassword, +}: Password) => { + try { + const response = await customInstance.put('/member/password/update', { + currentPassword, + newPassword, + confirmPassword, + }); + return response; + } catch (error) { + const errorMessage = + (error as any).response?.data?.message || '비밀번호 변경에 실패했습니다. 다시 시도해주세요.'; + throw new Error(errorMessage); + } +}; diff --git a/src/api/taskAPI.ts b/src/api/taskAPI.ts new file mode 100644 index 00000000..41a92348 --- /dev/null +++ b/src/api/taskAPI.ts @@ -0,0 +1,36 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { customInstance } from './axios-client'; + +interface TaskResponse { + success: boolean; + code: number; + message: string; + data: Task[]; +} + +interface Task { + id: number; + projectId: number; + projectType: string; + taskStatus: string; + taskData: string; + resultMsg: string | null; +} + +export const taskAPI = { + loadTasks: async (): Promise => { + try { + const response = await customInstance.get('/task/load'); + + if (response.data.success) { + return response.data.data; + } else { + throw new Error(response.data.message || '작업 목록 로드에 실패했습니다.'); + } + } catch (error) { + const errorMessage = + (error as any).response?.data?.message || '작업 목록 로드 중 오류가 발생했습니다.'; + throw new Error(errorMessage); + } + }, +}; diff --git a/src/api/ttsAPI.ts b/src/api/ttsAPI.ts new file mode 100644 index 00000000..7cb4a729 --- /dev/null +++ b/src/api/ttsAPI.ts @@ -0,0 +1,157 @@ +import { ResponseDto, TTSDetailDto, TTSSaveDto } from '@/api/aIParkAPI.schemas'; +import { customInstance } from '@/api/axios-client'; + +/** + * TTS 프로젝트를 저장합니다. + * @param data: TTSSaveDto + * @returns Promise + */ +export const saveTTSProject = async (data: TTSSaveDto) => { + try { + console.log('보낸 데이터:', data); + + const response = await customInstance.post('/tts/save', data); + console.log('서버 응답:', response.data); + + if (response.data?.data.ttsProject) { + console.log('TTS 프로젝트 저장 성공:', response.data); + return response.data.data; // 서버 응답 데이터 반환 + } else { + console.error('TTS 프로젝트 저장 실패: 응답 데이터 없음'); + return null; + } + } catch (error) { + console.error('TTS 프로젝트 저장 요청 오류:', error); + throw error; + } +}; + +/** + * TTS 프로젝트 상태를 가져옵니다. + * @summary TTS 상태 로드 + */ +export const ttsLoad = async (projectId: number) => { + try { + const response = await customInstance({ + url: `/tts/${projectId}`, + method: 'GET', + }); + if (response.data) { + console.log('TTS 프로젝트 로드 성공:', response.data); + return response; + } else { + throw new Error('TTS 프로젝트 로드 실패'); + } + } catch (error) { + console.error('TTS 프로젝트 로드 오류:', error); + throw error; + } +}; + +export interface VoiceStyle { + id: number; + country: string; + languageCode: string; + voiceType: string; + voiceName: string; + gender: 'male' | 'female'; + personality: string; + label: string; +} + +const languageCodeMap = { + female: '여성', + male: '남성', +}; + +export interface voiceStyleData { + value: number; + label: string; + gender: 'male' | 'female'; +} + +/** + * TTS에 적용할 음원 보이스 값들을 가져옵니다. + * @summary all voice + * @param language: string + */ +export const loadVoiceStyleOptions = async (language: string): Promise => { + try { + const response = await customInstance({ url: `/voice-style`, method: 'GET' }); + const seen = new Set(); + return response.data.voiceStyleDto + .filter((v: VoiceStyle) => v.country === language) + .map((v: VoiceStyle) => { + const label = `#${languageCodeMap[v.gender]} #${v.personality} `; + if (seen.has(label)) { + return null; + } + seen.add(label); + return { + value: v.id, + label: label, + gender: v.gender, + }; + }) + .filter((v: voiceStyleData | null) => v !== null) + .sort((a: voiceStyleData) => (a!.gender === 'female' ? -1 : 1)); + } catch (error) { + console.error('Error loading voice style options:', error); + throw error; + } +}; + +/** + * TTS에 적용할 가능한음원 언어 값들을 가져옵니다. + * @summary all voice language + */ +export const loadVoiceLanguageOptions = async (): Promise>> => { + try { + const response = await customInstance({ url: `/voice-style`, method: 'GET' }); + const countries = new Set( + response.data.voiceStyleDto + .map((v: VoiceStyle) => v.country) + .sort((a: string, b: string) => a.localeCompare(b, 'ko')) + ); + return { ...response.data, data: countries }; + } catch (error) { + console.error('Error loading voice language options:', error); + throw error; + } +}; + +export interface TTSConvertRequestDto { + fullScript: string; + globalPitch: number; + globalSpeed: number; + globalVoiceStyleId: number; + globalVolume: number; + memberId?: number; + projectId: number; + projectName?: string; + ttsDetails: TTSDetailDto[]; +} + +/** + * 주어진 텍스트 목록을 Google TTS API를 사용하여 음성 파일로 변환합니다. + * @summary TTS 배치 변환 + */ +export const convertBatchTexts = async (TTSConvertRequest: TTSConvertRequestDto) => { + try { + console.log('convertBatchTexts 보낸 데이터:', TTSConvertRequest); + const response = await customInstance({ + url: `/tts/convert/batch`, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + data: TTSConvertRequest, + }); + if (!response.data) { + console.error('TTS 배치 변환 실패:', response.data); + throw new Error('TTS 배치 변환 실패'); + } + return ttsLoad(TTSConvertRequest.projectId); + } catch (error) { + console.error('TTS 배치 변환 오류:', error); + throw error; + } +}; diff --git a/src/api/uploadTTSProjectData.ts b/src/api/uploadTTSProjectData.ts new file mode 100644 index 00000000..4337cb70 --- /dev/null +++ b/src/api/uploadTTSProjectData.ts @@ -0,0 +1,69 @@ +import { TTSDetailDto, TTSSaveDto } from '@/api/aIParkAPI.schemas'; +import { saveTTSProject } from '@/api/ttsAPI'; +import { initialProjectData, TTSItem } from '@/stores/tts.store'; + +type setProjectData = (data: { + projectId: number | null; + projectName: string; + fullScript?: string; + globalVoiceStyleId?: number; + globalSpeed?: number; + globalPitch?: number; + globalVolume?: number; + ttsDetails?: TTSDetailDto[]; +}) => void; + +export const uploadTTSProjectData = async ( + projectData: TTSSaveDto, + items: TTSItem[], + setProjectData: setProjectData, + setItems: (items: TTSItem[]) => void +) => { + try { + const transformedData = { + ...projectData, + ttsDetails: items.map((item, index) => ({ + id: item.enitityId, + unitScript: item.text, + unitSpeed: item.speed, + unitVolume: item.volume, + unitPitch: item.pitch, + unitSequence: index + 1, + unitVoiceStyleId: item.style ? Number(item.style) : null, + isDeleted: false, + })), + }; + + const response = await saveTTSProject(transformedData); + + if (response) { + setProjectData({ + projectId: response.ttsProject.id, + projectName: response.ttsProject.projectName, + fullScript: response.ttsProject.fullScript, + globalSpeed: response.ttsProject.globalSpeed, + globalPitch: response.ttsProject.globalPitch, + globalVolume: response.ttsProject.globalVolume, + globalVoiceStyleId: response.ttsProject.globalVoiceStyleId, + ttsDetails: response.ttsDetails, + }); + setItems( + response.ttsDetails.map((detail: TTSDetailDto, index: number) => ({ + id: String(detail.id), + enitityId: detail.id, + text: items[index].text || initialProjectData.fullScript, + isSelected: items[index].isSelected || false, + speed: items[index].speed || initialProjectData.globalSpeed, + volume: items[index].volume || initialProjectData.globalVolume, + pitch: items[index].pitch || initialProjectData.globalPitch, + style: items[index].style || String(initialProjectData.globalVoiceStyleId), + })) + ); + } + + return response; + } catch (error) { + console.error('프로젝트 저장 오류:', error); + throw error; + } +}; diff --git a/src/api/vcAPI.ts b/src/api/vcAPI.ts new file mode 100644 index 00000000..4071e670 --- /dev/null +++ b/src/api/vcAPI.ts @@ -0,0 +1,276 @@ +import { newcustomInstance } from '@/api/new-axios-client'; + +import type { ResponseDto, VCSaveDto } from './aIParkAPI.schemas'; +import { customInstance } from './axios-client'; + +export interface VCProcessResponse { + id: number; + projectId: number; + isChecked: boolean; + unitScript: string; + srcAudio: string; + genAudios: string[]; +} + +export interface VCSaveRequestSrcFile { + id: number | null; + localFileName: string | null; + unitScript: string; + isChecked: boolean; +} +export interface VCSaveRequestTrgFile { + audioType: string; + localFileName: string | null; + s3MemberAudioMetaId: number | null; +} + +interface VCSaveRequestDto { + projectId: number | null; + projectName: string; + srcFiles: VCSaveRequestSrcFile[]; + trgFiles: VCSaveRequestTrgFile[]; +} + +/** + * VC 프로젝트 처리 및 음성 변환 + * @param vcSaveDto VC 저장 데이터 + * @param files 오디오 파일 배열 (소스 파일들 + 타겟 파일) + * @param memberId 멤버 ID + */ +export const processVoiceConversion = async ( + vcSaveDto: VCSaveDto, + files: File[], + memberId: number +): Promise => { + try { + if (vcSaveDto.projectId === null) { + const formData = new FormData(); + + // VCSaveDto를 JSON 문자열로 변환하여 추가 + formData.append('VCSaveRequestDto', JSON.stringify(vcSaveDto)); + + // 파일들을 순서대로 추가 (소스 파일들 + 타겟 파일) + files.forEach((file) => { + formData.append('files', file); + }); + + console.log('vcSaveDto', vcSaveDto); + + const response = await customInstance>({ + url: '/vc/process', + method: 'POST', + headers: { + 'Content-Type': 'multipart/form-data', + }, + data: formData, + params: { memberId }, + }); + + console.log('서버 원본 응답:', response); + console.log('응답 데이터:', response.data); + return Array.isArray(response.data) ? response.data : []; + } else { + const currentProject = await vcLoad(vcSaveDto.projectId); + + const { vcDetailsRes } = currentProject.data; + + const currentProjectSrcFiles = vcDetailsRes.map((vcDetail) => { + return { + detailId: vcDetail.id, + unitScript: vcDetail.unitScript, + srcAudio: vcDetail.srcAudio, + }; + }); + + const updatedVCSaveDto: VCSaveRequestDto = { + ...vcSaveDto, + projectId: vcSaveDto.projectId, + projectName: vcSaveDto.projectName, + srcFiles: vcSaveDto.srcFiles.map((srcFile, index) => ({ + id: currentProjectSrcFiles[index]?.detailId || null, + localFileName: currentProjectSrcFiles[index]?.srcAudio.split('/').pop() + ? null + : srcFile.localFileName, + unitScript: srcFile.unitScript, + isChecked: srcFile.isChecked, + })), + trgFiles: [ + { + audioType: 'VC_TRG', + localFileName: null, + s3MemberAudioMetaId: 821, + }, + ], + }; + + const formData = new FormData(); + formData.append('VCSaveRequestDto', JSON.stringify(updatedVCSaveDto)); + console.log('updatedVCSaveDto', updatedVCSaveDto); + + files.forEach((file) => { + formData.append('files', file); + }); + + const response = await customInstance>({ + url: '/vc/process', + method: 'POST', + headers: { + 'Content-Type': 'multipart/form-data', + }, + data: formData, + params: { memberId }, + }); + + console.log('기존 프로젝트 처리 응답:', response); + return Array.isArray(response.data) ? response.data : []; + } + } catch (error) { + console.error('VC 프로세스 실패:', error); + throw error; + } +}; + +interface genAudios { + id: number; + audioUrl: string; +} + +interface VCLoadSaveResponse { + vcProjectRes: { + id: number; + projectName: string; + trgAudios: { + id: number; + audioUrl: string; + }[]; + }; + vcDetailsRes: { + id: number; + projectId: number; + isChecked: boolean; + unitScript: string; + srcAudio: string | null; + genAudios: genAudios[]; + }[]; +} + +interface VCAudio { + id: number; + audioUrl: string; +} + +interface VCProject { + id: number; + projectName: string; + trgAudios: VCAudio[]; +} + +interface VCDetail { + id: number; + projectId: number; + isChecked: boolean; + unitScript: string; + srcAudio: string; + genAudios: genAudios[]; +} + +interface VCResponse { + success: boolean; + code: number; + message: string; + data: { + vcProjectRes: VCProject; + vcDetailsRes: VCDetail[]; + }; +} + +/** + * VC 프로젝트 상태를 가져옵니다. + */ +export const vcLoad = async (projectId: number): Promise => { + try { + const response = await newcustomInstance.get(`/vc/${projectId}`); + return response.data; + } catch (error) { + console.error('VC 프로젝트 로드 실패:', error); + throw error; + } +}; + +/** + * VC 프로젝트를 저장합니다. + * @param data VCSaveDto 데이터 + * @param files 오디오 파일 배열 (선택적) + */ +export const saveVCProject = async ( + data: VCSaveDto, + files?: File[] +): Promise> => { + try { + const formData = new FormData(); + + // 메타데이터 구성 + const metadata: VCSaveDto = { + projectId: data.projectId, + projectName: data.projectName, + srcFiles: data.srcFiles.map((file) => ({ + detailId: file.detailId, + localFileName: file.detailId ? null : file.localFileName, + unitScript: file.unitScript, + isChecked: file.isChecked, + })), + trgFiles: data.trgFiles?.[0] + ? [ + { + localFileName: data.trgFiles[0].s3MemberAudioMetaId + ? '' + : data.trgFiles[0].localFileName, + s3MemberAudioMetaId: data.trgFiles[0].s3MemberAudioMetaId, + audioType: data.trgFiles[0].audioType, + }, + ] + : [], + }; + + formData.append('metadata', JSON.stringify(metadata)); + + // 파일 추가 로직 수정 + if (files?.length) { + files.forEach((file) => { + const matchingSrcFile = data.srcFiles.find( + (srcFile) => srcFile.localFileName === file.name + ); + if (matchingSrcFile) { + formData.append('file', file); + } + }); + } + + const response = await customInstance>({ + url: '/vc/save', + method: 'POST', + headers: { + 'Content-Type': 'multipart/form-data', + }, + data: formData, + }); + + if (!response.data.success) { + throw new Error(response.data.message); + } + + return response.data; + } catch (error) { + console.error('VC 프로젝트 저장 실패:', error); + throw error; + } +}; + +// Response Types +export type ProcessVoiceConversionResult = NonNullable< + Awaited> +>; +export type LoadVCProjectResult = NonNullable>>; + +// VCSaveDto를 export 해야 합니다 +export type { VCSaveDto } from './aIParkAPI.schemas'; diff --git a/src/api/workspaceAPI.ts b/src/api/workspaceAPI.ts new file mode 100644 index 00000000..239f435c --- /dev/null +++ b/src/api/workspaceAPI.ts @@ -0,0 +1,199 @@ +import { Export, Project, workspacesResponse } from '@/api/aIParkAPI.schemas'; +import { customInstance } from '@/api/axios-client'; +import { concatLoad } from '@/api/concatAPI'; +import { ttsLoad } from '@/api/ttsAPI'; +import { vcLoad } from '@/api/vcAPI'; +import { RecentExportTableItem } from '@/components/custom/tables/history/RecentExportTable'; +import { formatUpdatedAt } from '@/utils/dateUtils'; + +// 프로젝트 목록 +export const fetchProjects = async ( + page: number, + size: number, + keyword: string = '' +): Promise<{ content: Project[]; totalPages: number; totalElements: number }> => { + try { + const response = await customInstance.get('/workspace/projects', { + params: { page, size, keyword }, + }); + + console.log('API 전체 응답:', response.data); + + // 응답에서 data 필드 추출 + const data = response.data; + + console.log('API 전체 응답:', response.data); + console.log('API 부분 응답:', data); + console.log('프로젝트 데이터:', data.content); + console.log('총 페이지 수:', data.totalPages); + + // 데이터 검증 + if (!data || !Array.isArray(data.content) || typeof data.totalPages !== 'number') { + throw new Error('API 응답 데이터가 예상과 다릅니다.'); + } + + // 반환 + return { + content: data.content, + totalPages: data.totalPages, + totalElements: data.totalElements, + }; + } catch (error) { + console.error('API 요청 실패:', error); + throw new Error('API 요청 실패'); + } +}; + +// 프로젝트 삭제 +export const deleteProject = async (projectIds: number[]) => { + try { + const response = await customInstance.delete('/workspace/delete/project', { + data: projectIds, // 요청 본문에 ID 배열 전달 + }); + console.log('프로젝트 삭제 성공:', response.data); + return response.data; + } catch (error) { + console.error('프로젝트 삭제 실패:', error); + throw error; + } +}; + +// 최근 프로젝트 5개 +export const fetchRecentProjects = async (): Promise => { + try { + const response = await customInstance.get('/workspace/project-list'); + console.log('최근 프로젝트 데이터:', response.data); + + return response.data.map((project) => ({ + projectId: project.id, // id를 projectId로 매핑 + projectType: project.type, // type을 projectType으로 매핑 + projectName: project.name, // name을 projectName으로 매핑 + script: project.script || '작성된 내용이 없습니다.', // script 기본값 처리 + updatedAt: project.updatedAt, + createdAt: project.createdAt, + projectStatus: project.status, + })); + } catch (error) { + console.error('최근 프로젝트 조회 실패:', error); + throw new Error('최근 프로젝트 조회에 실패했습니다.'); + } +}; + +// 프로젝트 클릭시 로드 호출 +export const fetchProjectByType = async ( + projectId: number, + projectType: 'TTS' | 'VC' | 'CONCAT' +) => { + try { + switch (projectType) { + case 'TTS': + return await ttsLoad(projectId); + case 'VC': + return await vcLoad(projectId); + case 'CONCAT': + return await concatLoad(projectId); + default: + throw new Error('Invalid project type'); + } + } catch (error) { + console.error(`프로젝트 로드 실패 [${projectType}]:`, error); + throw error; + } +}; + +// 히스토리 목록 +export const fetchExports = async ( + page: number, + size: number, + keyword: string = '' +): Promise<{ content: Export[]; totalPages: number; totalElements: number }> => { + try { + const response = await customInstance.get('/workspace/exports', { + params: { page, size, keyword }, + }); + + console.log('API 전체 응답:', response.data); + + // 응답에서 data 필드 추출 + const data = response.data; + + console.log('내보내기 데이터:', data.content); + console.log('총 페이지 수:', data.totalPages); + console.log('총 데이터 수:', data.totalElements); + + // 데이터 검증 + if ( + !data || + !Array.isArray(data.content) || + typeof data.totalPages !== 'number' || + typeof data.totalElements !== 'number' + ) { + throw new Error('API 응답 데이터가 예상과 다릅니다.'); + } + + // 반환 + return { + content: data.content, + totalPages: data.totalPages, + totalElements: data.totalElements, + }; + } catch (error) { + console.error('API 요청 실패:', error); + throw new Error('API 요청 실패'); + } +}; + +export const fetchRecentExports = async (): Promise => { + try { + const response = await customInstance.get<{ + success: boolean; + code: number; + message: string; + data: Export[]; + }>('/workspace/export-list'); + + // 응답 전체 출력 + console.log('API 응답 전체:', response); + + const data = response.data; + + if (!data || !Array.isArray(data)) { + throw new Error('API 응답에서 데이터를 찾을 수 없습니다.'); + } + + // 데이터 매핑 + const mappedData = data.map((item, index) => ({ + id: item.projectId, + metaId: item.metaId || index, + projectName: item.projectName, + type: item.projectType as 'VC' | 'TTS' | 'Concat', + content: item.script || '작성된 내용이 없습니다.', + fileName: item.fileName, + url: item.url || '', + unitStatus: + item.unitStatus === 'SUCCESS' || item.unitStatus === 'FAILURE' ? item.unitStatus : null, + createdAt: formatUpdatedAt(item.createAt), + })); + + console.log('매핑된 데이터:', mappedData); + + return mappedData; + } catch (error) { + console.error('최근 내보내기 목록 조회 실패:', error); + throw new Error('최근 내보내기 목록 조회에 실패했습니다.'); + } +}; + +// 내보내기 오디오 삭제 +export const deleteExportProject = async (mataId: number[]) => { + try { + const response = await customInstance.delete('/workspace/delete/export', { + data: mataId, // 요청 본문에 ID 배열 전달 + }); + console.log('프로젝트 삭제 성공:', response.data); + return response.data; + } catch (error) { + console.error('프로젝트 삭제 실패:', error); + throw error; + } +}; diff --git a/src/components/custom/buttons/IconButton.tsx b/src/components/custom/buttons/IconButton.tsx new file mode 100644 index 00000000..4169bb4b --- /dev/null +++ b/src/components/custom/buttons/IconButton.tsx @@ -0,0 +1,291 @@ +import { Slot } from '@radix-ui/react-slot'; +import * as React from 'react'; +import { + TbDeviceFloppy, + TbDownload, + TbHistory, + TbPlayerPlayFilled, + TbRefresh, + TbReload, + TbSparkles, + TbTrash, + TbUpload, + TbX, +} from 'react-icons/tb'; + +import { cn } from '@/lib/utils'; +interface TTSPlaybackHistoryButtonProps { + readonly onClick?: () => void; + readonly isActive?: boolean; + readonly className?: string; + readonly isHistoryViewEnabled: boolean; +} +interface IconButtonProps extends React.ButtonHTMLAttributes { + readonly icon: React.ReactNode; + readonly label: string; + readonly asChild?: boolean; + readonly iconBgColor?: string; + readonly iconColor?: string; + readonly textColor?: string; + readonly width?: string; + disabled?: boolean; +} + +const IconButton = React.forwardRef( + ( + { + icon, + label, + asChild = false, + iconBgColor, + iconColor, + textColor = 'text-gray-900', + width = '228px', + className, + disabled = false, + ...props + }, + ref + ) => { + const Comp = asChild ? Slot : 'button'; + + return ( + + + {React.cloneElement(icon as React.ReactElement, { width: 20, height: 20 })} + + + {label} + + + ); + } +); +IconButton.displayName = 'IconButton'; + +interface UploadTextButtonProps { + onClick?: () => void; + isLoading?: boolean; + disabled?: boolean; +} + +export const UploadTextButton: React.FC = ({ + onClick, + isLoading, + disabled, +}) => { + return ( + } + label="텍스트 파일 업로드" + iconBgColor="bg-purple-50" + iconColor="text-purple-500" + textColor="text-gray-800" + width="167px" + onClick={onClick} + disabled={disabled || isLoading} + /> + ); +}; + +export function UploadAudioButton({ onClick }: { readonly onClick?: () => void }) { + return ( + } + label="오디오 파일 업로드" + iconBgColor="bg-purple-50" + iconColor="text-purple-500" + textColor="text-gray-800" + width="167px" + onClick={onClick} + /> + ); +} + +export function SaveButton({ onClick }: { readonly onClick?: () => void }) { + return ( + } + label="저장" + iconBgColor="bg-pink-50" + iconColor="text-pink-500" + textColor="text-gray-800" + width="76px" + onClick={onClick} + /> + ); +} + +export function RecreateButton({ onClick }: { readonly onClick?: () => void }) { + return ( + } + label="재생성" + iconBgColor="bg-blue-50" + iconColor="text-blue-500" + textColor="text-gray-800" + width="90px" + onClick={onClick} + /> + ); +} + +export function DownloadButton({ onClick }: { readonly onClick?: () => void }) { + return ( + } + label="다운로드" + iconBgColor="bg-blue-50" + iconColor="text-blue-500" + textColor="text-gray-800" + width="104px" + onClick={onClick} + /> + ); +} + +export function TTSPlaybackHistoryButton({ + onClick, + isActive, + isHistoryViewEnabled, +}: TTSPlaybackHistoryButtonProps) { + return ( + } + label="내역" + iconBgColor="bg-blue-50" + iconColor="text-blue-500" + textColor={`text-gray-800`} + width="78px" + onClick={onClick} + className={cn( + `border border-transparent group/tts`, + isActive ? 'border-blue-500 bg-blue-50 text-blue-500 ' : '', + isHistoryViewEnabled + ? '' + : 'bg-gray-50 opacity-50 cursor-not-allowed pointer-events-none border-transparent ' + )} + type="button" + /> + ); +} + +export function ApplyButton({ + onClick, + className, +}: { + readonly onClick?: () => void; + readonly className?: string; +}) { + return ( + } + label="적용하기" + iconBgColor="bg-blue-50" + iconColor="text-blue-600" + onClick={onClick} + className={className} + /> + ); +} + +export function ResetChangesButton({ + onClick, + className, +}: { + readonly onClick?: () => void; + readonly className?: string; +}) { + return ( + } + label="변경 초기화" + iconBgColor="bg-blue-50" + iconColor="text-blue-600" + onClick={onClick} + className={className} + /> + ); +} + +export function DeleteCompletedButton() { + return ( + } + label="완료 작업 삭제" + iconBgColor="bg-blue-50" + iconColor="text-blue-500" + width="228px" + /> + ); +} + +export function RetryFailedButton() { + return ( + } + label="실패 작업 재실행" + iconBgColor="bg-blue-50" + iconColor="text-blue-600" + width="228px" + /> + ); +} + +export function CloseButton({ + onClick, + className, +}: { + readonly onClick?: () => void; + readonly className?: string; +}) { + return ( + } + label="닫기" + iconBgColor="bg-slate-100" + iconColor="text-slate-600" + textColor="text-gray-800" + width="76px" + onClick={onClick} + className={className} + /> + ); +} + +export { IconButton }; diff --git a/src/components/custom/buttons/PlayButton.tsx b/src/components/custom/buttons/PlayButton.tsx new file mode 100644 index 00000000..8812e117 --- /dev/null +++ b/src/components/custom/buttons/PlayButton.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { TbPlayerPauseFilled, TbPlayerPlayFilled } from 'react-icons/tb'; + +import { cn } from '@/lib/utils'; + +interface PlayButtonProps extends React.ButtonHTMLAttributes { + isPlaying?: boolean; + onPlay?: () => void; + onPause?: () => void; +} + +const PlayButton = React.forwardRef( + ({ className, isPlaying = false, onPlay, onPause, ...props }, ref) => { + const Icon = isPlaying ? TbPlayerPauseFilled : TbPlayerPlayFilled; + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (isPlaying) { + onPause?.(); + } else { + onPlay?.(); + } + }; + + return ( + + ); + } +); + +PlayButton.displayName = 'PlayButton'; + +export { PlayButton }; diff --git a/src/components/custom/buttons/ViewFilterButton.tsx b/src/components/custom/buttons/ViewFilterButton.tsx new file mode 100644 index 00000000..c9ad4876 --- /dev/null +++ b/src/components/custom/buttons/ViewFilterButton.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { TbCategory, TbList } from 'react-icons/tb'; + +import { cn } from '@/lib/utils'; + +interface ViewButtonProps { + label: React.ReactNode; + isSelected: boolean; + onClick: () => void; + position: 'left' | 'right'; + ariaLabel: string; +} + +interface ViewButtonGroupProps { + isListView: boolean; + onViewChange: (isListView: boolean) => void; +} + +const ViewButton: React.FC = ({ + label, + isSelected, + onClick, + position, + ariaLabel, +}) => { + return ( + + ); +}; + +const ViewButtonGroup: React.FC = ({ isListView, onViewChange }) => { + return ( +
+ } + isSelected={isListView} + onClick={() => onViewChange(true)} + position="left" + ariaLabel="list" + /> + } + isSelected={!isListView} + onClick={() => onViewChange(false)} + position="right" + ariaLabel="grid" + /> +
+ ); +}; + +export default ViewButtonGroup; diff --git a/src/components/custom/cards/HomeCard.tsx b/src/components/custom/cards/HomeCard.tsx new file mode 100644 index 00000000..39ce765b --- /dev/null +++ b/src/components/custom/cards/HomeCard.tsx @@ -0,0 +1,34 @@ +import { TbArrowUpRight } from 'react-icons/tb'; + +interface HomeCardProps { + title: string; + description1: string; + description2: string; + onClick?: () => void; +} + +const HomeCard = ({ title, description1, description2, onClick }: HomeCardProps) => { + return ( +
+ {title} + +
+ +
+

{description1}

+

{description2}

+
+ +
+ +
+
+ ); +}; + +export default HomeCard; diff --git a/src/components/custom/cards/RecentProjectCard.tsx b/src/components/custom/cards/RecentProjectCard.tsx new file mode 100644 index 00000000..4636a1d3 --- /dev/null +++ b/src/components/custom/cards/RecentProjectCard.tsx @@ -0,0 +1,57 @@ +import React, { useEffect, useState } from 'react'; +import { TbArrowUpRight } from 'react-icons/tb'; + +import RecentCardbg1 from '@/images/recent-card-bg1.svg'; +import RecentCardbg2 from '@/images/recent-card-bg2.svg'; +import RecentCardbg3 from '@/images/recent-card-bg3.svg'; +import RecentCardbg4 from '@/images/recent-card-bg4.svg'; + +const backgrounds = [RecentCardbg1, RecentCardbg2, RecentCardbg3, RecentCardbg4]; + +interface RecentProjectCardProps { + title: string; + description?: string; + date: string; + type: string; +} + +const RecentProjectCard: React.FC = ({ + title, + description, + date, + type, +}) => { + const [background, setBackground] = useState(''); + + useEffect(() => { + // 한 번만 설정된 배경을 유지 + const randomBackground = backgrounds[Math.floor(Math.random() * backgrounds.length)]; + setBackground(randomBackground); + }, []); + + return ( +
+
+ + +
+ +
+
+ +
+
{type}
+

+ {title} +

+
+ +
+

{description}

+ {date} +
+
+ ); +}; + +export default RecentProjectCard; diff --git a/src/components/custom/cards/VoiceCard.tsx b/src/components/custom/cards/VoiceCard.tsx new file mode 100644 index 00000000..87589945 --- /dev/null +++ b/src/components/custom/cards/VoiceCard.tsx @@ -0,0 +1,143 @@ +import { useRef, useState } from 'react'; +import { TbDotsVertical, TbEdit, TbPlayerPause, TbPlayerPlay, TbTrash } from 'react-icons/tb'; + +import { Card, CardContent } from '@/components/ui/card'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Input } from '@/components/ui/input'; +import { RadioGroupItem } from '@/components/ui/radio-group'; + +interface VoiceCardProps { + voice: { + id: string; + name: string; + description: string; + audioUrl?: string; + }; + isSelected: boolean; + onSelect: () => void; + onDelete?: () => void; + onEdit?: (newName: string) => void; +} + +const VoiceCard = ({ voice, isSelected, onSelect, onDelete, onEdit }: VoiceCardProps) => { + const [isEditing, setIsEditing] = useState(false); + const [fileName, fileExt] = voice.name.split('.'); + const [isPlaying, setIsPlaying] = useState(false); + const audioRef = useRef(null); + + const handlePlay = () => { + if (!audioRef.current && voice.audioUrl) { + audioRef.current = new Audio(voice.audioUrl); + audioRef.current.onended = () => { + setIsPlaying(false); + audioRef.current = null; + }; + } + audioRef.current?.play(); + setIsPlaying(true); + }; + + const handlePause = () => { + audioRef.current?.pause(); + setIsPlaying(false); + }; + + const handleEdit = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + onEdit?.(`${e.currentTarget.value}.${fileExt}`); + setIsEditing(false); + } else if (e.key === 'Escape') { + setIsEditing(false); + } + }; + + return ( + + +
+ { + e.stopPropagation(); + if (!isEditing) onSelect(); + }} + /> +
+
+ {isEditing ? ( + e.stopPropagation()} + className="h-6 py-0" + /> + ) : ( + voice.name + )} +
+
{voice.description}
+
+
+ {voice.audioUrl && ( + + )} + {(onDelete || onEdit) && ( + + e.stopPropagation()}> + + + + {onEdit && ( + setIsEditing(true)}> + + 파일명 수정 + + )} + {onDelete && ( + + + 삭제 + + )} + + + )} +
+
+
+
+ ); +}; + +export default VoiceCard; diff --git a/src/components/custom/dialogs/AudioHistoryDialog.tsx b/src/components/custom/dialogs/AudioHistoryDialog.tsx new file mode 100644 index 00000000..22273db6 --- /dev/null +++ b/src/components/custom/dialogs/AudioHistoryDialog.tsx @@ -0,0 +1,121 @@ +import React from 'react'; +import { TbTrash, TbX } from 'react-icons/tb'; + +import { DownloadButton } from '@/components/custom/buttons/IconButton'; +import { AudioPlayer, PlayerMode } from '@/components/custom/features/common/AudioPlayer'; +import { Checkbox } from '@/components/ui/checkbox'; +import { DialogClose, DialogContent, DialogPortal, DialogTitle } from '@/components/ui/dialog'; +import { useAudioDownload } from '@/hooks/useAudioDownload'; +import { formatUpdatedAt } from '@/utils/dateUtils'; + +interface AudioHistoryDialogProps { + audioHistory: { + id: number; + audioUrl: string; + fileName?: string; + createdAt?: string; + }[]; +} + +const AudioHistoryDialog: React.FC = ({ audioHistory }) => { + const [selectedItems, setSelectedItems] = React.useState([]); + + const handleSelectAll = (checked: boolean) => { + if (checked) { + setSelectedItems(audioHistory.map((audio) => audio.id)); + } else { + setSelectedItems([]); + } + }; + + const handleSelectItem = (id: number, checked: boolean) => { + setSelectedItems((prev) => (checked ? [...prev, id] : prev.filter((itemId) => itemId !== id))); + }; + + const handleDelete = () => { + console.log('삭제된 아이템:', selectedItems); + }; + + const isAllSelected = selectedItems.length === audioHistory.length; + + const handleDownload = useAudioDownload({ + items: audioHistory.map((item) => ({ + id: item.id.toString(), + fileName: item.fileName || 'audio', + convertedAudioUrl: item.audioUrl, + })), + showAlert: (message, _variant) => { + console.log(message); // 실제로는 여기에 알림 컴포넌트를 사용하면 좋습니다 + }, + }); + + return ( + + + {/* Header */} +
+ 전체 음성 생성 내역 + {audioHistory.length} +
+ + {/* Toolbar */} +
+ handleSelectAll(checked as boolean)} + /> + +
+ + {/* History Grid */} +
+ {audioHistory.map((audio) => ( +
+
+
+ handleSelectItem(audio.id, checked as boolean)} + /> +
+ {audio.fileName} + + {audio.createdAt && formatUpdatedAt(audio.createdAt)} + +
+
+
+ handleDownload(audio.id.toString())} /> +
+
+
+ +
+
+ ))} +
+ + + + +
+
+ ); +}; + +export default AudioHistoryDialog; diff --git a/src/components/custom/dialogs/ConfirmationDialog.tsx b/src/components/custom/dialogs/ConfirmationDialog.tsx new file mode 100644 index 00000000..bb99e530 --- /dev/null +++ b/src/components/custom/dialogs/ConfirmationDialog.tsx @@ -0,0 +1,34 @@ +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; + +interface DeleteConfirmProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; +} + +export const DeleteConfirm = ({ open, onOpenChange, onConfirm }: DeleteConfirmProps) => { + return ( + + + + 삭제하시겠습니까? + + + + + + + + ); +}; diff --git a/src/components/custom/dialogs/CreateProjectDialog.tsx b/src/components/custom/dialogs/CreateProjectDialog.tsx new file mode 100644 index 00000000..11cbb18d --- /dev/null +++ b/src/components/custom/dialogs/CreateProjectDialog.tsx @@ -0,0 +1,99 @@ +import { useState } from 'react'; +import { TbFileDatabase, TbFileMusic, TbFileTypography } from 'react-icons/tb'; +import { useNavigate } from 'react-router-dom'; + +import { DialogContent, DialogTitle } from '@/components/ui/dialog'; +import { useProjectStore } from '@/stores/project.store'; + +const features = [ + { + title: 'Text to Speech', + description: ( + <> + 텍스트를 업로드하고 원하는 스타일과 +
+ 목소리로 음성을 생성해 보세요. + + ), + icon: TbFileTypography, + bgColor: 'bg-green-50', + route: '/tts', // TTS 경로 추가 + }, + { + title: 'Voice Conversion', + description: ( + <> + 다양한 음성 샘플을 사용하여 +
+ 파일의 음색을 자유롭게 바꾸어 보세요. + + ), + icon: TbFileMusic, + bgColor: 'bg-pink-50', + route: '/vc', + }, + { + title: 'Concat', + description: ( + <> + 여러 오디오를 하나로 연결하며, 자유롭게 무음 +
+ 구간을 조절해 완성도 높은 파일을 만들어 보세요. + + ), + icon: TbFileDatabase, + bgColor: 'bg-yellow-50', + route: '/concat', + }, +]; + +const CreateProjectDialogContent = () => { + const navigate = useNavigate(); + const addProject = useProjectStore((state) => state.addProject); + const [projectName] = useState('새 프로젝트'); + + const handleNewProject = (type: 'TTS' | 'VC' | 'Concat', route: string) => { + addProject({ + name: projectName, + type: type, + }); + navigate(route); + }; + + return ( + + 새 프로젝트 생성 +

+ 새 프로젝트 작업을 시작해 보세요! +

+
+ {features.map((feature, index) => ( +
+ handleNewProject( + feature.title === 'Text to Speech' + ? 'TTS' + : feature.title === 'Voice Conversion' + ? 'VC' + : 'Concat', + feature.route + ) + } + > +
+ +
+

{feature.title}

+

{feature.description}

+
+ ))} +
+
+ ); +}; + +export default CreateProjectDialogContent; diff --git a/src/components/custom/dialogs/EditProfileDialog.tsx b/src/components/custom/dialogs/EditProfileDialog.tsx new file mode 100644 index 00000000..e0d7a52e --- /dev/null +++ b/src/components/custom/dialogs/EditProfileDialog.tsx @@ -0,0 +1,34 @@ +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; + +interface EditConfirmProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; +} + +export const EditConfirm = ({ open, onOpenChange, onConfirm }: EditConfirmProps) => { + return ( + + + + 정말 수정하시겠습니까? + + + + + + + + ); +}; diff --git a/src/components/custom/dialogs/FindResultDialog.tsx b/src/components/custom/dialogs/FindResultDialog.tsx new file mode 100644 index 00000000..21b6a8ff --- /dev/null +++ b/src/components/custom/dialogs/FindResultDialog.tsx @@ -0,0 +1,41 @@ +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; + +interface ResultDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + message: string; + onConfirm?: () => void; +} + +export const ResultDialog = ({ + open, + onOpenChange, + title, + message, + onConfirm, +}: ResultDialogProps) => { + return ( + + + + {title} + {message} + + + + + + + ); +}; diff --git a/src/components/custom/dialogs/TermsDialog.tsx b/src/components/custom/dialogs/TermsDialog.tsx new file mode 100644 index 00000000..4295c299 --- /dev/null +++ b/src/components/custom/dialogs/TermsDialog.tsx @@ -0,0 +1,40 @@ +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { ScrollArea } from '@/components/ui/scroll-area'; + +interface TermsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + content: string; + type: 'service' | 'privacy'; + onAgree: (type: 'service' | 'privacy') => void; +} + +const TermsDialog = ({ open, onOpenChange, title, content, type, onAgree }: TermsDialogProps) => { + return ( + + + + {title} + + +
{content}
+
+
+ +
+
+
+ ); +}; + +export default TermsDialog; diff --git a/src/components/custom/dropdowns/FileProgressDropdown.tsx b/src/components/custom/dropdowns/FileProgressDropdown.tsx new file mode 100644 index 00000000..6dda1695 --- /dev/null +++ b/src/components/custom/dropdowns/FileProgressDropdown.tsx @@ -0,0 +1,241 @@ +import React, { useMemo, useState } from 'react'; +import { AiOutlineLoading3Quarters } from 'react-icons/ai'; +import { TbChevronDown, TbChevronUp, TbCircleFilled, TbRotate } from 'react-icons/tb'; + +import { DeleteCompletedButton, RetryFailedButton } from '@/components/custom/buttons/IconButton'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; + +export interface FileProgressItem { + id: number; + name: string; + status: '진행' | '대기' | '실패' | '완료'; + progress?: number; + createdAt: string; +} + +export interface FileProgressDropdownProps { + items: FileProgressItem[]; + onDeleteCompleted?: () => void; + onRetryFailed?: () => void; +} + +const statusColorMap = { + 진행: 'text-green-500', + 대기: 'text-yellow-500', + 실패: 'text-red-500', + 완료: 'text-blue-500', +} as const; + +const formatDateCategory = (date: Date): string => { + const now = new Date(); + const diffTime = now.getTime() - date.getTime(); + const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) { + return '오늘'; + } + if (diffDays === 1) { + return '어제'; + } + if (diffDays === 2) { + return '그저께'; + } + if (diffDays <= 7) { + return '일주일 전'; + } + return '한달 전'; +}; + +const FileProgressDropdown: React.FC = ({ items }) => { + const [isOpen, setIsOpen] = useState(false); + const [selectedStatuses, setSelectedStatuses] = useState([]); + + const categorizedFiles = useMemo(() => { + const grouped = items.reduce( + (acc, file) => { + const date = new Date(file.createdAt); + const category = formatDateCategory(date); + + if (!acc[category]) { + acc[category] = []; + } + acc[category].push(file); + return acc; + }, + {} as Record + ); + + const categoryOrder = ['오늘', '어제', '그저께', '일주일 전', '한달 전']; + + return categoryOrder.reduce( + (acc, category) => { + if (grouped[category]) { + acc[category] = grouped[category]; + } + return acc; + }, + {} as Record + ); + }, [items]); + + const fileStats = useMemo(() => { + return items.reduce( + (acc, file) => { + acc[file.status] = (acc[file.status] || 0) + 1; + return acc; + }, + {} as Record + ); + }, [items]); + + const toggleStatus = (status: string) => { + setSelectedStatuses((prev) => { + if (prev.includes(status)) { + return prev.filter((s) => s !== status); + } else { + return [...prev, status]; + } + }); + }; + + return ( +
+ {/* 메인 드롭다운 버튼 */} +
+
+ {/* 진행 상태 */} + + + 진행 +
+ {fileStats['진행'] || 0} +
+
+ {/* 대기 상태 */} + + + 대기 +
+ {fileStats['대기'] || 0} +
+
+ {/* 실패 상태 */} + + + 실패 +
+ {fileStats['실패'] || 0} +
+
+ {/* 완료 상태 */} + + + 완료 +
+ {fileStats['완료'] || 0} +
+
+
+ +
+ +
+
+ + {isOpen && ( +
+ {/* 필터 버튼 영역 */} +
+
+ {['진행', '대기', '실패', '완료'].map((status) => ( + + ))} + {/* 리셋 버튼 */} + {selectedStatuses.length > 0 && ( + + )} +
+
+ {/* 파일 목록 영역 */} +
+ {Object.entries(categorizedFiles).map(([category, categoryItems]) => { + const filteredItems = categoryItems.filter( + (item) => selectedStatuses.length === 0 || selectedStatuses.includes(item.status) + ); + + if (filteredItems.length === 0) return null; + + return ( +
+

{category}

+
+ {filteredItems.length > 1 && ( + +
+ ); + })} +
+ {/* 하단 버튼 영역 */} +
+ + +
+
+ )} +
+ ); +}; + +export default FileProgressDropdown; diff --git a/src/components/custom/dropdowns/ProfileDropdown.tsx b/src/components/custom/dropdowns/ProfileDropdown.tsx new file mode 100644 index 00000000..082980dc --- /dev/null +++ b/src/components/custom/dropdowns/ProfileDropdown.tsx @@ -0,0 +1,88 @@ +import * as React from 'react'; +import { TbChevronDown, TbChevronUp, TbLogout, TbUser } from 'react-icons/tb'; +import { useNavigate } from 'react-router-dom'; + +import { logout } from '@/api/authAPI'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { DEFAULT_PROFILE_IMAGE } from '@/constants/images'; +import { cn } from '@/lib/utils'; +import { useAuthStore } from '@/stores/auth.store'; + +interface ProfileDropdownProps { + className?: string; +} + +const ProfileDropdown = React.forwardRef( + ({ className }, ref) => { + const [isOpen, setIsOpen] = React.useState(false); + const navigate = useNavigate(); + const { user } = useAuthStore(); + + const handleLogout = async () => { + try { + await logout(); + + // Zustand 상태 초기화 + useAuthStore.getState().logout(); + + navigate('/signin'); + } catch (error) { + console.error('로그아웃 실패:', error); + alert('로그아웃에 실패했습니다. 다시 시도해주세요.'); + } + }; + + const handleMyPageNavigation = () => { + navigate('/mypage'); + }; + + return ( +
+
+ + + {user?.name?.charAt(0).toUpperCase() || 'U'} + +
+ {user?.name || 'Anonymous'} + {user?.email || 'No Email'} +
+ + +
+ {isOpen ? : } +
+
+ + + + 마이페이지 + + + + + + + 로그아웃 + + +
+
+
+ ); + } +); + +ProfileDropdown.displayName = 'ProfileDropdown'; + +export default ProfileDropdown; diff --git a/src/components/custom/features/auth/TermsAgreement.tsx b/src/components/custom/features/auth/TermsAgreement.tsx new file mode 100644 index 00000000..bc4897b6 --- /dev/null +++ b/src/components/custom/features/auth/TermsAgreement.tsx @@ -0,0 +1,108 @@ +import { useState } from 'react'; +import { Control, ControllerRenderProps } from 'react-hook-form'; + +import { Checkbox } from '@/components/ui/checkbox'; +import { FormField, FormItem, FormMessage } from '@/components/ui/form'; +import { SignupFormData } from '@/types/signup'; + +interface TermsAgreementProps { + control: Control; + onOpenTerms: (type: 'service' | 'privacy', isAll?: boolean) => void; +} + +type TermsField = ControllerRenderProps; + +const TermsAgreement = ({ control, onOpenTerms }: TermsAgreementProps) => { + const [isAllTermsFlow, setIsAllTermsFlow] = useState(false); + + const handleAllTermsChange = (checked: boolean, field: TermsField) => { + if (checked) { + setIsAllTermsFlow(true); + field.onChange(['age']); + onOpenTerms('service', true); + } else { + setIsAllTermsFlow(false); + field.onChange([]); + } + }; + + const handleTermChange = (checked: boolean, id: string, field: TermsField) => { + if (checked) { + if (id === 'age') { + const currentTerms = field.value || []; + field.onChange([...currentTerms, id]); + } else if (id === 'service' || id === 'privacy') { + onOpenTerms(id, false); + } + } else { + const currentTerms = field.value || []; + field.onChange(currentTerms.filter((value: string) => value !== id)); + } + }; + + return ( +
+
+ ( + <> + handleAllTermsChange(checked as boolean, field)} + checked={field.value?.length === 3} + className="h-[18px] w-[18px]" + /> + + + )} + /> +
+ ( + +
+ {[ + { id: 'age', label: '만 14세 이상' }, + { id: 'service', label: '서비스 이용약관 동의' }, + { id: 'privacy', label: '개인정보 수집 · 이용 동의' }, + ].map(({ id, label }) => ( +
+ + handleTermChange(checked as boolean, id, field)} + className="h-[18px] w-[18px]" + /> + +
+ + {id !== 'age' && ( + + )} +
+
+ ))} +
+ {!isAllTermsFlow && } +
+ )} + /> +
+ ); +}; + +export default TermsAgreement; diff --git a/src/components/custom/features/common/AudioPlayer.tsx b/src/components/custom/features/common/AudioPlayer.tsx new file mode 100644 index 00000000..b64862ee --- /dev/null +++ b/src/components/custom/features/common/AudioPlayer.tsx @@ -0,0 +1,171 @@ +import throttle from 'lodash.throttle'; +import * as React from 'react'; +import WaveSurfer, { WaveSurferOptions } from 'wavesurfer.js'; + +import { PlayButton } from '@/components/custom/buttons/PlayButton'; +import { cn } from '@/lib/utils'; + +export enum PlayerMode { + MINI = 'MINI', + NORMAL = 'NORMAL', +} +export interface AudioPlayerProps { + audioUrl: string; + className?: string; + mode?: PlayerMode; + silentRegions?: { start: number; end: number }[]; + onSilentRegionAdd?: (region: { start: number; end: number }) => void; + onRegionClick?: (index: number, duration: number) => void; +} + +const AudioPlayer = React.forwardRef( + ({ audioUrl, className, mode = PlayerMode.NORMAL }, ref) => { + const waveformRef = React.useRef(null); + const wavesurferRef = React.useRef(null); + + const [isPlaying, setIsPlaying] = React.useState(false); + const [currentTime, setCurrentTime] = React.useState(0); + const [totalTime, setTotalTime] = React.useState(0); + + React.useEffect(() => { + const controller = new AbortController(); + const { signal } = controller; + + let wavesurfer: WaveSurfer | null = null; + + const initWavesurfer = async () => { + if (!waveformRef.current || signal.aborted) { + return; + } + + const options: WaveSurferOptions = { + container: waveformRef.current, + waveColor: '#c2d4ff', + progressColor: '#356ae7', + cursorColor: 'transparent', + barWidth: 2, + barGap: 2, + height: 36, + normalize: true, + interact: true, + minPxPerSec: 60, + fillParent: true, + barRadius: 0, + cursorWidth: 0, + mediaControls: false, + splitChannels: [ + { + overlay: false, + }, + ], + }; + try { + wavesurfer = WaveSurfer.create(options); + wavesurferRef.current = wavesurfer; + + if (!signal.aborted) { + wavesurfer.on('ready', () => { + if (!signal.aborted) { + const duration = wavesurfer?.getDuration() || 0; + setTotalTime(duration); + } + }); + + const handleAudioProcess = throttle((time: number) => { + if (!signal.aborted) { + setCurrentTime(time); + } + }, 250); + + wavesurfer.on('audioprocess', handleAudioProcess); + + wavesurfer.on('play', () => !signal.aborted && setIsPlaying(true)); + wavesurfer.on('pause', () => !signal.aborted && setIsPlaying(false)); + wavesurfer.on('finish', () => { + if (!signal.aborted) { + setIsPlaying(false); + setCurrentTime(0); + } + }); + + await wavesurfer.load(audioUrl); + } + } catch (error) { + if (!signal.aborted) { + console.error('Failed to initialize WaveSurfer:', error); // 에러 처리는 추후에... 알겠습니당 + } + } + }; + + initWavesurfer(); + + return () => { + controller.abort(); + if (wavesurfer) { + wavesurfer.unAll(); + wavesurfer.destroy(); + } + wavesurferRef.current = null; + }; + }, [audioUrl]); + + const handlePlayPause = () => { + if (!wavesurferRef.current) { + return; + } + + if (isPlaying) { + wavesurferRef.current.pause(); + } else { + wavesurferRef.current.play(); + } + }; + + const formatTime = (seconds: number): string => { + if (!seconds) { + return '00:00'; + } + const minutes = Math.floor(seconds / 60); + const remainingSeconds = Math.floor(seconds % 60); + return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`; + }; + + return ( +
+
+ {mode === PlayerMode.NORMAL && ( + + )} + {mode === PlayerMode.MINI && ( + + )} +
{formatTime(currentTime)}
+
+ +
+
+
+ +
+
{formatTime(totalTime)}
+
+
+ ); + } +); + +AudioPlayer.displayName = 'AudioPlayer'; + +export { AudioPlayer }; diff --git a/src/components/custom/features/common/StateController.tsx b/src/components/custom/features/common/StateController.tsx new file mode 100644 index 00000000..a24b1c3d --- /dev/null +++ b/src/components/custom/features/common/StateController.tsx @@ -0,0 +1,89 @@ +import * as React from 'react'; +import { TbMinus, TbPlus } from 'react-icons/tb'; + +import { Slider } from '@/components/ui/slider'; + +interface StateControllerProps { + label: string; + value: number; + unit?: string; + min?: number; + max?: number; + step?: number; + onChange?: (value: number) => void; + onIncrease?: () => void; + onDecrease?: () => void; +} + +const formatValue = (value: number, unit: string) => { + if (unit === '%') { + return Math.round(value); + } + return value.toFixed(1); +}; + +const StateController = React.forwardRef( + ( + { label, value, unit = '', min = 0, max = 100, step = 1, onChange, onIncrease, onDecrease }, + ref + ) => { + const handleSliderChange = (newValue: number[]) => { + onChange?.(newValue[0]); + }; + + const handleDecrease = () => { + const newValue = Math.max(min, value - step); + onChange?.(Number(newValue.toFixed(1))); + onDecrease?.(); + }; + + const handleIncrease = () => { + const newValue = Math.min(max, value + step); + onChange?.(Number(newValue.toFixed(1))); + onIncrease?.(); + }; + + const formattedValue = formatValue(value, unit); + + return ( +
+ {label} +
+
+ +
+
+ +
+ {formattedValue} + {unit} +
+ +
+
+
+ ); + } +); + +StateController.displayName = 'StateController'; + +export { StateController }; diff --git a/src/components/custom/features/concat/SilenceStatus.tsx b/src/components/custom/features/concat/SilenceStatus.tsx new file mode 100644 index 00000000..c4ab2f22 --- /dev/null +++ b/src/components/custom/features/concat/SilenceStatus.tsx @@ -0,0 +1,51 @@ +import * as React from 'react'; +import { TbClockPlus } from 'react-icons/tb'; + +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; + +export const SILENCE_STATUS_TYPES = { + FRONT_SILENCE: 'front_silence', + BACK_SILENCE: 'back_silence', + END_SILENCE: 'end_silence', +} as const; + +export type SilenceStatusType = (typeof SILENCE_STATUS_TYPES)[keyof typeof SILENCE_STATUS_TYPES]; + +interface SilenceStatusProps extends React.HTMLAttributes { + type: SilenceStatusType; + value: number; + showLabel?: boolean; +} + +export const STATUS_CONFIGS = { + [SILENCE_STATUS_TYPES.FRONT_SILENCE]: { label: '맨 앞', unit: '초' }, + [SILENCE_STATUS_TYPES.BACK_SILENCE]: { label: '맨 뒤', unit: '초' }, + [SILENCE_STATUS_TYPES.END_SILENCE]: { label: '간격', unit: '초' }, +} as const; + +const SilenceStatus = React.forwardRef( + ({ className, type, value, showLabel = false, ...props }, ref) => { + const { label, unit } = STATUS_CONFIGS[type]; + + return ( + + + + {showLabel ? `${label} ` : ''} + {value.toFixed(1)} + {unit} + + + ); + } +); + +SilenceStatus.displayName = 'SilenceStatus'; + +export { SilenceStatus }; diff --git a/src/components/custom/features/tts/SoundStatus.tsx b/src/components/custom/features/tts/SoundStatus.tsx new file mode 100644 index 00000000..62cb3b1e --- /dev/null +++ b/src/components/custom/features/tts/SoundStatus.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import { TbPlaystationTriangle, TbVolume, TbWaveSquare } from 'react-icons/tb'; + +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; + +export const UNIT_SOUND_STATUS_TYPES = { + SPEED: 'unit_speed', + VOLUME: 'unit_volume', + PITCH: 'unit_pitch', +} as const; + +export type UnitStatusType = (typeof UNIT_SOUND_STATUS_TYPES)[keyof typeof UNIT_SOUND_STATUS_TYPES]; + +interface SoundStatusProps extends React.HTMLAttributes { + type: UnitStatusType; + value: number; + showLabel?: boolean; +} + +export const STATUS_CONFIGS = { + [UNIT_SOUND_STATUS_TYPES.SPEED]: { + icon: TbPlaystationTriangle, + unit: 'x', + label: '속도', + rotation: 90, + }, + [UNIT_SOUND_STATUS_TYPES.VOLUME]: { icon: TbVolume, unit: ' dB', label: '볼륨', rotation: 0 }, + [UNIT_SOUND_STATUS_TYPES.PITCH]: { icon: TbWaveSquare, unit: '', label: '피치', rotation: 0 }, +} as const; + +const SoundStatus = React.forwardRef( + ({ className, type, value, showLabel = false, ...props }, ref) => { + const { icon: Icon, unit, label, rotation } = STATUS_CONFIGS[type]; + + return ( + + + + {showLabel ? `${label} ` : ''} + {value.toFixed(1)} + {unit} + + + ); + } +); + +SoundStatus.displayName = 'SoundStatus'; + +export { SoundStatus }; diff --git a/src/components/custom/features/vc/CustomVoiceUpload.tsx b/src/components/custom/features/vc/CustomVoiceUpload.tsx new file mode 100644 index 00000000..f130b24f --- /dev/null +++ b/src/components/custom/features/vc/CustomVoiceUpload.tsx @@ -0,0 +1,82 @@ +import { TbClock, TbMicrophone, TbUpload, TbVolume } from 'react-icons/tb'; + +interface CustomVoiceUploadProps { + onUpload: (files: File[]) => void; +} + +export const CustomVoiceUpload = ({ onUpload }: CustomVoiceUploadProps) => { + const handleFileSelect = (e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + const validFiles: File[] = []; + + Array.from(files).forEach((file) => { + if (file.size > 10 * 1024 * 1024) { + alert(`${file.name}의 크기가 10MB를 초과합니다.`); + return; + } + validFiles.push(file); + }); + + if (validFiles.length > 0) { + onUpload(validFiles); + } + } + }; + + return ( +
+
+
+
    + {[ + { + icon: , + text: '깨끗한 음질의 음성 파일을\n준비해주세요.', + }, + { + icon: , + text: '배경 소음이 없는 음성 파일을 권장합니다.', + }, + { + icon: , + text: '30초 이상의 음성 파일을\n권장합니다.', + }, + ].map((item, index) => ( +
  • +
    + {item.icon} +
    + {item.text} +
  • + ))} +
+
+
+ +
+
+ +
+

음성 파일을 업로드하세요

+

WAV, MP3 파일 지원 (최대 10MB)

+
+ + + +
+ ); +}; diff --git a/src/components/custom/features/vc/VoiceList.tsx b/src/components/custom/features/vc/VoiceList.tsx new file mode 100644 index 00000000..17f90216 --- /dev/null +++ b/src/components/custom/features/vc/VoiceList.tsx @@ -0,0 +1,77 @@ +import { TbChevronLeft, TbChevronRight } from 'react-icons/tb'; + +import VoiceCard from '@/components/custom/cards/VoiceCard'; +import { RadioGroup } from '@/components/ui/radio-group'; +import { usePagination } from '@/hooks/usePagination'; + +interface VoiceListProps { + voices: Array<{ + id: string; + name: string; + description: string; + }>; + selectedVoice: string; + onVoiceSelect: (id: string) => void; + onDelete?: (id: string) => void; + onEdit?: (newName: string) => void; +} + +export const VoiceList = ({ + voices, + selectedVoice, + onVoiceSelect, + onDelete, + onEdit, +}: VoiceListProps) => { + const { currentPage, setCurrentPage, getCurrentPageItems, totalPages } = usePagination({ + data: voices, + itemsPerPage: 4, + }); + + return ( +
+
+ + {getCurrentPageItems().map((voice) => ( + onVoiceSelect(voice.name)} + onDelete={() => onDelete?.(voice.id)} + onEdit={onEdit} + /> + ))} + +
+ +
+ + + {currentPage} / {totalPages} + + +
+
+ ); +}; diff --git a/src/components/custom/features/vc/VoiceSelection.tsx b/src/components/custom/features/vc/VoiceSelection.tsx new file mode 100644 index 00000000..e64b8613 --- /dev/null +++ b/src/components/custom/features/vc/VoiceSelection.tsx @@ -0,0 +1,83 @@ +import { TbMicrophone, TbPlus } from 'react-icons/tb'; + +import { ALLOWED_FILE_TYPES, useFileUpload } from '@/hooks/useFileUpload'; + +import { CustomVoiceUpload } from './CustomVoiceUpload'; +import { VoiceList } from './VoiceList'; + +interface VoiceSelectionProps { + customVoices: Array<{ + id: string; + name: string; + description: string; + }>; + selectedVoice: string; + onVoiceSelect: (id: string) => void; + onVoiceUpload: (files: File[]) => void; + onVoiceDelete?: (id: string) => void; + onVoiceEdit?: (newName: string) => void; +} + +const VoiceSelection = ({ + customVoices, + selectedVoice, + onVoiceSelect, + onVoiceUpload, + onVoiceDelete, + onVoiceEdit, +}: VoiceSelectionProps) => { + const { handleFiles, isLoading } = useFileUpload({ + maxSizeInMB: 10, + allowedTypes: [ALLOWED_FILE_TYPES.WAV, ALLOWED_FILE_TYPES.MP3], + type: 'audio', + onSuccess: (files) => { + onVoiceUpload(files); + }, + }); + + return ( +
+ {customVoices.length > 0 && ( +
+
+ +

내 음성 목록

+
+
+ handleFiles(e.target.files)} + /> + +
+
+ )} + +
+ {customVoices.length > 0 ? ( + + ) : ( + + )} +
+
+ ); +}; + +export default VoiceSelection; diff --git a/src/components/custom/forms/EditProfileForm.tsx b/src/components/custom/forms/EditProfileForm.tsx new file mode 100644 index 00000000..6f47d66e --- /dev/null +++ b/src/components/custom/forms/EditProfileForm.tsx @@ -0,0 +1,360 @@ +import { Separator } from '@radix-ui/react-separator'; +import { AxiosError } from 'axios'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; + +import { changePassword, changeProfile } from '@/api/profileAPI'; +import { EditConfirm } from '@/components/custom/dialogs/EditProfileDialog'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Avatar, AvatarImage } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { Form, FormControl, FormField, FormItem, FormLabel } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useAuthStore } from '@/stores/auth.store'; +import { formatPhoneNumber } from '@/utils/phoneNumber'; +interface ProfileFormData { + email: string; + name: string; + phoneNumber: string; +} + +interface EditProfileFormProps { + defaultValues: ProfileFormData; + avatarUrl: string; +} + +interface PasswordFormData { + currentPassword: string; + newPassword: string; + confirmPassword: string; +} + +const EditProfileForm = ({ defaultValues, avatarUrl }: EditProfileFormProps) => { + const [activeTab, setActiveTab] = useState('profile'); + const [isModalOpen, setIsModalOpen] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const { setUser } = useAuthStore(); + const [showAlert, setShowAlert] = useState(false); + const [lastSuccessfulResponse, setLastSuccessfulResponse] = useState( + false + ); + const [passwordChangeData, setPasswordChangeData] = useState(null); + const [profileChangeData, setProfileChangeData] = useState(null); + + const profileForm = useForm({ + defaultValues: { + ...defaultValues, + phoneNumber: formatPhoneNumber(defaultValues.phoneNumber), + }, + }); + + const passwordForm = useForm({ + defaultValues: { + currentPassword: '', + newPassword: '', + confirmPassword: '', + }, + }); + + const onProfileSubmit = async (data: ProfileFormData) => { + try { + setErrorMessage(''); + const formattedData = { + ...data, + phoneNumber: formatPhoneNumber(data.phoneNumber), + }; + setProfileChangeData(formattedData); + setIsModalOpen(true); + } catch (error) { + if (error instanceof AxiosError) { + setErrorMessage(error.response?.data?.message || '프로필 수정에 실패했습니다.'); + } else if (error instanceof Error) { + setErrorMessage(error.message); + } else { + setErrorMessage('알 수 없는 오류가 발생했습니다.'); + } + } + }; + + const onPasswordSubmit = async (data: PasswordFormData) => { + try { + setErrorMessage(''); + if (data.newPassword !== data.confirmPassword) { + throw new Error('새 비밀번호가 일치하지 않습니다.'); + } + setPasswordChangeData(data); + setIsModalOpen(true); + } catch (error) { + if (error instanceof AxiosError) { + setErrorMessage(error.response?.data?.message || '비밀번호 변경에 실패했습니다.'); + } else if (error instanceof Error) { + setErrorMessage(error.message); + } else { + setErrorMessage('알 수 없는 오류가 발생했습니다.'); + } + } + }; + + const handleConfirmSubmit = async () => { + try { + if (profileChangeData) { + const response = await changeProfile(profileChangeData); + setLastSuccessfulResponse(response.data); + } + if (passwordChangeData) { + await changePassword({ + currentPassword: passwordChangeData.currentPassword, + newPassword: passwordChangeData.newPassword, + confirmPassword: passwordChangeData.confirmPassword, + }); + setLastSuccessfulResponse(true); + } + if (lastSuccessfulResponse) { + if (typeof lastSuccessfulResponse === 'object' && lastSuccessfulResponse !== null) { + setUser({ + email: lastSuccessfulResponse.email, + name: lastSuccessfulResponse.name, + phoneNumber: formatPhoneNumber(lastSuccessfulResponse.phoneNumber), + }); + } + setShowAlert(true); + setTimeout(() => { + setShowAlert(false); + }, 2000); + } + } catch (error) { + if (error instanceof AxiosError) { + setErrorMessage(error.response?.data?.message || '수정에 실패했습니다.'); + } else if (error instanceof Error) { + setErrorMessage(error.message); + } else { + setErrorMessage('알 수 없는 오류가 발생했습니다.'); + } + } + setProfileChangeData(null); + setPasswordChangeData(null); + setIsModalOpen(false); + }; + + return ( + <> + + + 나의 회원정보 + 비밀번호 설정 + + + +

나의 회원정보

+ +
+ + ( + +
+ + 이메일 (아이디) +
+ 이메일 주소는 변경할 수 없습니다. +
+
+
+ + + +
+ )} + /> + +
+
+ + 사진 +
+ 현재 기본 이미지로만 표시됩니다. +
+
+
+
+
+ + + +
+
+
+ + ( + +
+ + 이름 * +
+ 나의 프로필에 게시되는 이름입니다. +
+
+
+ + + +
+ )} + /> + + ( + +
+ + 전화번호 * +
+ 전화번호 수정 시 인증이 필요합니다. +
+
+
+ + { + const value = formatPhoneNumber(e.target.value); + field.onChange({ target: { name: field.name, value } }); + }} + className="flex-1 max-w-[320px] h-[50px]" + /> + +
+ )} + />{' '} + {errorMessage &&

{errorMessage}

} + + +
+ + +

비밀번호 설정

+ +
+ + {' '} + ( + + + 현재 비밀번호 * + + + + + + )} + /> + ( + + + 새 비밀번호 * + + + + + + )} + /> + ( + + + 새 비밀번호 확인 * + + + + + + )} + /> + {errorMessage &&

{errorMessage}

} + + +
+
+ + +
+ + +
+ + + {showAlert && ( +
+ + + 회원정보가 성공적으로 수정되었습니다. + + +
+ )} + + ); +}; + +export default EditProfileForm; diff --git a/src/components/custom/forms/FindAccountForm.tsx b/src/components/custom/forms/FindAccountForm.tsx new file mode 100644 index 00000000..b6a6dc73 --- /dev/null +++ b/src/components/custom/forms/FindAccountForm.tsx @@ -0,0 +1,218 @@ +import React, { useState } from 'react'; + +import { findID, findPassword } from '@/api/profileAPI'; +import { ResultDialog } from '@/components/custom/dialogs/FindResultDialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { formatPhoneNumber } from '@/utils/phoneNumber'; +interface FindAccountProps { + type: 'ID' | 'PW'; +} + +interface FindAccountState { + name: string; + email?: string; + phone: string; + verificationCode: string; + isPhoneVerified: boolean; +} + +const FindAccount: React.FC = ({ type }) => { + const [formData, setFormData] = useState({ + name: '', + email: '', + phone: '', + verificationCode: '', + isPhoneVerified: false, + }); + const [resultDialogOpen, setResultDialogOpen] = useState(false); + const [resultDialogInfo, setResultDialogInfo] = useState({ + title: '', + message: '', + }); + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + if (name === 'phone') { + setFormData((prev) => ({ + ...prev, + phone: formatPhoneNumber(value), + })); + return; + } + setFormData((prev) => ({ + ...prev, + [name]: value, + })); + }; + + const handleSubmit = async () => { + try { + if (type === 'ID') { + const response = await findID(formData.name, formData.phone); + setResultDialogInfo({ + title: '아이디 찾기 결과', + message: `아이디(이메일) : [ ${response.data.email} ]`, + }); + setResultDialogOpen(true); + } else { + const response = await findPassword({ + email: formData.email!, + phoneNumber: formData.phone, + }); + setResultDialogInfo({ + title: '비밀번호 찾기 결과', + message: `비밀번호 : [ ${response.data.password} ]`, + }); + setResultDialogOpen(true); + } + } catch (error) { + setResultDialogInfo({ + title: '오류', + message: error instanceof Error ? error.message : '오류가 발생했습니다.', + }); + setResultDialogOpen(true); + } + }; + + const handleSMS = async () => { + try { + setResultDialogInfo({ + title: '인증번호 발송', + message: '인증번호가 발송되었습니다.', + }); + setResultDialogOpen(true); + } catch (error) { + setResultDialogInfo({ + title: '오류', + message: error instanceof Error ? error.message : '인증번호 발송에 실패했습니다.', + }); + setResultDialogOpen(true); + } + }; + const handleSMSConfirm = async () => { + setFormData((prev) => ({ + ...prev, + isPhoneVerified: true, + })); + setResultDialogInfo({ + title: '인증 성공', + message: '휴대폰 인증이 완료되었습니다.', + }); + setResultDialogOpen(true); + }; + + return ( +
+

{type === 'ID' ? '아이디 찾기' : '비밀번호 찾기'}

+ +

+ {type === 'ID' + ? '회원정보에 등록된 연락처로 본인 확인을 진행하여 아이디를 찾아보세요.' + : '회원정보에 등록된 연락처로 본인 확인을 해 주시면 비밀번호를 재설정할 수 있어요.'} +

+ +
+ {type === 'ID' && ( +
+ + +
+ )} + + {type === 'PW' && ( +
+ + +
+ )} +
+ +
+ + {type === 'PW' && ( + + )} +
+
+ {type === 'PW' && ( +
+ +
+ + +
+
+ )} +
+ + + + +
+ ); +}; +export default FindAccount; diff --git a/src/components/custom/forms/SigninForm.tsx b/src/components/custom/forms/SigninForm.tsx new file mode 100644 index 00000000..08212bce --- /dev/null +++ b/src/components/custom/forms/SigninForm.tsx @@ -0,0 +1,149 @@ +import { AxiosError } from 'axios'; +import { useEffect, useState } from 'react'; +import { FiEye, FiEyeOff } from 'react-icons/fi'; +import { useNavigate } from 'react-router-dom'; + +import { login } from '@/api/authAPI'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import Logo from '@/images/logo.png'; +import { useAuthStore } from '@/stores/auth.store'; + +const SigninForm = () => { + const navigate = useNavigate(); + const { setUser } = useAuthStore(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + const [rememberEmail, setRememberEmail] = useState(false); + const [showPassword, setShowPassword] = useState(false); + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault(); + try { + if (rememberEmail) { + localStorage.setItem('rememberedEmail', email); + } else { + localStorage.removeItem('rememberedEmail'); + } + const userData = await login(email, password); + setUser({ + id: userData.data.id, + email: userData.data.email, + name: userData.data.name, + phoneNumber: userData.data.phoneNumber, + }); + navigate('/'); + } catch (error: unknown) { + if (error instanceof AxiosError) { + setErrorMessage(error.response?.data?.message || '로그인 실패. 다시 시도해주세요.'); + } else if (error instanceof Error) { + setErrorMessage(error.message || '로그인 실패. 다시 시도해주세요.'); + } else { + setErrorMessage('알 수 없는 오류가 발생했습니다. 다시 시도해주세요.'); + } + } + }; + + useEffect(() => { + const savedEmail = localStorage.getItem('rememberedEmail'); + if (savedEmail) { + setEmail(savedEmail); + setRememberEmail(true); + } + }, []); + + const togglePasswordVisibility = () => { + setShowPassword(!showPassword); + }; + + return ( +
+
+ AIPark Logo +
+ +
+ + setEmail(e.target.value)} + className="mt-2 w-[360px] h-[50px] rounded-lg border border-gray-300 bg-white px-4 font-pretendard" + /> + +
+ +
+
+ setPassword(e.target.value)} + className="mt-2 w-[360px] h-[50px] rounded-lg border border-gray-300 bg-white px-4 font-pretendard" + /> + +
+ +
+
+ setRememberEmail(checked as boolean)} + className="h-4 w-4 border-gray-100 data-[state=checked]:bg-primary data-[state=checked]:border-primary" + /> + +
+
+ + | + +
+
+
+ + +
+ {/* 에러 메시지 */} + {errorMessage && ( +

{errorMessage}

+ )} +
+
+ ); +}; + +export default SigninForm; diff --git a/src/components/custom/forms/SignupForm.tsx b/src/components/custom/forms/SignupForm.tsx new file mode 100644 index 00000000..7f636a85 --- /dev/null +++ b/src/components/custom/forms/SignupForm.tsx @@ -0,0 +1,270 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router-dom'; + +import { signup } from '@/api/authAPI'; +import { checkEmail } from '@/api/profileAPI'; +import TermsDialog from '@/components/custom/dialogs/TermsDialog'; +import TermsAgreement from '@/components/custom/features/auth/TermsAgreement'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { TERMS } from '@/constants/terms'; +import { SignupFormData } from '@/types/signup'; +import { formatPhoneNumber } from '@/utils/phoneNumber'; +import { signupFormSchema } from '@/utils/signupSchema'; + +const SignupForm = () => { + const navigate = useNavigate(); + const [successDialog, setSuccessDialog] = useState(false); + + const form = useForm({ + resolver: zodResolver(signupFormSchema), + defaultValues: { + email: '', + pwd: '', + pwdConfirm: '', + name: '', + phoneNumber: '', + terms: [], + }, + }); + + const [termsDialog, setTermsDialog] = useState({ + open: false, + title: '', + content: '', + type: '' as 'service' | 'privacy', + }); + + const [isAllTermsFlow, setIsAllTermsFlow] = useState(false); + const onSubmit = async (data: SignupFormData) => { + try { + await signup({ + email: data.email, + name: data.name, + pwd: data.pwd, + pwdConfirm: data.pwdConfirm, + phoneNumber: data.phoneNumber, + terms: data.terms.join(','), + }); + setSuccessDialog(true); + } catch (error) { + alert(error instanceof Error ? error.message : '회원가입에 실패했습니다.'); + } + }; + + const handleEmailCheck = async () => { + const email = form.getValues('email'); + try { + await checkEmail(email); + form.clearErrors('email'); + form.setError('email', { + type: 'manual', + message: '사용 가능한 이메일입니다.', + }); + } catch (error) { + form.setError('email', { + type: 'manual', + message: error instanceof Error ? error.message : '이메일 중복 확인에 실패했습니다.', + }); + } + }; + + const handleOpenTerms = (type: 'service' | 'privacy', isAll: boolean = false) => { + setIsAllTermsFlow(isAll); + setTermsDialog({ + open: true, + title: type === 'service' ? '서비스 이용약관' : '개인정보 처리방침', + content: type === 'service' ? TERMS.SERVICE : TERMS.PRIVACY, + type, + }); + }; + + const handleAgreeTerms = (type: 'service' | 'privacy') => { + const currentTerms = form.getValues('terms') || []; + if (!currentTerms.includes(type)) { + form.setValue('terms', [...currentTerms, type], { shouldValidate: true }); + } + if (isAllTermsFlow && type === 'service') { + setTimeout(() => { + handleOpenTerms('privacy', true); + }, 100); + } + }; + + return ( +
+ +
+ + 이메일 (아이디) * + +
+ ( + + + + + + + )} + /> + +
+
+ + ( + + + 비밀번호 * + + + + + + + )} + /> + + ( + + + 비밀번호 확인 * + + + + + + + )} + /> + +
+ ( + + + 이름 * + + + + + + + )} + /> +
+ + ( + + + 전화번호 * + (비밀번호 찾을 때, 인증이 필요합니다.) + + + { + const formattedNumber = formatPhoneNumber(e.target.value); + field.onChange(formattedNumber); + }} + /> + + + + )} + /> + + + + + + setTermsDialog((prev) => ({ ...prev, open }))} + title={termsDialog.title} + content={termsDialog.content} + type={termsDialog.type} + onAgree={handleAgreeTerms} + /> + + + + + 회원가입 완료 + 회원가입이 성공적으로 완료되었습니다. + + + + + + + + + ); +}; + +export default SignupForm; diff --git a/src/components/custom/guide/HomePopup.tsx b/src/components/custom/guide/HomePopup.tsx new file mode 100644 index 00000000..f863b74d --- /dev/null +++ b/src/components/custom/guide/HomePopup.tsx @@ -0,0 +1,73 @@ +import { useState } from 'react'; +import { TbX } from 'react-icons/tb'; +import { useNavigate } from 'react-router-dom'; + +import HomeCard from '@/components/custom/cards/HomeCard'; +import HomePopupBg from '@/images/home-popup-bg.svg'; +import { useProjectStore } from '@/stores/project.store'; + +const HomePopup = () => { + const [isVisible, setIsVisible] = useState(true); + const navigate = useNavigate(); + const addProject = useProjectStore((state) => state.addProject); + + const handleNewProject = (type: 'TTS' | 'VC' | 'Concat', route: string) => { + addProject({ + name: '새 프로젝트', + type: type, + }); + + navigate(route); + }; + + if (!isVisible) { + return null; + } + + return ( +
+ {/* Close Button */} + + + {/* Text Content */} +

AI 기술이 만든 스마트한 음성 솔루션

+

오디오 작업의 새로운 경험. 지금 시작해 보세요!

+ + {/* Cards Container */} +
+ handleNewProject('TTS', '/tts')} + /> + handleNewProject('VC', '/vc')} + /> + handleNewProject('Concat', '/concat')} + /> +
+
+ ); +}; + +export default HomePopup; diff --git a/src/components/custom/guide/TooltipWrapper.tsx b/src/components/custom/guide/TooltipWrapper.tsx new file mode 100644 index 00000000..661508f0 --- /dev/null +++ b/src/components/custom/guide/TooltipWrapper.tsx @@ -0,0 +1,49 @@ +import * as TooltipPrimitive from '@radix-ui/react-tooltip'; +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +interface TooltipWrapperProps { + content: React.ReactNode; + children: React.ReactNode; + className?: string; + sideOffset?: number; +} + +const TooltipWrapper = React.forwardRef( + ({ content, children, className, sideOffset = 16 }, ref) => { + return ( + + + + {children} + + +

+ {content} +

+
+
+
+ ); + } +); + +TooltipWrapper.displayName = 'TooltipWrapper'; + +export default TooltipWrapper; diff --git a/src/components/custom/icons/PlusIcon.tsx b/src/components/custom/icons/PlusIcon.tsx new file mode 100644 index 00000000..6c796a62 --- /dev/null +++ b/src/components/custom/icons/PlusIcon.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; + +const PlusIcon: React.FC> = ({ + width = 24, + height = 24, + className, + ...props +}) => ( + + + + +); + +export default PlusIcon; diff --git a/src/components/custom/tables/history/HistoryListTable.tsx b/src/components/custom/tables/history/HistoryListTable.tsx new file mode 100644 index 00000000..deb633d3 --- /dev/null +++ b/src/components/custom/tables/history/HistoryListTable.tsx @@ -0,0 +1,145 @@ +import { TbDownload } from 'react-icons/tb'; + +import { PlayButton } from '@/components/custom/buttons/PlayButton'; +import { StatusBadge } from '@/components/custom/tables/history/RecentExportTable'; +import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; + +export interface ProjectListTableItem { + id: string; + order: string; + projectName: string; + fileName: string; + script: string; + projectType: 'VC' | 'TTS' | 'Concat'; + status: '진행' | '대기중' | '실패' | '완료'; + unitStatus?: 'SUCCESS' | 'FAILURE'; + updatedAt: string; + onClick?: () => void; +} + +interface HistoryListTableProps { + items: ProjectListTableItem[]; + onPlay: (id: string) => void; + onPause: (id: string) => void; + onDownload: (id: string) => void; + currentPlayingId?: string; + isAllSelected: boolean; + itemCount: number; + onSelectAll: (checked: boolean) => void; + selectedItems: string[]; + onSelectionChange: (id: string, checked: boolean) => void; +} + +const AudioBadge = ({ type }: { type: 'VC' | 'TTS' | 'Concat' }) => ( + {type} +); + +export function HistoryListTable({ + items, + onPlay, + onPause, + onDownload, + currentPlayingId, + isAllSelected, + itemCount, + onSelectAll, + selectedItems, + onSelectionChange, +}: HistoryListTableProps) { + return ( + + {/* Header */} + + + + 0 && isAllSelected} + onCheckedChange={(checked) => onSelectAll(checked as boolean)} + /> + + 유형 + 프로젝트명 + 파일명 + 스크립트 + 상태 + + 다운로드 + + 업데이트 날짜 + + + + {/* Body */} + + {items.map((item, index) => ( + + + onSelectionChange(item.id, checked as boolean)} + onClick={(e) => e.stopPropagation()} + /> + + +
e.stopPropagation()} className="flex items-center gap-[14px]"> + onPlay(item.id)} + onPause={() => onPause(item.id)} + className="scale-90" + /> + +
+
+ + {item.projectName} + + + {item.fileName} + + +
{item.script}
+
+ +
+ {item.unitStatus === 'SUCCESS' || + item.unitStatus === 'FAILURE' || + item.unitStatus === null ? ( + + ) : null} +
+
+ +
+ +
+
+ {item.updatedAt} +
+ ))} +
+
+ ); +} diff --git a/src/components/custom/tables/history/ProjectListTable.tsx b/src/components/custom/tables/history/ProjectListTable.tsx new file mode 100644 index 00000000..54518e9c --- /dev/null +++ b/src/components/custom/tables/history/ProjectListTable.tsx @@ -0,0 +1,99 @@ +import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; + +export interface ProjectListTableItem { + id: string; + order: string; + projectName: string; + fileName: string; + script: string; + projectType: 'VC' | 'TTS' | 'Concat'; + status: '진행' | '대기중' | '실패' | '완료'; + updatedAt: string; + onClick?: () => void; +} + +interface ProjectListTableProps { + items: ProjectListTableItem[]; + + currentPlayingId?: string; + isAllSelected: boolean; + itemCount: number; + onSelectAll: (checked: boolean) => void; + selectedItems: string[]; + onSelectionChange: (id: string, checked: boolean) => void; +} + +const AudioBadge = ({ type }: { type: 'VC' | 'TTS' | 'Concat' }) => ( + {type} +); + +export function ProjectListTable({ + items, + currentPlayingId, + isAllSelected, + itemCount, + onSelectAll, + selectedItems, + onSelectionChange, +}: ProjectListTableProps) { + return ( + + {/* Header */} + + + + 0 && isAllSelected} + onCheckedChange={(checked) => onSelectAll(checked as boolean)} + onClick={(e) => e.stopPropagation()} // 이벤트 전파 방지 + /> + + + 유형 + 프로젝트명 + 스크립트 + 업데이트 날짜 + + + + {/* Body */} + + {items.map((item) => ( + + + onSelectionChange(item.id, checked as boolean)} + onClick={(e) => e.stopPropagation()} + /> + + +
+ +
+
+ {item.projectName} + + {item.script} + + {item.updatedAt} +
+ ))} +
+
+ ); +} diff --git a/src/components/custom/tables/history/RecentExportTable.tsx b/src/components/custom/tables/history/RecentExportTable.tsx new file mode 100644 index 00000000..f9404bef --- /dev/null +++ b/src/components/custom/tables/history/RecentExportTable.tsx @@ -0,0 +1,301 @@ +import { useCallback, useEffect, useState } from 'react'; +import { TbChevronRight, TbCircleFilled, TbDownload } from 'react-icons/tb'; +import { useNavigate } from 'react-router-dom'; + +import { fetchProjectByType, fetchRecentExports } from '@/api/workspaceAPI'; +import { PlayButton } from '@/components/custom/buttons/PlayButton'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; + +export interface RecentExportTableItem { + metaId: number; + id: number; + projectName: string; + fileName: string; + content: string; + type: 'VC' | 'TTS' | 'Concat'; + unitStatus?: string; + createdAt: string; + url?: string; +} + +interface StatusBadgeProps { + unitStatus: 'SUCCESS' | 'FAILURE'; +} + +export const StatusBadge: React.FC = ({ unitStatus }) => { + const variantMap = { + FAILURE: 'failed', + SUCCESS: 'completed', + NONE: 'failed', + } as const; + + const textMap = { + FAILURE: '실패', + SUCCESS: '완료', + NONE: '상태없음', + } as const; + + const status = unitStatus === null ? 'NONE' : unitStatus; + + return ( +
+ + + {textMap[status]} + +
+ ); +}; + +export function RecentExportTable() { + const navigate = useNavigate(); + const [items, setItems] = useState([]); + const [audio, setAudio] = useState(null); + const [currentPlayingKey, setCurrentPlayingKey] = useState(null); // projectId와 metaId 조합으로 관리 + const [alert, setAlert] = useState<{ + visible: boolean; + message: string; + variant: 'default' | 'destructive'; + }>({ + visible: false, + message: '', + variant: 'default', + }); + + const AudioBadge = useCallback((type: 'VC' | 'TTS' | 'Concat') => { + const variant = type.toLowerCase() as 'vc' | 'tts' | 'concat'; + return {type}; + }, []); + + // 최근 내보내기 내역 호출 + useEffect(() => { + const loadRecentExports = async () => { + try { + const data = await fetchRecentExports(); + if (!data || data.length === 0) { + return; + } + setItems(data); // 매핑된 데이터를 그대로 설정 + } catch (err) { + console.error(err); + } + }; + + loadRecentExports(); + }, []); + + const handleProjectClick = async (projectId: number, projectType: 'TTS' | 'VC' | 'CONCAT') => { + try { + const response = await fetchProjectByType(projectId, projectType); + console.log('프로젝트 데이터:', response.data); + + // 프로젝트 타입에 따른 경로 생성 + const path = `/${projectType.toLowerCase()}/${projectId}`; + + // 상세 페이지로 이동 + navigate(path, { state: response.data }); + } catch (error) { + console.error('프로젝트 로드 중 오류 발생:', error); + setAlert({ + visible: true, + message: '프로젝트 로드 중 오류가 발생했습니다. 다시 시도해주세요.', + variant: 'destructive', + }); + + setTimeout(() => setAlert({ visible: false, message: '', variant: 'default' }), 2000); + } + }; + + const handlePlay = (projectId: number, metaId: number, url: string) => { + const key = `${projectId}-${metaId}`; + if (currentPlayingKey === key) { + handlePause(); + return; + } + + if (audio) { + audio.pause(); + setAudio(null); + } + + const newAudio = new Audio(url); + newAudio.crossOrigin = 'anonymous'; + newAudio.play(); + setAudio(newAudio); + setCurrentPlayingKey(key); + + console.log('재생 시작:', key); + }; + + const handlePause = () => { + if (audio) { + audio.pause(); + setAudio(null); + } + setCurrentPlayingKey(null); + console.log('재생 멈춤'); + }; + + const isPlaying = (projectId: number, metaId: number) => { + return currentPlayingKey === `${projectId}-${metaId}`; + }; + + const handleDownload = (url: string, fileName: string) => { + if (!url) { + setAlert({ + visible: true, + message: '다운로드할 파일이 없습니다.', + variant: 'destructive', + }); + + setTimeout(() => setAlert({ visible: false, message: '', variant: 'default' }), 2000); + return; + } + + fetch(url) + .then((response) => { + if (!response.ok) { + throw new Error('다운로드 실패'); + } + return response.blob(); + }) + .then((blob) => { + const link = document.createElement('a'); + const blobUrl = URL.createObjectURL(blob); + link.href = blobUrl; + link.download = fileName; + link.click(); + URL.revokeObjectURL(blobUrl); + setAlert({ + visible: true, + message: `${fileName} 다운로드가 완료되었습니다.`, + variant: 'default', + }); + + setTimeout(() => setAlert({ visible: false, message: '', variant: 'default' }), 2000); + }) + .catch((error) => { + console.error('다운로드 중 오류 발생:', error); + setAlert({ + visible: true, + message: '다운로드 중 문제가 발생했습니다.', + variant: 'destructive', + }); + + // 알림 자동 제거 + setTimeout(() => setAlert({ visible: false, message: '', variant: 'default' }), 2000); + }); + }; + + return ( +
+ {alert.visible && ( +
+ + {alert.message} + +
+ )} +
+

최근 내보내기

+

navigate('/History')} + className="text-black text-body2 flex items-center gap-1 cursor-pointer" + > + 전체보기 + +

+
+ + + + 유형 + 프로젝트명 + 파일명 + 스크립트 + 상태 + 다운로드 + 업데이트 날짜 + + + + {items.map((item, index) => { + const metaId = item.metaId ?? `meta-${index}`; + const projectId = item.id ?? `project-${index}`; + const key = `${projectId}-${metaId}`; + const playing = isPlaying(projectId, metaId); + + return ( + + handleProjectClick(projectId, item.type.toUpperCase() as 'TTS' | 'VC' | 'CONCAT') + } + className="cursor-pointer" + > + +
e.stopPropagation()} + className="flex items-center gap-[14px]" + > + handlePlay(projectId, metaId, item.url || '')} + onPause={() => handlePause()} + className="scale-90" + /> + {AudioBadge(item.type)} +
+
+ + {item.projectName} + + + {item.fileName} + + +
{item.content}
+
+ +
+ {item.unitStatus === 'SUCCESS' || + item.unitStatus === 'FAILURE' || + item.unitStatus === null ? ( + + ) : null} +
+
+ +
+ +
+
+ + {item.createdAt} + +
+ ); + })} +
+
+
+ ); +} diff --git a/src/components/custom/tables/history/TableToolbar.tsx b/src/components/custom/tables/history/TableToolbar.tsx new file mode 100644 index 00000000..b4361f72 --- /dev/null +++ b/src/components/custom/tables/history/TableToolbar.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { TbSearch, TbTrash } from 'react-icons/tb'; + +import { Input } from '@/components/ui/input'; + +interface TableToolbarProps { + title: string; + totalItemsCount: number; + selectedItemsCount: number; + onDelete: () => void; + onSearch: (searchTerm: string) => void; +} + +const TableToolbar: React.FC = ({ + title, + totalItemsCount, + selectedItemsCount, + onDelete, + onSearch, +}) => { + const isDeleteDisabled = selectedItemsCount === 0; + + return ( +
+ {/* Left Section: Title and Delete Button */} +
+ {title} + {totalItemsCount} + +
+ + {/* Right Section: Search Input and Filter Button */} +
+
+ + onSearch(e.target.value)} + /> +
+
+
+ ); +}; + +export default TableToolbar; diff --git a/src/components/custom/tables/project/common/TableContents.tsx b/src/components/custom/tables/project/common/TableContents.tsx new file mode 100644 index 00000000..0fdf4d6a --- /dev/null +++ b/src/components/custom/tables/project/common/TableContents.tsx @@ -0,0 +1,169 @@ +import * as React from 'react'; +import { useState } from 'react'; + +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { FILE_CONSTANTS } from '@/constants/messages'; +import { useFileUpload } from '@/hooks/useFileUpload'; +import { useTableItems } from '@/hooks/useTableItems'; +import TableUploadMessage from '@/images/table-upload-message.svg'; +import { cn } from '@/lib/utils'; +import { TableItem } from '@/types/table'; +import { textSplitter } from '@/utils/textSpliter'; + +import { TTSTableGridView } from '../tts/TTSTableGridView'; +import { TableFooter } from './TableFooter'; +import { TableHeader } from './TableHeader'; +import { TableListView } from './TableListView'; + +interface TableContentsProps { + items: (TableItem & { + status?: '대기중' | '완료' | '실패' | '진행'; + targetVoice?: string; + originalAudioUrl?: string; + convertedAudioUrl?: string; + type?: 'TTS' | 'VC' | 'Concat'; + })[]; + onSelectionChange: (id: string) => void; + onTextChange: (id: string, newText: string) => void; + onDelete: () => void; + onAdd: (newItems?: TableItem[]) => void; + onRegenerateItem?: (id: string) => void; + onDownloadItem?: (id: string) => void; + onPlay: (id: string) => void; + onSelectAll?: () => void; + isAllSelected?: boolean; + type?: 'TTS' | 'VC' | 'Concat'; + onReorder?: (startIndex: number, endIndex: number) => void; + onFileUpload?: (files: FileList | null) => void; + hasAudioFile?: boolean; +} + +export const TableContents: React.FC = ({ + items, + onSelectionChange, + onTextChange, + onDelete, + onAdd, + onRegenerateItem, + onDownloadItem, + onPlay, + onSelectAll, + isAllSelected, + type, + onReorder, + onFileUpload, + hasAudioFile, +}) => { + const [isListView, setIsListView] = React.useState(true); + const [error, setError] = useState(null); + + const { handleFiles } = useFileUpload({ + maxSizeInMB: 5, + allowedTypes: ['text/plain'], + type: 'text', + onSuccess: (texts) => { + const sentences = texts.flatMap((text) => textSplitter(text)); + const newItems = sentences.map((text) => ({ + id: crypto.randomUUID(), + text, + isSelected: false, + })); + onAdd(newItems); + }, + onError: setError, + }); + + const { selectedCount, handleRegenerate, handleDownload, listItems, gridItems } = useTableItems({ + items: items.map((item) => ({ + ...item, + audioUrl: + type === 'VC' + ? item.originalAudioUrl || item.audioUrl + : item.convertedAudioUrl || item.audioUrl, + })), + onPlay, + onRegenerateItem, + onDownloadItem, + onSelectionChange, + onTextChange, + }); + + React.useEffect(() => { + if (items.length === 0 && isAllSelected) { + onSelectAll?.(); + } + }, [items.length, isAllSelected, onSelectAll]); + + React.useEffect(() => { + if (error) { + const timer = window.setTimeout(() => { + setError(null); + }, FILE_CONSTANTS.ERROR_TIMEOUT); + + return () => { + window.clearTimeout(timer); + }; + } + }, [error]); + + return ( + <> + {error && ( + + {error} + + )} +
+ +
+ {items.length === 0 ? ( +
+ Empty table message +
+ ) : ( + + {isListView ? ( + + ) : ( + + )} + + )} +
+ handleRegenerate()} + onDownload={() => handleDownload()} + isListView={isListView} + type={type} + /> +
+ + ); +}; diff --git a/src/components/custom/tables/project/common/TableFooter.tsx b/src/components/custom/tables/project/common/TableFooter.tsx new file mode 100644 index 00000000..83fb7f9e --- /dev/null +++ b/src/components/custom/tables/project/common/TableFooter.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; + +import { RecreateButton } from '@/components/custom/buttons/IconButton'; +import TooltipWrapper from '@/components/custom/guide/TooltipWrapper'; +import { TTS_TOOLTIP } from '@/constants/tooltips'; +import { cn } from '@/lib/utils'; + +interface TableFooterProps { + selectedCount: number; + onRegenerate: () => void; + onDownload: () => void; + isListView: boolean; + type?: 'TTS' | 'VC' | 'Concat'; +} + +export const TableFooter: React.FC = ({ + selectedCount, + onRegenerate, + isListView, + type = 'TTS', +}) => ( +
+
+
선택 항목: {selectedCount}
+ {isListView && ( +
+ {type === 'TTS' && ( + +
+ +
+
+ )} +
+ )} +
+
+); diff --git a/src/components/custom/tables/project/common/TableHeader.tsx b/src/components/custom/tables/project/common/TableHeader.tsx new file mode 100644 index 00000000..dccb40f2 --- /dev/null +++ b/src/components/custom/tables/project/common/TableHeader.tsx @@ -0,0 +1,176 @@ +import * as React from 'react'; +import { TbCirclePlus, TbTrash } from 'react-icons/tb'; + +import { UploadAudioButton, UploadTextButton } from '@/components/custom/buttons/IconButton'; +import ViewButtonGroup from '@/components/custom/buttons/ViewFilterButton'; +import TooltipWrapper from '@/components/custom/guide/TooltipWrapper'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Separator } from '@/components/ui/separator'; +import { VC_TOOLTIP } from '@/constants/tooltips'; +import { useTextUpload } from '@/hooks/useFileUpload'; +import { cn } from '@/lib/utils'; +import { useVCStore } from '@/stores/vc.store'; + +interface TableHeaderProps { + onDelete: () => void; + onAdd: () => void; + onSelectAll?: () => void; + isAllSelected?: boolean; + isListView: boolean; + onViewChange: (isListView: boolean) => void; + itemCount: number; + type?: 'TTS' | 'VC' | 'Concat'; + onFileUpload: (files: FileList | null) => void; + isLoading?: boolean; + hasAudioFile?: boolean; +} + +export const TTSTableHeader: React.FC = () => { + return ( +
+
+
+
+
텍스트
+
+
속도
+
볼륨
+
피치
+
내역
+
+
+ ); +}; + +export const VCTableHeader: React.FC = () => { + return ( +
+
+
+
파일명
+
텍스트
+
타겟 보이스
+
+ ); +}; + +export const ConcatTableHeader: React.FC = () => { + return ( +
+
+
+
파일명
+
텍스트
+
+
맨 앞
+
맨 뒤
+
간격
+
+
+ ); +}; + +export const TableHeader: React.FC = ({ + onDelete, + onAdd, + onSelectAll, + isAllSelected, + isListView, + onViewChange, + itemCount, + type = 'TTS', + onFileUpload, + isLoading, + hasAudioFile = false, +}) => { + const { openFileDialog: openTextFileDialog } = useTextUpload( + (texts) => { + const dataTransfer = new DataTransfer(); + texts.forEach((text) => { + const file = new File([text], 'text.txt', { type: 'text/plain' }); + dataTransfer.items.add(file); + }); + onFileUpload(dataTransfer.files); + }, + { + onError: (error) => { + useVCStore.getState().showAlert(error, 'destructive'); + }, + } + ); + + return ( +
+
+
+
+ 0 && isAllSelected} + onCheckedChange={() => onSelectAll?.()} + className="cursor-pointer" + /> +
+ + {type === 'TTS' && ( + <> + +
+ + 텍스트 추가 +
+ + )} +
+
+ {type === 'VC' || type === 'Concat' ? ( +
+ +
+ +
+ { + if (!hasAudioFile) return; + openTextFileDialog(); + }} + isLoading={isLoading} + disabled={!hasAudioFile} + /> +
+
+
+
+ ) : type === 'TTS' ? ( + <> + { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.txt'; + input.multiple = true; + input.onchange = (e) => { + onFileUpload((e.target as HTMLInputElement).files); + }; + input.click(); + }} + isLoading={isLoading} + /> + + + ) : null} +
+
+
+ ); +}; diff --git a/src/components/custom/tables/project/common/TableListView.tsx b/src/components/custom/tables/project/common/TableListView.tsx new file mode 100644 index 00000000..ae3f3038 --- /dev/null +++ b/src/components/custom/tables/project/common/TableListView.tsx @@ -0,0 +1,117 @@ +import { closestCenter, DndContext, DragEndEvent } from '@dnd-kit/core'; +import { useSensor, useSensors } from '@dnd-kit/core'; +import { PointerSensor } from '@dnd-kit/core'; +import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import React from 'react'; + +import { ConcatListRow } from '../concat/ConcatListRow'; +import { TTSListRow } from '../tts/TTSListRow'; +import { VCListRow } from '../vc/VCListRow'; +import { ConcatTableHeader, TTSTableHeader, VCTableHeader } from './TableHeader'; + +interface ListRowProps { + id: string; + text: string; + isSelected: boolean; + onPlay: (id: string) => void; + onSelectionChange: (id: string) => void; + onTextChange: (id: string, newText: string) => void; + type?: 'TTS' | 'VC' | 'Concat'; + fileName?: string; + speed: number; + volume: number; + pitch: number; + convertedAudioUrl?: string; + originalAudioUrl?: string; + status?: '대기중' | '완료' | '실패' | '진행'; + targetVoice?: string; + frontSilence?: number; + backSilence?: number; + endSilence?: number; +} + +interface TableListViewProps { + rows: ListRowProps[]; + onSelectionChange: (id: string) => void; + onTextChange: (id: string, newText: string) => void; + type?: 'TTS' | 'VC' | 'Concat'; + onReorder?: (startIndex: number, endIndex: number) => void; +} + +export const TableListView: React.FC = ({ + rows, + onSelectionChange, + onTextChange, + type = 'TTS', + onReorder, +}) => { + const renderHeader = () => { + switch (type) { + case 'TTS': + return ; + case 'VC': + return ; + case 'Concat': + return ; + } + }; + + const renderRow = (row: ListRowProps) => { + switch (type) { + case 'TTS': + return ( + + ); + case 'VC': + return ( + + ); + case 'Concat': + return ( + + ); + } + }; + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (!over || active.id === over.id) { + return; + } + + const oldIndex = rows.findIndex((row) => row.id === active.id); + const newIndex = rows.findIndex((row) => row.id === over.id); + + if (oldIndex === -1 || newIndex === -1) { + return; + } + + onReorder?.(oldIndex, newIndex); + }; + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }) + ); + + return ( +
+ {renderHeader()} + + row.id)} strategy={verticalListSortingStrategy}> + {rows.map((row) => ( + {renderRow(row)} + ))} + + +
+ ); +}; diff --git a/src/components/custom/tables/project/concat/ConcatListRow.tsx b/src/components/custom/tables/project/concat/ConcatListRow.tsx new file mode 100644 index 00000000..862917db --- /dev/null +++ b/src/components/custom/tables/project/concat/ConcatListRow.tsx @@ -0,0 +1,103 @@ +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import React, { useEffect } from 'react'; + +import { PlayButton } from '@/components/custom/buttons/PlayButton'; +import { + SILENCE_STATUS_TYPES, + SilenceStatus, +} from '@/components/custom/features/concat/SilenceStatus'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Textarea } from '@/components/ui/textarea'; +import { useConcatStore } from '@/stores/concat.store'; +import { ListRowProps } from '@/types/table'; + +export const ConcatListRow: React.FC = ({ + id, + text, + isSelected, + onPlay, + onSelectionChange, + onTextChange, + fileName, + frontSilence = 0, + backSilence = 0, + endSilence = 0, +}) => { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id, + }); + + const { audioPlayer, handlePause } = useConcatStore(); + const isPlaying = audioPlayer.currentPlayingId === id; + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + const handleTextAreaResize = (element: HTMLTextAreaElement) => { + element.style.height = 'auto'; + element.style.height = `${element.scrollHeight}px`; + }; + + useEffect(() => { + const textareas = document.querySelectorAll('textarea'); + textareas.forEach((textarea) => { + textarea.style.height = 'auto'; + textarea.style.height = `${textarea.scrollHeight}px`; + }); + }, [text]); + + return ( +
+
+
+ onSelectionChange(id)} + className="cursor-pointer ml-2 mr-2" + id={`checkbox-${id}`} + /> +
onSelectionChange(id)} /> +
+ (isPlaying ? handlePause() : onPlay(id))} + className="ml-2 mr-2 w-6 h-6" + isPlaying={isPlaying} + /> +
{fileName}
+