+
{!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..96dea53 100644
--- a/apps/web/src/pages/Review.jsx
+++ b/apps/web/src/pages/Review.jsx
@@ -1,80 +1,239 @@
-// 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 [userComment, setUserComment] = useState("");
+ const [repoUrl, setRepoUrl] = 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) {
+ // 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 occurred.");
+ 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 {
+ /* noop */
+ }
+ };
return (
-
- {/* ... (Particles, header UI๋ ๋์ผ) ... */}
-
-
-
-
-
-
Review Feedback
-
- {/* ... (isLoading, error UI๋ ๋์ผ) ... */}
-
- {/* 5. (์์ ) formatAiResponse ํ
์ ์ง์ ํธ์ถ */}
- {review && (
-
- {formatAiResponse(review)}
+
+ {/* ๋ฐฐ๊ฒฝ ์
์ */}
+
+
+ {/* ์ฝํ
์ธ ๋ํผ */}
+
+ {/* ํค๋ */}
+
+
+
+ Home
+
+
+
+
+ AI Code Review
+
+
+
+
+
+
+ {/* ๋ฉ์ธ ๊ทธ๋ฆฌ๋: ๋์ด ๊ณ ์ + ๋ด๋ถ ์คํฌ๋กค */}
+
+ {/* LEFT: Code input */}
+
+
+
+
+ Paste Your Code
+
+
+ {/* ์ฝ๋ ์
๋ ฅ ์์ญ */}
+
+
+
+ {/* ํ๋จ ์
๋ ฅ (์ฝ๋ฉํธ / GitHub URL) + ๋ฒํผ */}
+
+
+
+
+
+ {/* RIGHT: Feedback */}
+
+
+
+
+ Review Feedback
+
+
+
+
+ {isLoading && !review && (
+
+
+
+ AI๊ฐ ์ฝ๋๋ฅผ ๋ถ์ ์ค์
๋๋ค...
+
+
+ ์ ์๋ง ๊ธฐ๋ค๋ ค์ฃผ์ธ์.
+
+
+ )}
+
+ {error && (
+
+
+
+ Error
+
+
{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]);
+}
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..56ca060
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,6 @@
+{
+ "name": "SkillBoost",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {}
+}