Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
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: |
if [ -f requirements.txt ]; then
pip install -r requirements.txt || true
fi
pip install pytest || true

- name: Test
run: |
if [ -f pytest.ini ] || [ -d tests ]; then
pytest -q || true
else
echo "No python tests. Skipping."
fi

frontend-node:
runs-on: ubuntu-latest
defaults:
run:
working-directory: .
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: "20"

- name: Install
run: |
if [ -f package-lock.json ]; then
npm ci
elif [ -f package.json ]; then
npm install
else
echo "No package.json. Skipping install."
fi

- name: Build
run: |
if [ -f package.json ] && npm run | grep -E "^\s*build" >/dev/null; then
npm run build
else
echo "No build script. Skipping."
fi
65 changes: 65 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -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 # 초기엔 실패 막기
21 changes: 21 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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 }}
2 changes: 1 addition & 1 deletion apps/review-service/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down
3 changes: 2 additions & 1 deletion apps/review-service/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
fastapi
uvicorn[standard]
google-generativeai
python-dotenv
python-dotenv
python-multipart
42 changes: 41 additions & 1 deletion apps/web/.dockerignore
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -14,4 +51,7 @@ README.md
!tailwind.config.cjs
!postcss.config.cjs
!package.json
!package-lock.json
!package-lock.json

# ⭐️ Vite 캐시 무시 (추가)
.vite
5 changes: 3 additions & 2 deletions apps/web/postcss.config.cjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// apps/web/postcss.config.cjs
module.exports = {
plugins: [require('@tailwindcss/postcss')],
plugins: {
'@tailwindcss/postcss': {},
},
};
37 changes: 25 additions & 12 deletions apps/web/src/App.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/coding" element={<Coding />} />
<Route path="/review" element={<Review />} />
<Route path="/interview" element={<Interview />} />
<Route path="*" element={<div style={{ padding: 24 }}>Not Found</div>} />
</Routes>
</BrowserRouter>
<Routes>
{/* 기본 */}
<Route path="/" element={<Home />} />

{/* 기존 기능 유지 */}
<Route path="/coding" element={<Coding />} />
<Route path="/review" element={<Review />} />

{/* 인터뷰: 단일 /interview → 3개 라우트로 분리 */}
<Route path="/interview" element={<Intro />} />
<Route path="/interview/session" element={<Session />} />
<Route path="/interview/result" element={<Result />} />

{/* 404 */}
<Route path="*" element={<div style={{ padding: 24 }}>Not Found</div>} />
</Routes>
);
}
24 changes: 10 additions & 14 deletions apps/web/src/api/reviewService.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,23 @@
const BASE_URL = "/api";

/**
* AI 코드 리뷰를 요청합니다.
* (main.py의 /api/review 엔드포인트를 호출합니다)
*
* @param {string} code - 리뷰를 요청할 코드
* @returns {Promise<object>} - AI 리뷰 결과 (e.g., { review: "..." })
* AI 코드 리뷰 요청
* @param {string} code - 리뷰할 코드 문자열
* @returns {Promise<object>} - { 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();
};
return await res.json();
};
13 changes: 13 additions & 0 deletions apps/web/src/components/interview/AiAvatar.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export default function AiAvatar({ text, title = "AI Interviewer" }) {
return (
<div className="flex items-start gap-3">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-indigo-500 to-violet-600 shadow-md animate-pulse" />
<div className="flex-1">
<div className="text-xs text-slate-400">{title}</div>
<div className="mt-1 rounded-2xl px-4 py-3 bg-slate-800/70 border border-slate-700 text-slate-100 whitespace-pre-wrap">
{text}
</div>
</div>
</div>
);
}
52 changes: 52 additions & 0 deletions apps/web/src/components/interview/MicRecorder.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center gap-2">
<span
className={`inline-block w-3 h-3 rounded-full ${recording ? "bg-red-500 animate-pulse" : "bg-slate-500"}`}
title={recording ? "Recording" : "Idle"}
/>
<span className="text-xs text-slate-300">{recording ? "Recording..." : "Idle"}</span>
</div>
);
}
15 changes: 15 additions & 0 deletions apps/web/src/components/interview/ProgressDots.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export default function ProgressDots({ total = 5, current = 0 }) {
return (
<div className="flex gap-2">
{Array.from({ length: total }).map((_, i) => (
<div
key={i}
className={`w-2.5 h-2.5 rounded-full ${
i <= current ? "bg-indigo-400" : "bg-slate-600"
}`}
aria-label={`step ${i + 1}`}
/>
))}
</div>
);
}
Loading