From 8b2ee6093fcd432e9ef348f16deaac7fd77d91a1 Mon Sep 17 00:00:00 2001 From: ChoiWonkeun Date: Fri, 28 Nov 2025 20:43:27 +0900 Subject: [PATCH] Frontend updates: coding test UI fixes, toggle view, review tab, etc --- src/api/reviewService.js | 76 ++++--- src/features/codingTest/CodingTest.jsx | 69 ++++-- src/features/review/CodeReview.jsx | 283 +++++++++++++++++-------- 3 files changed, 295 insertions(+), 133 deletions(-) diff --git a/src/api/reviewService.js b/src/api/reviewService.js index ada5e6c..ea2b8ed 100644 --- a/src/api/reviewService.js +++ b/src/api/reviewService.js @@ -1,31 +1,55 @@ -const BASE_URL = "/api"; +// src/api/reviewService.js -/** - * AI 코드 리뷰 요청 - * @param {string} code - * @param {string} [comment] - * @param {string} [repoUrl] - */ -export const fetchCodeReview = async (code, comment, repoUrl) => { - const formData = new FormData(); - formData.append("code", code); +// 중요: 백엔드 주소를 정확하게 입력 (Proxy 설정이 없다면 전체 주소 필수) +const BASE_URL = "http://localhost:8080/api"; - if (comment) formData.append("comment", comment); - if (repoUrl) formData.append("repo_url", repoUrl); +export const fetchCodeReview = async (code, comment) => { + // 1. 데이터 객체 생성 + const payload = { + code: code, + comment: comment && comment.trim() ? comment.trim() : null, + }; - const res = await fetch(`${BASE_URL}/review/`, { - method: "POST", - body: formData, - }); + try { + // 2. fetch 요청 (JSON 모드) + const res = await fetch(`${BASE_URL}/review`, { + method: "POST", + headers: { + "Content-Type": "application/json", // 나 JSON 보낸다고 알려줌 + }, + body: JSON.stringify(payload), // 객체를 문자열로 변환 + }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error( - err.detail || - err.error || - `AI 리뷰 요청 실패: ${res.statusText || res.status}`, - ); - } + // 3. 에러 처리 + const raw = await res.text(); + + if (!res.ok) { + // 서버가 에러 응답을 준 경우 + let errMsg = raw; + try { + const json = JSON.parse(raw); + errMsg = json.message || json.error || json.detail || raw; + } catch { + // JSON 파싱 실패 시 raw text 사용 + } + throw new Error(errMsg || `요청 실패 (${res.status})`); + } + + if (!raw) return {}; + + // 정상 응답 파싱 + try { + return JSON.parse(raw); + } catch { + return { review: raw, questions: [] }; + } - return await res.json(); -}; + } catch (error) { + console.error("API 요청 실패:", error); + // "Failed to fetch"는 보통 서버가 꺼져있거나 주소가 틀렸을 때 발생 + if (error.message === "Failed to fetch") { + throw new Error("서버에 연결할 수 없습니다. 백엔드 서버가 켜져 있는지 확인해주세요."); + } + throw error; + } +}; \ No newline at end of file diff --git a/src/features/codingTest/CodingTest.jsx b/src/features/codingTest/CodingTest.jsx index 01740ef..7aafd00 100644 --- a/src/features/codingTest/CodingTest.jsx +++ b/src/features/codingTest/CodingTest.jsx @@ -1,3 +1,4 @@ +// src/features/coding/CodingTest.jsx import { useState } from "react"; import { Link } from "react-router-dom"; import { @@ -123,16 +124,18 @@ export default function CodingTest() { const [result, setResult] = useState(null); const [errorMsg, setErrorMsg] = useState(""); - // AI 피드백을 보여줄지 결과 요약을 보여줄지 토글하는 상태 + // AI 피드백(리뷰/면접질문) 구역을 열지 말지 토글 const [showFeedback, setShowFeedback] = useState(false); + // 코드 리뷰 vs 예상 면접 질문 토글 상태 + const [showInterview, setShowInterview] = useState(false); + // 언어 변경 const handleChangeLanguage = (nextLang) => { if (code !== LANGUAGE_TEMPLATES[language] && code.trim() !== "") { - // window.confirm 대신 커스텀 모달이 권장되지만, 빠른 해결을 위해 일단 유지 - if (!window.confirm("언어를 변경하면 작성 중인 코드가 초기화됩니다. 계속하시겠습니까?")) { - return; - } + if (!window.confirm("언어를 변경하면 작성 중인 코드가 초기화됩니다. 계속하시겠습니까?")) { + return; + } } setLanguage(nextLang); setCode(LANGUAGE_TEMPLATES[nextLang] || ""); @@ -144,6 +147,7 @@ export default function CodingTest() { setErrorMsg(""); setResult(null); setShowFeedback(false); + setShowInterview(false); try { const data = await fetchRandomProblem(difficulty); setProblem(data); @@ -171,6 +175,7 @@ export default function CodingTest() { setErrorMsg(""); setResult(null); setShowFeedback(false); + setShowInterview(false); try { const res = await submitCode({ @@ -180,8 +185,9 @@ export default function CodingTest() { userId: 1, // Long 타입이므로 숫자 1 사용 }); setResult(res); - // AI 피드백이 있다면, 기본적으로 피드백 화면을 보여주도록 설정 - if (res.aiFeedback) { + + // aiFeedback 또는 interviewQuestions가 있으면 피드백 영역 기본 ON + if (res.aiFeedback || (res.interviewQuestions && res.interviewQuestions.length > 0)) { setShowFeedback(true); } else { setShowFeedback(false); @@ -453,7 +459,7 @@ export default function CodingTest() { {/* 상단 요약/피드백 토글 */}
- {/* 결과 상태 표시 */} + {/* 결과 상태 표시 */}
{/* AI 피드백 토글 버튼 */} - {result.aiFeedback && ( + {(result.aiFeedback || (result.interviewQuestions && result.interviewQuestions.length > 0)) && (
{/* 결과 내용 */} - {showFeedback && result.aiFeedback ? ( - // AI 피드백 섹션 + {showFeedback && (result.aiFeedback || (result.interviewQuestions && result.interviewQuestions.length > 0)) ? ( + // 👉 여기서 코드 리뷰 ↔ 예상 면접 질문 토글
-

+
+

- AI 코드 리뷰 -

-
- {result.aiFeedback} + {showInterview ? "예상 면접 질문" : "AI 코드 리뷰"} +

+ + {result.interviewQuestions && result.interviewQuestions.length > 0 && ( + + )}
+ + {showInterview && result.interviewQuestions && result.interviewQuestions.length > 0 ? ( + // 🔥 여기서 1. 2. 3. 형식으로 질문 출력 +
    + {result.interviewQuestions.map((q, idx) => ( +
  1. + {q} +
  2. + ))} +
+ ) : ( +
+ {result.aiFeedback || "AI 코드 리뷰가 제공되지 않았습니다."} +
+ )}
) : ( // 결과 요약 섹션 @@ -530,4 +565,4 @@ export default function CodingTest() {
); -} \ No newline at end of file +} diff --git a/src/features/review/CodeReview.jsx b/src/features/review/CodeReview.jsx index 1f4998e..6fcdcc2 100644 --- a/src/features/review/CodeReview.jsx +++ b/src/features/review/CodeReview.jsx @@ -13,11 +13,7 @@ import { fetchCodeReview } from "../../api/reviewService"; const particlesOptions = { background: { color: { value: "transparent" } }, fpsLimit: 60, - interactivity: { - events: { - resize: true, - }, - }, + interactivity: { events: { resize: true } }, particles: { color: { value: "#8eb5ff" }, links: { enable: true, opacity: 0.22, width: 1 }, @@ -28,33 +24,75 @@ const particlesOptions = { }, }; +// 리뷰 텍스트 포맷팅: "- " → "□ " + 항목 사이 한 줄 띄우기 +function formatReviewText(review) { + if (!review) return ""; + return review + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => line.replace(/^- /, "□ ")) + .join("\n\n"); +} + export default function Review() { + // 모드: 코드 / 레포 + const [mode, setMode] = useState("code"); // "code" | "repo" + const [code, setCode] = useState(""); - const [userComment, setUserComment] = useState(""); const [repoUrl, setRepoUrl] = useState(""); + const [userComment, setUserComment] = useState(""); + const [review, setReview] = useState(""); + const [questions, setQuestions] = useState([]); + const [showQuestions, setShowQuestions] = useState(false); + const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [copied, setCopied] = useState(false); const handleSubmit = async (e) => { e.preventDefault(); + setError(null); + + // 아직 레포 모드는 백엔드 구현 전이라 안내만 띄우고 return + if (mode === "repo") { + if (!repoUrl.trim()) return; + setError( + "Repository URL 기반 리뷰는 아직 백엔드 구현 전입니다. 우선 코드를 직접 붙여넣어서 사용해줘. 이 UI는 나중에 레포 분석 백엔드 만들 때 그대로 연결하면 된다!" + ); + return; + } + if (!code.trim()) return; setIsLoading(true); - setError(null); setReview(""); + setQuestions([]); + setShowQuestions(false); try { - // reviewService.js에서 fetchCodeReview를 (code, comment, repoUrl) 받도록 맞춰주면 됨 - const data = await fetchCodeReview(code, userComment, repoUrl); - if (data?.review) { - setReview(data.review); - } else { - throw new Error(data?.error || "Unknown error"); + const data = await fetchCodeReview(code, userComment); + console.log("[CodeReview] parsed data:", data); + + // review 텍스트 후보 몇 가지에서 찾아보기 + const reviewText = + typeof data?.review === "string" + ? data.review + : typeof data?.content === "string" + ? data.content + : typeof data === "string" + ? data + : ""; + + if (!reviewText) { + throw new Error("AI 응답이 비어 있습니다."); } + + setReview(reviewText); + setQuestions(Array.isArray(data.questions) ? data.questions : []); } catch (err) { - setError(err.message || "Failed to fetch review"); + setError(err.message); } finally { setIsLoading(false); } @@ -62,25 +100,27 @@ export default function Review() { const copyReview = async () => { try { - await navigator.clipboard.writeText(review || ""); + const textToCopy = showQuestions + ? questions.map((q, i) => `${i + 1}. ${q}`).join("\n\n") + : review || ""; + + await navigator.clipboard.writeText(textToCopy); setCopied(true); setTimeout(() => setCopied(false), 1200); } catch { - /* noop */ + // ignore } }; return (
- {/* 배경 입자 */} - {/* 콘텐츠 래퍼 */}
- {/* 헤더 */} + {/* HEADER */}
- {/* 메인 그리드: 높이 고정 + 내부 스크롤 */} -
- {/* LEFT: Code input */} -
-
-
+ {/* GRID */} +
+ {/* LEFT: 입력 카드 */} +
+
+
+ {/* 헤더: 기존 디자인 + 작게 모드 토글만 추가 */}
Paste Your Code + +
+ + +
- {/* 코드 입력 영역 */} + {/* 본문: 코드 / 레포 입력만 조건부로 바꿈, 카드 레이아웃은 그대로 */}
-