+
{!problem && !loading && (
-
옵션을 선택하고 ‘AI 문제 생성’을 눌러주세요.
+
+ 옵션을 선택하고 ‘AI 문제 생성’을 눌러주세요.
+
+ )}
+ {loading && (
+
+ 새로운 문제를 생성 중입니다…
+
)}
- {loading &&
새로운 문제를 생성 중입니다…
}
{problem && (
@@ -220,21 +321,25 @@ export default function Coding() {
-
{problem.description}
+
+ {problem.description}
+
{problem.constraints?.length > 0 && (
<>
제약 조건
- {problem.constraints.map((c,i)=>- {c}
)}
+ {problem.constraints.map((c, i) => (
+ - {c}
+ ))}
>
)}
@@ -243,56 +348,32 @@ export default function Coding() {
- {/* 에디터 + 결과 콘솔 */}
-
-
-
- {/* 실행 결과 콘솔 */}
-
-
-
-
실행 결과
-
-
- {/* stdin 입력창 */}
-
-
-
-
- {/* 상태별 출력 */}
- {isExecuting && (
-
코드를 실행 중입니다…
- )}
- {!isExecuting && runError && (
-
- {runError}
-
- )}
- {!isExecuting && !runError && runResult && (
-
- {runResult.output || "(출력 없음)"}
-
- )}
- {!isExecuting && !runError && !runResult && (
-
아직 실행 결과가 없습니다. 오른쪽 상단의 코드 실행을 눌러보세요.
- )}
+ {/* RIGHT: Editor (2/3) + Run (1/3) */}
+
- {toast &&
}
+ {toast && }
);
}
diff --git a/apps/web/src/pages/Interview.jsx b/apps/web/src/pages/Interview.jsx
deleted file mode 100644
index 01cc5c3..0000000
--- a/apps/web/src/pages/Interview.jsx
+++ /dev/null
@@ -1,180 +0,0 @@
-// src/pages/Interview.jsx
-import React, { useState } from 'react';
-import { Link } from 'react-router-dom';
-import { IconArrowLeft, IconSend, IconUser, IconBrain } from "@tabler/icons-react";
-import { sendChatRequest } from '../api/interviewService';
-
-// 채팅 메시지를 표시하는 컴포넌트
-const ChatBubble = ({ role, text }) => {
- const isUser = role === 'user';
- return (
-
-
-
- {isUser ? : }
-
-
- {/* AI 응답 텍스트 포맷팅 (간단 버전) */}
-
{text}
-
-
-
- );
-};
-
-export default function Interview() {
- const [topic, setTopic] = useState("React"); // 면접 주제
- const [history, setHistory] = useState([]); // 대화 기록
- const [userMessage, setUserMessage] = useState(""); // 현재 입력
- const [isLoading, setIsLoading] = useState(false);
- const [error, setError] = useState(null);
-
- // 면접 시작 (첫 질문 받기)
- const handleStartInterview = async () => {
- setIsLoading(true);
- setError(null);
- setHistory([]); // 대화 기록 초기화
-
- try {
- // "start" 메시지를 보내 AI의 첫 질문을 유도
- const aiResponse = await sendChatRequest(topic, [], "start");
- setHistory([{ role: "model", text: aiResponse }]);
- } catch (err) {
- setError(err.message);
- } finally {
- setIsLoading(false);
- }
- };
-
- // 메시지 전송 (답변 제출 및 다음 질문 받기)
- const handleSubmitMessage = async (e) => {
- e.preventDefault();
- if (!userMessage || isLoading) return;
-
- setIsLoading(true);
- setError(null);
-
- // 1. 사용자 메시지를 히스토리에 추가
- const newHistory = [...history, { role: "user", text: userMessage }];
- setHistory(newHistory);
- setUserMessage("");
-
- try {
- // 2. AI에게 (주제, 전체 히스토리, 현재 메시지) 전송
- const aiResponse = await sendChatRequest(topic, history, userMessage);
-
- // 3. AI 응답을 히스토리에 추가
- setHistory([...newHistory, { role: "model", text: aiResponse }]);
- } catch (err) {
- setError(err.message);
- } finally {
- setIsLoading(false);
- }
- };
-
- return (
-
- {/* --- 헤더 --- */}
-
-
-
- Home
-
-
- AI Interview
-
-
-
-
- {/* --- 메인 채팅 UI --- */}
-
-
- {/* 면접 시작/설정 영역 */}
-
-
-
-
-
-
- {/* 채팅 메시지 영역 */}
-
- {history.length === 0 && !isLoading && (
-
면접 주제를 선택하고 '면접 시작' 버튼을 눌러주세요.
- )}
- {isLoading && history.length === 0 && (
-
AI 면접관이 첫 질문을 생성 중입니다...
- )}
-
- {history.map((msg, index) => (
-
- ))}
-
- {isLoading && history.length > 0 && (
-
- )}
-
- {error && (
-
- )}
-
-
- {/* 메시지 입력 폼 */}
-
-
-
-
- );
-}
\ No newline at end of file
diff --git a/apps/web/src/pages/Review.jsx b/apps/web/src/pages/Review.jsx
index ffb51fa..7d79a97 100644
--- a/apps/web/src/pages/Review.jsx
+++ b/apps/web/src/pages/Review.jsx
@@ -1,80 +1,178 @@
-// src/pages/Review.jsx
import { useState } from "react";
import { Link } from "react-router-dom";
-import { IconArrowLeft, IconSparkles } from "@tabler/icons-react";
+import {
+ IconArrowLeft, IconSparkles, IconLoader2, IconAlertTriangle, IconCopy
+} from "@tabler/icons-react";
import Particles from "@tsparticles/react";
-import { particlesOptions, particlesVersion } from "@/config/particles";
-import { useParticlesInit } from "../hooks/useParticlesInit";
-
-// 1. (신규) formatters.js에서 함수 임포트
-import { formatAiResponse } from "../utils/formatters.jsx";
-// 2. (신규) api/reviewService.js에서 함수 임포트
import { fetchCodeReview } from "../api/reviewService";
+const particlesOptions = {
+ background: { color: { value: "transparent" } },
+ fpsLimit: 60,
+ interactivity: { events: { resize: true } },
+ particles: {
+ color: { value: "#8eb5ff" },
+ links: { enable: true, opacity: 0.22, width: 1 },
+ move: { enable: true, speed: 0.45 },
+ number: { value: 42 },
+ opacity: { value: 0.25 },
+ size: { value: { min: 1, max: 3 } },
+ },
+};
+
export default function Review() {
const [code, setCode] = useState("");
const [review, setReview] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
-
- const init = useParticlesInit();
+ const [copied, setCopied] = useState(false);
- // 3. (수정) handleSubmit 함수가 fetchCodeReview를 사용하도록 변경
const handleSubmit = async (e) => {
e.preventDefault();
+ if (!code.trim()) return;
setIsLoading(true);
setError(null);
setReview("");
-
try {
- // API 모듈을 통해 요청
const data = await fetchCodeReview(code);
-
- if (data.review) {
- setReview(data.review);
- } else {
- throw new Error(data.error || "Unknown error occurred.");
- }
+ if (data?.review) setReview(data.review);
+ else throw new Error(data?.error || "Unknown error");
} catch (err) {
- setError(err.message);
+ setError(err.message || "Failed to fetch review");
} finally {
setIsLoading(false);
}
};
- // 4. (삭제) 기존 formatReviewText 함수를 여기서 삭제합니다.
-
- if (!init) {
- return null;
- }
+ const copyReview = async () => {
+ try {
+ await navigator.clipboard.writeText(review || "");
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1200);
+ } catch {}
+ };
return (
-
- {/* ... (Particles, header UI는 동일) ... */}
-
-
-
-
-
-
Review Feedback
-
- {/* ... (isLoading, error UI는 동일) ... */}
-
- {/* 5. (수정) formatAiResponse 훅을 직접 호출 */}
- {review && (
-
- {formatAiResponse(review)}
+
+ {/* 배경 입자: 배경 레이어(z-0)로 */}
+
+
+ {/* Header */}
+
+
+ Home
+
+
+
+ AI Code Review
+
+
+
+
+
+ {/* Main */}
+
+ {/* LEFT: Code input */}
+
+
+
+
+ Paste Your Code
+
+
+
+
+
+
+
+
+ {/* RIGHT: Feedback */}
+
+
+
+
+ Review Feedback
+
+
+
+
+ {isLoading && !review && (
+
+
+
AI가 코드를 분석 중입니다...
+
잠시만 기다려주세요.
+
+ )}
+
+ {error && (
+
+ )}
+
+ {review && (
+
+ {review}
+
+ )}
+
+ {!isLoading && !error && !review && (
+
+ 코드를 제출하면 AI 리뷰가 여기에 표시됩니다.
+
+ )}
- )}
-
- {!isLoading && !error && !review && (
-
코드를 제출하면 AI 리뷰가 여기에 표시됩니다.
- )}
+
-
-
+
+
);
-}
\ No newline at end of file
+}
diff --git a/apps/web/src/pages/interview/Intro.jsx b/apps/web/src/pages/interview/Intro.jsx
new file mode 100644
index 0000000..db9d1aa
--- /dev/null
+++ b/apps/web/src/pages/interview/Intro.jsx
@@ -0,0 +1,20 @@
+export default function Intro() {
+ return (
+
+
+
+
AI Interview
+
+
+ 5개의 질문(기술+인성 랜덤), 문항당 60초입니다. 준비되면 시작하세요.
+
+
+ 면접 시작
+
+
홈으로
+
+
+
+
+ );
+}
diff --git a/apps/web/src/pages/interview/Result.jsx b/apps/web/src/pages/interview/Result.jsx
new file mode 100644
index 0000000..63ab02c
--- /dev/null
+++ b/apps/web/src/pages/interview/Result.jsx
@@ -0,0 +1,18 @@
+export default function Result() {
+ return (
+
+
+
+
수고하셨습니다!
+
+
AI가 곧 요약과 점수를 보여줄 거예요.
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/pages/interview/Session.jsx b/apps/web/src/pages/interview/Session.jsx
new file mode 100644
index 0000000..9c44195
--- /dev/null
+++ b/apps/web/src/pages/interview/Session.jsx
@@ -0,0 +1,90 @@
+import { useEffect, useState } from "react";
+
+export default function Session() {
+ // 질문 상태
+ const [questions, setQuestions] = useState([]);
+ const [currentIndex, setCurrentIndex] = useState(0);
+
+ // 페이지 로드 시 랜덤 질문 생성
+ useEffect(() => {
+ const tech = [
+ "비동기 처리(Promise/async-await)의 차이와 에러 핸들링 전략을 설명해 주세요.",
+ "상태관리(Context, Redux, Zustand 등)를 선택할 때 기준은 무엇인가요?",
+ "HTTP/REST와 WebSocket의 차이점을 설명해 주세요.",
+ "데이터베이스 정규화와 비정규화의 차이점을 설명해 주세요.",
+ ];
+
+ const beh = [
+ "최근 가장 도전적이었던 경험은 무엇이었나요?",
+ "팀 내 갈등이 발생했을 때 어떻게 해결했나요?",
+ "실패 경험과 그 이후의 학습 과정을 말해 주세요.",
+ "압박 상황에서 자신을 어떻게 관리하나요?",
+ ];
+
+ // 기술 3 + 인성 2 랜덤 섞기
+ const randomTech = tech.sort(() => 0.5 - Math.random()).slice(0, 3);
+ const randomBeh = beh.sort(() => 0.5 - Math.random()).slice(0, 2);
+ const combined = [...randomTech, ...randomBeh].sort(() => 0.5 - Math.random());
+
+ setQuestions(combined);
+ }, []);
+
+ // 질문 아직 없을 때
+ if (questions.length === 0) {
+ return (
+
+ 질문 불러오는 중...
+
+ );
+ }
+
+ // 현재 질문 표시
+ const question = questions[currentIndex];
+
+ return (
+
+
+
+
면접 진행
+
+
+
+ {currentIndex + 1} / {questions.length} 문항
+
+
+
+ {question}
+
+
+
+ {currentIndex > 0 && (
+
+ )}
+
+ {currentIndex < questions.length - 1 ? (
+
+ ) : (
+
+ 결과 보기
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/services/interviewApi.js b/apps/web/src/services/interviewApi.js
new file mode 100644
index 0000000..e147360
--- /dev/null
+++ b/apps/web/src/services/interviewApi.js
@@ -0,0 +1,43 @@
+// src/api/interviewApi.js
+// 게이트웨이(Nginx)에서 `/api/interview/*` 프록시된다고 가정
+const BASE_URL = "/api/interview";
+
+/**
+ * 5문항(기술3+인성2) 랜덤 세트 가져오기
+ * GET /api/interview/start → { questions: [{type,text}] }
+ */
+export async function fetchQuestions() {
+ const res = await fetch(`${BASE_URL}/start`);
+ if (!res.ok) throw new Error(`질문 요청 실패: ${res.status} ${res.statusText}`);
+ return res.json(); // { questions }
+}
+
+/**
+ * 면접 결과 분석 요청 (multipart/form-data)
+ * POST /api/interview/analyze
+ *
+ * @param {{questions: Array<{type:string,text:string}>, answers: Array<{text:string|null,durationSec:number}>}} meta
+ * @param {Blob[]} [audioBlobs] // 길이 5, 없으면 생략 가능
+ * @returns {Promise<{summary:string, scores: Record
, detail:any}>}
+ */
+export async function analyzeInterview(meta, audioBlobs = []) {
+ const form = new FormData();
+ form.append("meta", JSON.stringify(meta)); // {questions, answers}
+
+ // audio_0..audio_4 형식으로 첨부 (선택)
+ audioBlobs.forEach((blob, idx) => {
+ if (blob) form.append(`audio_${idx}`, blob, `q${idx}.webm`);
+ });
+
+ const res = await fetch(`${BASE_URL}/analyze`, {
+ method: "POST",
+ body: form,
+ });
+
+ if (!res.ok) {
+ // 서버가 {error:"..."}를 주는 경우 대비
+ const err = await res.json().catch(() => ({}));
+ throw new Error(err.error || `분석 요청 실패: ${res.status} ${res.statusText}`);
+ }
+ return res.json(); // { summary, scores, detail }
+}
diff --git a/apps/web/src/store/interviewStore.js b/apps/web/src/store/interviewStore.js
new file mode 100644
index 0000000..2ef940b
--- /dev/null
+++ b/apps/web/src/store/interviewStore.js
@@ -0,0 +1,15 @@
+import { create } from "zustand";
+
+export const useInterviewStore = create((set) => ({
+ questions: [], // [{ type, text }]
+ currentIndex: 0, // 진행 중인 질문 인덱스
+ answers: [], // [{ blob, durationSec }]
+ result: null, // 분석 결과
+
+ // 액션들
+ setQuestions: (qs) => set({ questions: qs, currentIndex: 0, answers: [], result: null }),
+ setCurrentIndex: (i) => set({ currentIndex: i }),
+ addAnswer: (a) => set((s) => ({ answers: [...s.answers, a] })),
+ setResult: (r) => set({ result: r }),
+ reset: () => set({ questions: [], currentIndex: 0, answers: [], result: null }),
+}));
diff --git a/apps/web/src/utils/audio.js b/apps/web/src/utils/audio.js
new file mode 100644
index 0000000..708d8a6
--- /dev/null
+++ b/apps/web/src/utils/audio.js
@@ -0,0 +1,14 @@
+/** Blob → ArrayBuffer */
+export async function blobToArrayBuffer(blob) {
+ return await blob.arrayBuffer();
+}
+
+/** Blob → Base64 문자열 (분석 서버에 전송 시 사용 가능) */
+export async function blobToBase64(blob) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onloadend = () => resolve(reader.result.split(",")[1]);
+ reader.onerror = reject;
+ reader.readAsDataURL(blob);
+ });
+}
diff --git a/apps/web/src/utils/random.js b/apps/web/src/utils/random.js
new file mode 100644
index 0000000..b639472
--- /dev/null
+++ b/apps/web/src/utils/random.js
@@ -0,0 +1,16 @@
+/** 배열 셔플 */
+export function shuffle(arr) {
+ const a = [...arr];
+ for (let i = a.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [a[i], a[j]] = [a[j], a[i]];
+ }
+ return a;
+}
+
+/** 기술 + 인성 질문 샘플링 */
+export function sampleFive(TECH, BEH) {
+ const techQs = shuffle(TECH).slice(0, 3).map((t) => ({ type: "tech", text: t }));
+ const behQs = shuffle(BEH).slice(0, 2).map((t) => ({ type: "beh", text: t }));
+ return shuffle([...techQs, ...behQs]);
+}