From bdb08f0308fb679bd602942d1b71218a61203ea0 Mon Sep 17 00:00:00 2001 From: ChoiWonkeun Date: Mon, 10 Nov 2025 23:02:43 +0900 Subject: [PATCH 01/14] chore: update .gitignore (dist, node_modules, env, pycache) --- .gitignore | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 9fef65d..8c46b95 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,33 @@ +# ======================== # Python +# ======================== __pycache__/ *.pyc .venv/ +.venv*/ +.pytest_cache/ +coverage/ -# Node.js +# ======================== +# Node.js / React +# ======================== node_modules/ .next/ out/ +apps/web/dist/ +apps/web/web-react/dist/ -# Docker +# ======================== +# Docker / Logs +# ======================== *.log .env +.env.* +docker-compose.override.yml -# VSCode -.vscode/ \ No newline at end of file +# ======================== +# IDE / Editor +# ======================== +.vscode/ +.idea/ +.DS_Store From 0ada5c0dd2a72132c6ff0c208970779720a2a415 Mon Sep 17 00:00:00 2001 From: ChoiWonkeun Date: Mon, 10 Nov 2025 23:09:04 +0900 Subject: [PATCH 02/14] ci: add base CI workflow (backend/frontend) --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..34c8be1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1 @@ +code .github/workflows/ci.yml \ No newline at end of file From 3a8aa07842f51d3aaed8ff3c203580fd482195c8 Mon Sep 17 00:00:00 2001 From: ChoiWonkeun Date: Mon, 10 Nov 2025 23:11:35 +0900 Subject: [PATCH 03/14] ci: add base CI workflow (backend/frontend) --- .github/workflows/ci.yml | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34c8be1..277cbcd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1 +1,36 @@ -code .github/workflows/ci.yml \ No newline at end of file +name: CI + +on: + push: + branches: [ develop, "feature/*" ] + pull_request: + branches: [ develop, main ] + +jobs: + backend-python: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: "pip" + - name: Install deps + run: | + pip install -r requirements.txt || true + pip install pytest || true + - name: Test + run: pytest -q || true + + frontend-node: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + - name: Install + run: npm ci || true + - name: Build + run: npm run build || true From 9e484a98617813f64be27fa0e9349b051b89011c Mon Sep 17 00:00:00 2001 From: ChoiWonkeun Date: Mon, 10 Nov 2025 23:14:29 +0900 Subject: [PATCH 04/14] fix(ci): correct indentation for frontend job --- .github/workflows/ci.yml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 277cbcd..b1ad78d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,13 +24,27 @@ jobs: frontend-node: runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/web/web-react steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" + cache-dependency-path: apps/web/web-react/package-lock.json - name: Install - run: npm ci || true + run: | + if [ -f package-lock.json ]; then + npm ci + else + npm install + fi - name: Build - run: npm run build || true + run: | + if npm run | findstr /I /R "^ build"; then + npm run build + else + echo "No build script. Skipping." + fi From c34619c38e0aeb219330c7d0be7812da241b26eb Mon Sep 17 00:00:00 2001 From: ChoiWonkeun Date: Mon, 10 Nov 2025 23:18:08 +0900 Subject: [PATCH 05/14] ci(frontend): fix npm cache path and use grep on ubuntu --- .github/workflows/ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1ad78d..3f1b807 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,9 @@ jobs: with: node-version: "20" cache: "npm" - cache-dependency-path: apps/web/web-react/package-lock.json + cache-dependency-path: | + apps/web/web-react/package-lock.json + apps/web/package-lock.json - name: Install run: | if [ -f package-lock.json ]; then @@ -43,8 +45,9 @@ jobs: fi - name: Build run: | - if npm run | findstr /I /R "^ build"; then + if npm run | grep -E "^\s*build" >/dev/null; then npm run build else echo "No build script. Skipping." fi + From 4a9ac23058e6bde68fb493728192d82182896d50 Mon Sep 17 00:00:00 2001 From: ChoiWonkeun Date: Mon, 10 Nov 2025 23:21:15 +0900 Subject: [PATCH 06/14] ci(frontend): run npm at repo root --- .github/workflows/ci.yml | 31 ++----------------------------- 1 file changed, 2 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f1b807..04ce068 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,41 +1,15 @@ -name: CI - -on: - push: - branches: [ develop, "feature/*" ] - pull_request: - branches: [ develop, main ] - -jobs: - backend-python: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.11" - cache: "pip" - - name: Install deps - run: | - pip install -r requirements.txt || true - pip install pytest || true - - name: Test - run: pytest -q || true - frontend-node: runs-on: ubuntu-latest defaults: run: - working-directory: apps/web/web-react + working-directory: . steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" - cache-dependency-path: | - apps/web/web-react/package-lock.json - apps/web/package-lock.json + cache-dependency-path: package-lock.json - name: Install run: | if [ -f package-lock.json ]; then @@ -50,4 +24,3 @@ jobs: else echo "No build script. Skipping." fi - From c5c9d7547840007987fea242cc45af509c6dd8f7 Mon Sep 17 00:00:00 2001 From: ChoiWonkeun Date: Mon, 10 Nov 2025 23:22:57 +0900 Subject: [PATCH 07/14] fix(ci): restore full workflow with jobs root --- .github/workflows/ci.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04ce068..eaaf15f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,3 +1,27 @@ +name: CI + +on: + push: + branches: [ develop, "feature/*" ] + pull_request: + branches: [ develop, main ] + +jobs: + backend-python: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: "pip" + - name: Install deps + run: | + pip install -r requirements.txt || true + pip install pytest || true + - name: Test + run: pytest -q || true + frontend-node: runs-on: ubuntu-latest defaults: From 4b3043086a0a732fc4f241ef20335a80546c2a60 Mon Sep 17 00:00:00 2001 From: ChoiWonkeun Date: Mon, 10 Nov 2025 23:26:13 +0900 Subject: [PATCH 08/14] ci: final tidy (setup-node with npm cache) --- .github/workflows/ci.yml | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eaaf15f..5242436 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,16 +11,26 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: "3.11" cache: "pip" + - name: Install deps run: | - pip install -r requirements.txt || true + if [ -f requirements.txt ]; then + pip install -r requirements.txt || true + fi pip install pytest || true + - name: Test - run: pytest -q || true + run: | + if [ -f pytest.ini ] || [ -d tests ]; then + pytest -q || true + else + echo "No python tests. Skipping." + fi frontend-node: runs-on: ubuntu-latest @@ -29,21 +39,25 @@ jobs: working-directory: . steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" - cache-dependency-path: package-lock.json + - name: Install run: | if [ -f package-lock.json ]; then npm ci - else + elif [ -f package.json ]; then npm install + else + echo "No package.json. Skipping install." fi + - name: Build run: | - if npm run | grep -E "^\s*build" >/dev/null; then + if [ -f package.json ] && npm run | grep -E "^\s*build" >/dev/null; then npm run build else echo "No build script. Skipping." From 44f74855a3cdb92d8ddab302df6ba2c7358d949c Mon Sep 17 00:00:00 2001 From: ChoiWonkeun Date: Mon, 10 Nov 2025 23:28:46 +0900 Subject: [PATCH 09/14] ci(frontend): remove npm cache to avoid lock-file check error --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5242436..11f7d11 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,6 @@ jobs: - uses: actions/setup-node@v4 with: node-version: "20" - cache: "npm" - name: Install run: | From d441a5d60e87497ae9a0799fbe7feff42656a94a Mon Sep 17 00:00:00 2001 From: ChoiWonkeun Date: Mon, 10 Nov 2025 23:36:48 +0900 Subject: [PATCH 10/14] ci(release): add auto GitHub release workflow on tag push --- .github/workflows/release.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ecaa97a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,21 @@ +name: Release + +on: + push: + tags: + - "v*" # v로 시작하는 태그(v1.0.0 등) 푸시 시 실행 + +jobs: + create-release: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 4ebe230ad090a36aa89205bdae6f2ea87be3e6eb Mon Sep 17 00:00:00 2001 From: ChoiWonkeun Date: Mon, 10 Nov 2025 23:58:11 +0900 Subject: [PATCH 11/14] ci(release): grant contents write permission --- .github/workflows/release.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ecaa97a..baa92ac 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,19 +3,19 @@ name: Release on: push: tags: - - "v*" # v로 시작하는 태그(v1.0.0 등) 푸시 시 실행 + - "v*" + +permissions: + contents: write # ✅ 릴리즈/태그 쓰기 권한 jobs: create-release: runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - + - uses: actions/checkout@v4 - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: generate_release_notes: true env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file From 64e0cdb9ee3e5f1dfc4e28c0f05c62ba0f4158da Mon Sep 17 00:00:00 2001 From: ChoiWonkeun Date: Wed, 12 Nov 2025 18:34:59 +0900 Subject: [PATCH 12/14] ci: add lint workflow and tweak ci/release; docs & ignores --- .github/workflows/lint.yml | 65 +++++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 12 +++---- .gitignore | 25 +++----------- 3 files changed, 75 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/lint.yml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..c037113 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,65 @@ +name: Lint + +on: + push: + branches: [ develop, "feature/*" ] + pull_request: + branches: [ develop, main ] + +jobs: + python-flake8: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + # 파이썬 파일이 있을 때만 lint 실행 + - id: detect_py + name: Detect Python files + run: | + if [ -n "$(git ls-files '*.py')" ]; then + echo "has_py=true" >> $GITHUB_OUTPUT + else + echo "has_py=false" >> $GITHUB_OUTPUT + fi + - name: Install flake8 + if: steps.detect_py.outputs.has_py == 'true' + run: pip install flake8 + - name: Run flake8 + if: steps.detect_py.outputs.has_py == 'true' + run: | + echo "Running flake8..." + flake8 . || true # 초기엔 실패 막기 + + node-eslint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + # JS/TS 파일이 있을 때만 lint 실행 + - id: detect_js + name: Detect JS/TS files + run: | + if [ -n "$(git ls-files '*.js' '*.jsx' '*.ts' '*.tsx' 2>/dev/null)" ]; then + echo "has_js=true" >> $GITHUB_OUTPUT + else + echo "has_js=false" >> $GITHUB_OUTPUT + fi + # ESLint 설정이 있는지 확인 (있을 때만 실행) + - id: detect_eslint_cfg + name: Detect ESLint config + run: | + if [ -f .eslintrc ] || [ -f .eslintrc.js ] || [ -f .eslintrc.cjs ] || [ -f .eslintrc.json ] || ( [ -f package.json ] && grep -q '"eslintConfig"' package.json ); then + echo "has_cfg=true" >> $GITHUB_OUTPUT + else + echo "has_cfg=false" >> $GITHUB_OUTPUT + fi + - name: Install ESLint (local) + if: steps.detect_js.outputs.has_js == 'true' && steps.detect_eslint_cfg.outputs.has_cfg == 'true' + run: npm i -D eslint + - name: Run ESLint + if: steps.detect_js.outputs.has_js == 'true' && steps.detect_eslint_cfg.outputs.has_cfg == 'true' + run: npx eslint . --max-warnings=0 || true # 초기엔 실패 막기 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index baa92ac..ecaa97a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,19 +3,19 @@ name: Release on: push: tags: - - "v*" - -permissions: - contents: write # ✅ 릴리즈/태그 쓰기 권한 + - "v*" # v로 시작하는 태그(v1.0.0 등) 푸시 시 실행 jobs: create-release: runs-on: ubuntu-latest + steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: generate_release_notes: true env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 8c46b95..9fef65d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,33 +1,16 @@ -# ======================== # Python -# ======================== __pycache__/ *.pyc .venv/ -.venv*/ -.pytest_cache/ -coverage/ -# ======================== -# Node.js / React -# ======================== +# Node.js node_modules/ .next/ out/ -apps/web/dist/ -apps/web/web-react/dist/ -# ======================== -# Docker / Logs -# ======================== +# Docker *.log .env -.env.* -docker-compose.override.yml -# ======================== -# IDE / Editor -# ======================== -.vscode/ -.idea/ -.DS_Store +# VSCode +.vscode/ \ No newline at end of file From 9ffc37ab5f37c8025f2bea7132afd15ecc098340 Mon Sep 17 00:00:00 2001 From: ChoiWonkeun Date: Wed, 12 Nov 2025 18:36:04 +0900 Subject: [PATCH 13/14] feat(services): update FastAPI apps & requirements; Gemini integration and endpoints --- apps/review-service/main.py | 2 +- apps/review-service/requirements.txt | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/review-service/main.py b/apps/review-service/main.py index 73d2c80..fd856cd 100644 --- a/apps/review-service/main.py +++ b/apps/review-service/main.py @@ -49,7 +49,7 @@ # ---------------------------------------------------- # API 엔드포인트 정의 # ---------------------------------------------------- -@app.post("/api/review") +@app.post("/api/review/") async def handle_code_review(code: str = Form(...)): # 👈 Review.jsx의 FormData("code")를 받음 if not model: raise HTTPException(status_code=503, detail="Gemini AI model is not configured.") diff --git a/apps/review-service/requirements.txt b/apps/review-service/requirements.txt index dbfe79e..8b8de09 100644 --- a/apps/review-service/requirements.txt +++ b/apps/review-service/requirements.txt @@ -1,4 +1,5 @@ fastapi uvicorn[standard] google-generativeai -python-dotenv \ No newline at end of file +python-dotenv +python-multipart \ No newline at end of file From ec142763dc2f2017004c1d4b7e65cf16ca6c3b06 Mon Sep 17 00:00:00 2001 From: ChoiWonkeun Date: Wed, 12 Nov 2025 18:36:51 +0900 Subject: [PATCH 14/14] feat(web): renew React frontend (AI Interview UI, routes, and Tailwind/Vite setup) --- apps/web/.dockerignore | 42 ++- apps/web/postcss.config.cjs | 5 +- apps/web/src/App.jsx | 37 +- apps/web/src/api/reviewService.js | 24 +- .../web/src/components/interview/AiAvatar.jsx | 13 + .../src/components/interview/MicRecorder.jsx | 52 +++ .../src/components/interview/ProgressDots.jsx | 15 + .../src/components/interview/QuestionBox.jsx | 15 + apps/web/src/components/interview/Timer60.jsx | 32 ++ apps/web/src/data/interviewQuestions.js | 33 ++ apps/web/src/hooks/useInterviewFlow.js | 59 ++++ apps/web/src/index.css | 196 ++++++++--- apps/web/src/main.jsx | 8 +- apps/web/src/pages/Coding.jsx | 317 +++++++++++------- apps/web/src/pages/Interview.jsx | 180 ---------- apps/web/src/pages/Review.jsx | 198 ++++++++--- apps/web/src/pages/interview/Intro.jsx | 20 ++ apps/web/src/pages/interview/Result.jsx | 18 + apps/web/src/pages/interview/Session.jsx | 90 +++++ apps/web/src/services/interviewApi.js | 43 +++ apps/web/src/store/interviewStore.js | 15 + apps/web/src/utils/audio.js | 14 + apps/web/src/utils/random.js | 16 + 23 files changed, 1023 insertions(+), 419 deletions(-) create mode 100644 apps/web/src/components/interview/AiAvatar.jsx create mode 100644 apps/web/src/components/interview/MicRecorder.jsx create mode 100644 apps/web/src/components/interview/ProgressDots.jsx create mode 100644 apps/web/src/components/interview/QuestionBox.jsx create mode 100644 apps/web/src/components/interview/Timer60.jsx create mode 100644 apps/web/src/data/interviewQuestions.js create mode 100644 apps/web/src/hooks/useInterviewFlow.js delete mode 100644 apps/web/src/pages/Interview.jsx create mode 100644 apps/web/src/pages/interview/Intro.jsx create mode 100644 apps/web/src/pages/interview/Result.jsx create mode 100644 apps/web/src/pages/interview/Session.jsx create mode 100644 apps/web/src/services/interviewApi.js create mode 100644 apps/web/src/store/interviewStore.js create mode 100644 apps/web/src/utils/audio.js create mode 100644 apps/web/src/utils/random.js diff --git a/apps/web/.dockerignore b/apps/web/.dockerignore index 2cfd0db..ca56b08 100644 --- a/apps/web/.dockerignore +++ b/apps/web/.dockerignore @@ -1,3 +1,40 @@ +네, default.conf 파일 잘 받았습니다. + +파일을 보니... expires -1; 설정이 있어서, Nginx가 JS/CSS 파일을 캐시하는 문제는 아니었습니다. + +하지만 드디어 진짜 원인을 찾은 것 같습니다. 님이 겪는 문제는 두 가지의 심각한 오류가 동시에 발생하고 있었기 때문입니다. + +화면이 안 바뀌는 문제: web 컨테이너의 .dockerignore 파일에 Vite 캐시(.vite)가 누락되어, 님이 수정한 Review.jsx가 아닌 옛날 파일로 계속 빌드되었습니다. + +"Failed to fetch" 문제: Nginx와 FastAPI의 API 주소 끝에 슬래시(/)가 일치하지 않아 API 요청이 404 오류로 실패하고 있었습니다. + +🛠️ 최종 해결 (1+2번 문제 동시 해결) +아래 4단계를 순서대로 진행하시면, 디자인과 API 오류가 모두 해결됩니다. + +1단계: review-service의 API 경로 수정 +FastAPI(main.py)가 Nginx(default.conf)와 동일하게 슬래시가 붙은 주소를 받도록 수정합니다. + +apps/review-service/main.py 파일을 열어서 @app.post 부분을 수정하세요. + +수정 전: + +Python + +@app.post("/api/review") +async def handle_code_review(code: str = Form(...)): +수정 후: (끝에 / 추가) + +Python + +@app.post("/api/review/") +async def handle_code_review(code: str = Form(...)): +2단계: web의 .dockerignore 파일 수정 +Vite 캐시 폴더(.vite)가 Docker 빌드 시 복사되지 않도록 .dockerignore 파일에 추가합니다. + +apps/web/.dockerignore 파일을 열어서 맨 아래에 .vite를 추가하세요. + +수정 후: + # 기본 무시 항목 node_modules dist @@ -14,4 +51,7 @@ README.md !tailwind.config.cjs !postcss.config.cjs !package.json -!package-lock.json \ No newline at end of file +!package-lock.json + +# ⭐️ Vite 캐시 무시 (추가) +.vite \ No newline at end of file diff --git a/apps/web/postcss.config.cjs b/apps/web/postcss.config.cjs index f82b7ba..106d2f8 100644 --- a/apps/web/postcss.config.cjs +++ b/apps/web/postcss.config.cjs @@ -1,4 +1,5 @@ -// apps/web/postcss.config.cjs module.exports = { - plugins: [require('@tailwindcss/postcss')], + plugins: { + '@tailwindcss/postcss': {}, + }, }; \ No newline at end of file diff --git a/apps/web/src/App.jsx b/apps/web/src/App.jsx index 196f99f..167032a 100644 --- a/apps/web/src/App.jsx +++ b/apps/web/src/App.jsx @@ -1,20 +1,33 @@ -// src/App.jsx -import { BrowserRouter, Routes, Route } from "react-router-dom"; +// apps/web/src/App.jsx +import { Routes, Route } from "react-router-dom"; + +// 기존 페이지 (그대로 유지) import Home from "./pages/Home"; import Coding from "./pages/Coding"; import Review from "./pages/Review"; -import Interview from "./pages/Interview"; + +// 새로 만든 인터뷰 분리 페이지들 +import Intro from "./pages/interview/Intro"; +import Session from "./pages/interview/Session"; +import Result from "./pages/interview/Result"; export default function App() { return ( - - - } /> - } /> - } /> - } /> - Not Found} /> - - + + {/* 기본 */} + } /> + + {/* 기존 기능 유지 */} + } /> + } /> + + {/* 인터뷰: 단일 /interview → 3개 라우트로 분리 */} + } /> + } /> + } /> + + {/* 404 */} + Not Found} /> + ); } diff --git a/apps/web/src/api/reviewService.js b/apps/web/src/api/reviewService.js index 5fd6815..17adab0 100644 --- a/apps/web/src/api/reviewService.js +++ b/apps/web/src/api/reviewService.js @@ -3,27 +3,23 @@ const BASE_URL = "/api"; /** - * AI 코드 리뷰를 요청합니다. - * (main.py의 /api/review 엔드포인트를 호출합니다) - * - * @param {string} code - 리뷰를 요청할 코드 - * @returns {Promise} - AI 리뷰 결과 (e.g., { review: "..." }) + * AI 코드 리뷰 요청 + * @param {string} code - 리뷰할 코드 문자열 + * @returns {Promise} - { review: "..."} 형태 */ export const fetchCodeReview = async (code) => { - // Review.jsx는 FormData를 사용합니다 const formData = new FormData(); formData.append("code", code); - const response = await fetch(`${BASE_URL}/review`, { - method: 'POST', + const res = await fetch(`${BASE_URL}/review/`, { + method: "POST", body: formData, }); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error || `AI 리뷰 요청에 실패했습니다.`); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || `AI 리뷰 요청 실패: ${res.statusText || res.status}`); } - // main.py가 반환하는 { review: "..." } 객체를 반환 - return await response.json(); -}; \ No newline at end of file + return await res.json(); +}; diff --git a/apps/web/src/components/interview/AiAvatar.jsx b/apps/web/src/components/interview/AiAvatar.jsx new file mode 100644 index 0000000..004383f --- /dev/null +++ b/apps/web/src/components/interview/AiAvatar.jsx @@ -0,0 +1,13 @@ +export default function AiAvatar({ text, title = "AI Interviewer" }) { + return ( +
+
+
+
{title}
+
+ {text} +
+
+
+ ); +} diff --git a/apps/web/src/components/interview/MicRecorder.jsx b/apps/web/src/components/interview/MicRecorder.jsx new file mode 100644 index 0000000..8b12bee --- /dev/null +++ b/apps/web/src/components/interview/MicRecorder.jsx @@ -0,0 +1,52 @@ +import { useEffect, useRef, useState } from "react"; + +/** + * MediaRecorder 래퍼 + * props: + * - running: boolean (true면 자동 녹음 시작, false면 정지) + * - onStop: ({blob, durationSec}) => void (정지 시 콜백) + */ +export default function MicRecorder({ running, onStop }) { + const [recording, setRecording] = useState(false); + const mrRef = useRef(null); + const chunksRef = useRef([]); + const startAtRef = useRef(0); + + useEffect(() => { + if (running && !recording) start(); + if (!running && recording) stop(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [running]); + + async function start() { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const mr = new MediaRecorder(stream, { mimeType: "audio/webm" }); + chunksRef.current = []; + mr.ondataavailable = (e) => e.data.size && chunksRef.current.push(e.data); + mr.onstop = () => { + const blob = new Blob(chunksRef.current, { type: "audio/webm" }); + const durationSec = Math.round((performance.now() - startAtRef.current) / 1000); + onStop?.({ blob, durationSec }); + stream.getTracks().forEach((t) => t.stop()); + }; + mrRef.current = mr; + startAtRef.current = performance.now(); + mr.start(); + setRecording(true); + } + + function stop() { + if (mrRef.current?.state === "recording") mrRef.current.stop(); + setRecording(false); + } + + return ( +
+ + {recording ? "Recording..." : "Idle"} +
+ ); +} diff --git a/apps/web/src/components/interview/ProgressDots.jsx b/apps/web/src/components/interview/ProgressDots.jsx new file mode 100644 index 0000000..472f044 --- /dev/null +++ b/apps/web/src/components/interview/ProgressDots.jsx @@ -0,0 +1,15 @@ +export default function ProgressDots({ total = 5, current = 0 }) { + return ( +
+ {Array.from({ length: total }).map((_, i) => ( +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/interview/QuestionBox.jsx b/apps/web/src/components/interview/QuestionBox.jsx new file mode 100644 index 0000000..f7fd7ab --- /dev/null +++ b/apps/web/src/components/interview/QuestionBox.jsx @@ -0,0 +1,15 @@ +export default function QuestionBox({ type = "tech", text }) { + const isTech = type === "tech"; + const badge = isTech ? "기술" : "인성"; + return ( +
+
+ {badge} +
+
{text}
+
+ ); +} diff --git a/apps/web/src/components/interview/Timer60.jsx b/apps/web/src/components/interview/Timer60.jsx new file mode 100644 index 0000000..0a2bc34 --- /dev/null +++ b/apps/web/src/components/interview/Timer60.jsx @@ -0,0 +1,32 @@ +import { useEffect, useState } from "react"; + +/** + * 60초 카운트다운 타이머 + * props: + * - running: boolean (true면 시작/재시작) + * - onTimeout: () => void (0초 도달 시 콜백) + */ +export default function Timer60({ running, onTimeout }) { + const [sec, setSec] = useState(60); + + useEffect(() => { + if (!running) return; + setSec(60); // 시작할 때 항상 리셋 + }, [running]); + + useEffect(() => { + if (!running) return; + if (sec === 0) { + onTimeout?.(); + return; + } + const id = setTimeout(() => setSec((s) => s - 1), 1000); + return () => clearTimeout(id); + }, [sec, running, onTimeout]); + + return ( +
+ {sec}s +
+ ); +} diff --git a/apps/web/src/data/interviewQuestions.js b/apps/web/src/data/interviewQuestions.js new file mode 100644 index 0000000..66c8348 --- /dev/null +++ b/apps/web/src/data/interviewQuestions.js @@ -0,0 +1,33 @@ +// src/data/interviewQuestions.js + +// 🧠 기술 질문 (기술 역량) +export const TECH = [ + "비동기 처리(Promise / async-await)의 차이와 에러 핸들링 전략을 설명해 주세요.", + "상태관리(Context, Redux, Zustand 등)를 선택할 때 기준은 무엇인가요?", + "HTTP / REST와 WebSocket의 차이를 설명하고 각각의 사용 사례를 말해 주세요.", + "데이터베이스 정규화와 비정규화의 차이를 설명해 주세요.", + "캐시 전략(브라우저, 서버, CDN)과 무효화 설계는 어떻게 하시나요?", + "테스트 전략(Unit, Integration, E2E)을 어떻게 구성하나요?", + "성능 최적화에서 가장 임팩트 있었던 개선 사례를 말해 주세요.", + "CI/CD 환경에서 품질 보장을 위해 어떤 자동화를 적용해보셨나요?", +]; + +// 💬 인성 질문 (소프트 스킬) +export const BEH = [ + "최근에 가장 도전적이었던 경험은 무엇이며, 어떻게 해결했나요?", + "팀 내 갈등이 발생했을 때 어떤 방식으로 조율했나요?", + "실패했던 경험이 있다면, 그 경험에서 무엇을 배웠나요?", + "시간 압박 속에서도 높은 품질을 유지하기 위해 어떤 노력을 했나요?", + "새로운 기술을 학습할 때 본인만의 루틴이나 방법이 있나요?", + "팀 프로젝트에서 맡았던 역할과, 본인의 강점을 어떻게 발휘했는지 설명해 주세요.", + "압박 상황에서 침착함을 유지하는 본인만의 방법이 있나요?", + "본인의 커리어 목표와 그 이유는 무엇인가요?", +]; + +// ⚙️ 도우미 함수: 기술 3개 + 인성 2개 랜덤 섞기 +export function getRandomQuestions() { + const shuffle = (arr) => [...arr].sort(() => 0.5 - Math.random()); + const tech = shuffle(TECH).slice(0, 3).map((text) => ({ type: "tech", text })); + const beh = shuffle(BEH).slice(0, 2).map((text) => ({ type: "beh", text })); + return shuffle([...tech, ...beh]); +} diff --git a/apps/web/src/hooks/useInterviewFlow.js b/apps/web/src/hooks/useInterviewFlow.js new file mode 100644 index 0000000..1148d71 --- /dev/null +++ b/apps/web/src/hooks/useInterviewFlow.js @@ -0,0 +1,59 @@ +// src/hooks/useInterviewFlow.js +import { useNavigate } from "react-router-dom"; +import { useState } from "react"; +import { getRandomQuestions } from "@/data/interviewQuestions"; + +/** + * AI 면접 흐름 제어 훅 + * - 질문 랜덤 생성 + * - 현재 문항 인덱스 관리 + * - 답변 리스트 관리 + * - 마지막에 결과 페이지 이동 + */ +export default function useInterviewFlow() { + const navigate = useNavigate(); + + // 상태 관리 + const [questions, setQuestions] = useState([]); + const [currentIndex, setCurrentIndex] = useState(0); + const [answers, setAnswers] = useState([]); + + /** 면접 시작 (Intro.jsx에서 호출) */ + const startInterview = () => { + const qs = getRandomQuestions(); // 기술3 + 인성2 랜덤 + setQuestions(qs); + setCurrentIndex(0); + setAnswers([]); + navigate("/interview/session"); + }; + + /** 현재 문항에 대한 답변 저장 후 다음 문항으로 이동 */ + const submitAnswer = (answerObj) => { + setAnswers((prev) => [...prev, answerObj]); + const next = currentIndex + 1; + + if (next < questions.length) { + setCurrentIndex(next); + } else { + // 모든 문항 완료 → 결과 페이지로 이동 + navigate("/interview/result", { state: { questions, answers: [...answers, answerObj] } }); + } + }; + + /** 면접 리셋 */ + const resetInterview = () => { + setQuestions([]); + setAnswers([]); + setCurrentIndex(0); + }; + + return { + questions, + currentIndex, + currentQuestion: questions[currentIndex], + answers, + startInterview, + submitAnswer, + resetInterview, + }; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 2799430..17c3174 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,60 +1,176 @@ -/* ✅ Tailwind v4 방식 */ @import "tailwindcss"; @plugin "@tailwindcss/typography"; -/* 기본 레이아웃 설정 */ +/* ===== Base ===== */ @layer base { - html, body, #root { - height: 100%; - width: 100%; - margin: 0; - padding: 0; - } - + html, body, #root { height:100%; width:100%; margin:0; padding:0; } body { - color: #f8fafc; - font-family: 'Plus Jakarta Sans', 'Inter Tight', ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; + color:#e9edf6; background:#0A0D14; + font-family:'Plus Jakarta Sans','Inter Tight',ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif; + letter-spacing:.2px; } + a { text-decoration:none; color:inherit; } +} - a { - text-decoration: none; - color: inherit; - } +/* ===== Scrollbar ===== */ +.custom-scrollbar::-webkit-scrollbar{ width:8px } +.custom-scrollbar::-webkit-scrollbar-track{ background:#142033; border-radius:4px } +.custom-scrollbar::-webkit-scrollbar-thumb{ background:#32486d; border-radius:4px } +.custom-scrollbar::-webkit-scrollbar-thumb:hover{ background:#48659a } + +/* ===== Keyframes / Theme ===== */ +@theme { + @keyframes gradient-x { 0%{background-position:0% 50%} 50%{background-position:100% 50%} 100%{background-position:0% 50%} } + @keyframes float { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-6px)} } + @keyframes shimmer { 0%{background-position:-200% 0} 100%{background-position:200% 0} } } -/* 스크롤바 커스터마이징 */ -.custom-scrollbar::-webkit-scrollbar { - width: 8px; +/* ===== Background (Aurora) ===== */ +.bg-app{ + position: relative; + z-index: 0; /* 스태킹 컨텍스트 생성 */ + min-height: 100vh; + overflow: hidden; + background: + radial-gradient(1200px 600px at 50% -130px, rgba(102,167,255,.22), transparent 60%), + radial-gradient(1000px 420px at 50% 115%, rgba(4,10,22,.92), transparent 60%), + #0A0D14; } -.custom-scrollbar::-webkit-scrollbar-track { - background: #1e293b; - border-radius: 4px; +/* 흐르는 오로라 레이어 */ +.bg-app::before{ + content:""; + position:absolute; inset:-10%; + background: + radial-gradient(800px 500px at 30% 20%, rgba(100,160,255,.25), transparent 70%), + radial-gradient(900px 600px at 70% 80%, rgba(120,90,255,.22), transparent 70%); + animation: aurora 20s linear infinite alternate; + filter: blur(90px); + z-index: 0; } -.custom-scrollbar::-webkit-scrollbar-thumb { - background: #475569; - border-radius: 4px; +.bg-app::after{ + content:""; position:absolute; inset:-20%; + background: conic-gradient(from 120deg, rgba(50,120,255,.14), rgba(110,70,255,.12), rgba(70,210,255,.10), transparent 55%); + filter: blur(70px); animation: gradient-x 18s ease-in-out infinite; + pointer-events:none; z-index: 0; } -.custom-scrollbar::-webkit-scrollbar-thumb:hover { - background: #5a6a85; +/* 자식은 항상 배경 위로 */ +.bg-app > * { position: relative; z-index: 1; } + +@keyframes aurora { + 0% { transform: translateY(-8%) scale(1.08); } + 100% { transform: translateY(8%) scale(1.03); } } -/* 애니메이션 */ -@theme { - @keyframes fadeIn { - from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: translateY(0); } - } +/* ===== Gradient-border Card ===== */ +.gcard{ + position:relative; border-radius:18px; padding:1px; + background:linear-gradient(180deg, rgba(120,180,255,.55), rgba(106,76,255,.45), rgba(40,160,255,.35)); +} +.ginner{ + border-radius:17px; background:rgba(12,18,32,.82); backdrop-filter:blur(12px); + box-shadow:0 14px 42px rgba(0,0,0,.48), inset 0 1px 0 rgba(255,255,255,.04); +} +.gheader{ + border-bottom:1px solid rgba(140,170,255,.18); + font-size: 22px; font-weight: 700; padding: 20px; + letter-spacing:.3px; color:#eaf1ff; +} - @keyframes fadeOut { - from { opacity: 1; transform: translateY(0); } - to { opacity: 0; transform: translateY(10px); } - } +/* ===== Title (Deep Glow toned-down) ===== */ +.title-glow{ + position: relative; + color:#d9e2ff; + font-weight:800; letter-spacing:.02em; + text-shadow: + 0 0 10px rgba(140,180,255,.45), + 0 0 24px rgba(110,100,255,.36); +} +.title-glow::after{ + content: attr(data-text); + position:absolute; left:0; top:0; width:100%; + color: transparent; + background: radial-gradient(circle at 50% 50%, rgba(160,180,255,.35), transparent 60%); + filter: blur(20px); opacity:.35; z-index:-1; } -.animate-fadeIn { - animation: fadeIn 0.5s ease-out forwards; +/* ===== Glass sheen (inner highlight) ===== */ +.glass-sheen{ position: relative; } +.glass-sheen::before{ + content:""; position:absolute; inset:0; border-radius:17px; + background: + radial-gradient(120% 60% at 50% -10%, rgba(255,255,255,.07), transparent 42%), + radial-gradient(80% 50% at 50% 120%, rgba(0,0,0,.28), transparent 52%); + pointer-events:none; } -.animate-fadeInOut { - animation: fadeIn 0.3s ease-out, fadeOut 0.3s ease-in 2.7s forwards; +/* ===== Neon Button (interaction) ===== */ +.btn-neon{ + color:#fff; font-weight:700; + padding:14px 22px; border-radius:0.9rem; + transition:all .28s cubic-bezier(.2,.8,.2,1); + outline:none; border:none; + background-image: linear-gradient(90deg,#2649ff,#4661ff,#1f7bff); + background-size:200% auto; + box-shadow: + 0 0 0 1px rgba(80,120,255,.25), + 0 10px 28px rgba(0,40,255,.35); +} +.btn-neon:hover{ + transform: translateY(-2px) scale(1.02); + background-position: right center; + box-shadow: + 0 0 10px rgba(120,160,255,.55), + 0 0 40px rgba(80,120,255,.25); +} +.btn-neon:active{ transform: translateY(0) scale(.98); filter:brightness(.92); } +.btn-neon:disabled{ opacity:.55; cursor:not-allowed; box-shadow:none; } + +/* ===== Inputs focus (soft glow) ===== */ +textarea{ + transition: box-shadow .28s ease, border-color .28s ease; + border: 1px solid rgba(80,110,160,.35); + border-radius: 12px; +} +textarea:focus{ + outline:none; + box-shadow: 0 0 15px rgba(90,150,255,.25); + border-color: rgba(110,150,255,.55); +} + +/* ===== Prose tweaks ===== */ +.prose-elite :where(code):not(:where(pre code)){ + padding: 2px 6px; border-radius: 6px; + background-color: rgba(30,41,59,.7); color:#7dd3fc; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: .95em; +} +.prose-elite pre{ + background-color: rgba(15,23,42,.7); + border:1px solid rgba(51,65,85,.4); + border-radius: .6rem; padding: 1rem; +} +.prose-elite strong{ color:#93c5fd; } +.prose-elite a{ + color:#7dd3fc; text-decoration: underline; text-underline-offset: 4px; +} + +/* ===== Skeleton shimmer ===== */ +.skeleton{ + background:linear-gradient(90deg,rgba(255,255,255,.06) 25%,rgba(255,255,255,.18) 37%,rgba(255,255,255,.06) 63%); + background-size:400% 100%; animation:shimmer 1.4s ease-in-out infinite; +} + +/* ===== Layout helpers ===== */ +:root { --app-header: 160px; } +.vh-fit { height: calc(100vh - var(--app-header)); } +@media (max-width: 768px) { .vh-fit { height: auto; } } + +/* ===== Clamp & Fade (옵션) ===== */ +.line-clamp-10{ + display:-webkit-box; -webkit-line-clamp:10; -webkit-box-orient:vertical; overflow:hidden; +} +.fade-bottom{ + position:absolute; left:0; right:0; bottom:0; height:44px; + border-bottom-left-radius:.5rem; border-bottom-right-radius:.5rem; + background: linear-gradient(to bottom, rgba(11,15,25,0), rgba(11,15,25,0.95)); } diff --git a/apps/web/src/main.jsx b/apps/web/src/main.jsx index f18b0f0..0f7b7c7 100644 --- a/apps/web/src/main.jsx +++ b/apps/web/src/main.jsx @@ -1,10 +1,14 @@ import React from "react"; import ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; // 1. 추가된 부분 import App from "./App"; import "./index.css"; ReactDOM.createRoot(document.getElementById("root")).render( - + {/* 2. 로 감싼 부분 */} + + + -); +); \ No newline at end of file diff --git a/apps/web/src/pages/Coding.jsx b/apps/web/src/pages/Coding.jsx index 87d3677..5fdf7ae 100644 --- a/apps/web/src/pages/Coding.jsx +++ b/apps/web/src/pages/Coding.jsx @@ -1,38 +1,40 @@ -// src/pages/Coding.jsx -import React, { useState, useCallback } from 'react'; -import { Link } from 'react-router-dom'; +import React, { useState, useCallback } from "react"; +import { Link } from "react-router-dom"; import { - IconArrowLeft, IconCheck, IconX, IconCopy, IconSparkles, - IconPlayerPlay, IconSend, IconCodeCircle, IconTerminal2 + IconArrowLeft, IconCheck, IconX, IconSparkles, + IconPlayerPlay, IconSend, IconCodeCircle, IconTerminal2, IconCopy, } from "@tabler/icons-react"; -import { fetchAiCodingProblem, runCode, submitCode } from '../api/codingService'; +import { fetchAiCodingProblem, runCode, submitCode } from "../api/codingService"; -/* -------- Toast -------- */ +/* Toast */ const Toast = ({ message, type }) => { if (!message) return null; - const bg = type === 'success' ? 'bg-green-500' : 'bg-red-500'; - const icon = type === 'success' ? : ; + const bg = type === "success" ? "bg-green-500" : "bg-red-500"; + const icon = type === "success" ? : ; return (
- {icon}{message} + {icon} + {message}
); }; -/* -------- Code Editor -------- */ +/* Code Editor */ const CodeEditor = ({ code, setCode, language, setLanguage, - onRun, onSubmit, isExecuting, isSubmitting, problemLoaded + onRun, onSubmit, isExecuting, isSubmitting, problemLoaded, }) => { - const placeholderText = problemLoaded ? "AI가 생성한 문제를 풀어보세요." : "‘AI 문제 생성’ 버튼을 눌러주세요."; + const placeholderText = problemLoaded + ? "AI가 생성한 문제를 풀어보세요." + : "‘AI 문제 생성’ 버튼을 눌러주세요."; + return ( -
- {/* toolbar */} +